diff --git a/.docker-mongo/Dockerfile b/.docker-mongo/Dockerfile index 567b09c7730f..c2c038b05363 100644 --- a/.docker-mongo/Dockerfile +++ b/.docker-mongo/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12.16.1-buster-slim +FROM node:12.18.4-buster-slim LABEL maintainer="buildmaster@rocket.chat" diff --git a/.docker/Dockerfile b/.docker/Dockerfile index 4594afe41539..fbc23cceb3f3 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12.16.1-buster-slim +FROM node:12.18.4-buster-slim LABEL maintainer="buildmaster@rocket.chat" diff --git a/.docker/Dockerfile.rhel b/.docker/Dockerfile.rhel index de16afd2a318..adc641ae0a5a 100644 --- a/.docker/Dockerfile.rhel +++ b/.docker/Dockerfile.rhel @@ -1,6 +1,6 @@ FROM registry.access.redhat.com/rhscl/nodejs-8-rhel7 -ENV RC_VERSION 3.6.3 +ENV RC_VERSION 3.7.0 MAINTAINER buildmaster@rocket.chat diff --git a/.github/history.json b/.github/history.json index b3f28dc65772..1efde2fe2a08 100644 --- a/.github/history.json +++ b/.github/history.json @@ -49190,6 +49190,663 @@ } ] }, + "3.7.0-rc.0": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.18.0-beta.3848", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "18892", + "title": "New: Use database change streams when available", + "userLogin": "rodrigok", + "milestone": "3.7.0", + "contributors": [ + "rodrigok", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "18882", + "title": "[NEW] Send E2E encrypted messages’ content on push notifications", + "userLogin": "rodrigok", + "description": "Sends the content of end to end encrypted messages on Push Notifications allowing new versions of mobile apps to decrypt them and displays the content correctly.", + "milestone": "3.7.0", + "contributors": [ + "rodrigok", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "18955", + "title": "[NEW][Apps] Add a new upload API", + "userLogin": "lolimay", + "milestone": "3.7.0", + "contributors": [ + "lolimay", + "d-gubert", + "web-flow" + ] + }, + { + "pr": "18944", + "title": "[FIX] Purged threads still show as unread", + "userLogin": "FelipeParreira", + "description": "Remove threads from subscription (and update counter) when messages are purged (or threads are disabled).", + "contributors": [ + "sampaiodiego", + "FelipeParreira", + "web-flow", + "rodrigok" + ] + }, + { + "pr": "18979", + "title": "[FIX] Scrollbar mention ticks always rendering as white", + "userLogin": "gabriellsh", + "milestone": "3.7.0", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "18762", + "title": "Check i18n file for missing variables", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego", + "web-flow", + "rodrigok" + ] + }, + { + "pr": "18887", + "title": "Set some queries to prefer the secondary database", + "userLogin": "rodrigok", + "milestone": "3.7.0", + "contributors": [ + "rodrigok", + "web-flow" + ] + }, + { + "pr": "18975", + "title": "[NEW] Retention policy precision defined by a cron job expression", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "18974", + "title": "[NEW] Option to require settings on wizard UI via ENV variables", + "userLogin": "rodrigok", + "description": "[NEW] Option to require settings on wizard UI via ENV variables", + "milestone": "3.7.0", + "contributors": [ + "rodrigok", + "MartinSchoeler", + "web-flow" + ] + }, + { + "pr": "18977", + "title": "Bump Livechat widget", + "userLogin": "ggazzo", + "milestone": "3.7.0", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "18801", + "title": "Bump marked from 0.6.3 to 0.7.0", + "userLogin": "dependabot[bot]", + "contributors": [ + "dependabot[bot]", + "web-flow" + ] + }, + { + "pr": "18973", + "title": "LingoHub based on develop", + "userLogin": "lingohub[bot]", + "contributors": [ + "engelgabriel" + ] + }, + { + "pr": "18961", + "title": "[FIX] API call users.setStatus does not trigger status update of clients", + "userLogin": "FelipeParreira", + "description": "Notify logged users via WebSockets message when a user changes status via REST API.", + "contributors": [ + "FelipeParreira" + ] + }, + { + "pr": "18959", + "title": "Update Meteor to 1.11.1", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego", + "rodrigok" + ] + }, + { + "pr": "18976", + "title": "[FIX] User Info: Email and name/username display, alignment on big screens, make admin action", + "userLogin": "gabriellsh", + "milestone": "3.7.0", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "18608", + "title": "[IMPROVE] Stop re-sending push notifications rejected by the gateway", + "userLogin": "pierre-lehnen-rc", + "milestone": "3.7.0", + "contributors": [ + "pierre-lehnen-rc", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "17012", + "title": "[NEW] UploadFS respects $TMPDIR environment variable", + "userLogin": "d-sko", + "contributors": [ + "d-sko", + "web-flow" + ] + }, + { + "pr": "18912", + "title": "[NEW][Apps] Add a Livechat API - setCustomFields", + "userLogin": "lolimay", + "milestone": "3.7.0", + "contributors": [ + "lolimay", + "thassiov", + "web-flow", + "d-gubert" + ] + }, + { + "pr": "18946", + "title": "[NEW][Apps] Add support for new livechat guest's and room's events", + "userLogin": "thassiov", + "milestone": "3.7.0", + "contributors": [ + "thassiov", + "web-flow", + "d-gubert" + ] + }, + { + "pr": "18948", + "title": "[FIX] Errors in LDAP avatar sync preventing login", + "userLogin": "pierre-lehnen-rc", + "milestone": "3.7.0", + "contributors": [ + "pierre-lehnen-rc" + ] + }, + { + "pr": "18800", + "title": "Bump lodash.merge from 4.6.1 to 4.6.2", + "userLogin": "dependabot[bot]", + "contributors": [ + "dependabot[bot]", + "web-flow" + ] + }, + { + "pr": "18875", + "title": "[IMPROVE] Add \"Allow_Save_Media_to_Gallery\" setting", + "userLogin": "diegolmello", + "description": "- Added a new setting to allow/disallow saving media to device's gallery on mobile client", + "milestone": "3.7.0", + "contributors": [ + "diegolmello" + ] + }, + { + "pr": "18932", + "title": "Refactor: Admin permissions page", + "userLogin": "gabriellsh", + "milestone": "3.7.0", + "contributors": [ + "gabriellsh", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "18943", + "title": "[FIX] Users not being able to activate/deactivate E2E in DMs", + "userLogin": "MartinSchoeler", + "description": "[FIX] Users not being able to activate/deactivate E2E in DMs", + "milestone": "3.7.0", + "contributors": [ + "MartinSchoeler" + ] + }, + { + "pr": "18928", + "title": "[IMPROVE] Move jump to message outside menu", + "userLogin": "MartinSchoeler", + "milestone": "3.7.0", + "contributors": [ + "MartinSchoeler", + "web-flow" + ] + }, + { + "pr": "18947", + "title": "[FIX] \"Download my data\" popup showing HTML code.", + "userLogin": "pierre-lehnen-rc", + "milestone": "3.7.0", + "contributors": [ + "pierre-lehnen-rc" + ] + }, + { + "pr": "18808", + "title": "Refactor: Message Audit page & Audit logs", + "userLogin": "gabriellsh", + "milestone": "3.7.0", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "18832", + "title": "[FIX] Reaction buttons not behaving properly", + "userLogin": "gabriellsh", + "milestone": "3.7.0", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "18767", + "title": "[FIX] Deactivated users show as offline", + "userLogin": "gabriellsh", + "milestone": "3.7.0", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "18766", + "title": "Refactor: Omnichannel Analytics", + "userLogin": "gabriellsh", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "18950", + "title": "[FIX] Block user action", + "userLogin": "gabriellsh", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "18755", + "title": "[FIX] Open room after guest registration", + "userLogin": "ggazzo", + "milestone": "3.7.0", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "18667", + "title": "[FIX] Spurious expert role in startup data", + "userLogin": "gillesmeyer", + "milestone": "3.7.0", + "contributors": [ + null + ] + }, + { + "pr": "18956", + "title": "[FIX] PDF not rendering", + "userLogin": "gabriellsh", + "milestone": "3.6.3", + "contributors": [ + "gabriellsh", + "web-flow" + ] + }, + { + "pr": "18839", + "title": "[NEW] \"Room avatar changed\" system messages", + "userLogin": "pierre-lehnen-rc", + "milestone": "3.7.0", + "contributors": [ + "pierre-lehnen-rc" + ] + }, + { + "pr": "18830", + "title": "[FIX] Stop adding push messages to queue if push is disabled", + "userLogin": "pierre-lehnen-rc", + "milestone": "3.7.0", + "contributors": [ + "pierre-lehnen-rc" + ] + }, + { + "pr": "18794", + "title": "[FIX] Show custom fields of invalid type", + "userLogin": "gabriellsh", + "milestone": "3.6.2", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "18864", + "title": "[FIX] Deactivate users that are the last owner of a room using REST API", + "userLogin": "FelipeParreira", + "description": "Allow for user deactivation through REST API (even if user is the last owner of a room)", + "milestone": "3.6.2", + "contributors": [ + "FelipeParreira", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "18813", + "title": "[FIX] French: Add missing __online__ var", + "userLogin": "Karting06", + "contributors": [ + "Karting06" + ] + }, + { + "pr": "18814", + "title": "[FIX] Dutch: add translations for missing variables", + "userLogin": "Karting06", + "contributors": [ + "Karting06" + ] + }, + { + "pr": "18838", + "title": "[FIX] Custom fields required if minLength set and no text typed.", + "userLogin": "gabriellsh", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "18866", + "title": "[FIX] Ignore User action from user card", + "userLogin": "pierre-lehnen-rc", + "milestone": "3.6.2", + "contributors": [ + "pierre-lehnen-rc" + ] + }, + { + "pr": "18916", + "title": "[FIX] Version update check cron job", + "userLogin": "wreiske", + "milestone": "3.6.2", + "contributors": [ + "wreiske" + ] + }, + { + "pr": "18931", + "title": "[FIX] Non-upload requests being passed to UFS proxy middleware", + "userLogin": "FelipeParreira", + "description": "Avoid non-upload request to be caught by UFS proxy middleware.", + "contributors": [ + "FelipeParreira" + ] + }, + { + "pr": "18918", + "title": "[FIX] Read receipts showing blank names and not marking messages as read", + "userLogin": "wreiske", + "milestone": "3.6.2", + "contributors": [ + "wreiske" + ] + }, + { + "pr": "18883", + "title": "[FIX] \"Save to WebDav\" not working", + "userLogin": "pierre-lehnen-rc", + "milestone": "3.7.0", + "contributors": [ + "pierre-lehnen-rc" + ] + }, + { + "pr": "18874", + "title": "[FIX] If there is `ufs` somewhere in url the request to api always returns 404", + "userLogin": "FelipeParreira", + "contributors": [ + "FelipeParreira", + "web-flow" + ] + }, + { + "pr": "18828", + "title": "LingoHub based on develop", + "userLogin": "engelgabriel", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "18862", + "title": "[FIX] Showing alerts during setup wizard", + "userLogin": "sampaiodiego", + "milestone": "3.6.1", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "18837", + "title": "[FIX] Jitsi call start updating subscriptions", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "18795", + "title": "[FIX] Omnichannel Current Chats open status filter not working", + "userLogin": "renatobecker", + "milestone": "3.6.1", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "18852", + "title": "[FIX] User can't invite or join other Omnichannel rooms", + "userLogin": "renatobecker", + "milestone": "3.6.1", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "18835", + "title": "[FIX][ENTERPRISE] Omnichannel service status switching to unavailable", + "userLogin": "renatobecker", + "milestone": "3.6.1", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "18851", + "title": "[FIX] User administration throwing a blank page if user has no role", + "userLogin": "ggazzo", + "milestone": "3.6.1", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "18836", + "title": "[FIX] Can't change password", + "userLogin": "gabriellsh", + "milestone": "3.7.0", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "18850", + "title": "[FIX] IE11 support livechat widget", + "userLogin": "ggazzo", + "milestone": "3.6.1", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "18841", + "title": "[FIX] File upload (Avatars, Emoji, Sounds)", + "userLogin": "ggazzo", + "milestone": "3.6.1", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "18849", + "title": "Fix: Missing WebDav upload errors logs", + "userLogin": "pierre-lehnen-rc", + "milestone": "3.7.0", + "contributors": [ + "pierre-lehnen-rc" + ] + }, + { + "pr": "18834", + "title": "[FIX] e.sendToBottomIfNecessaryDebounced is not a function", + "userLogin": "gabriellsh", + "milestone": "3.7.0", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "18840", + "title": "Fix saveRoomSettings method complexity", + "userLogin": "pierre-lehnen-rc", + "milestone": "3.7.0", + "contributors": [ + "pierre-lehnen-rc" + ] + }, + { + "pr": "17377", + "title": "[FIX] Create Custom OAuth services from environment variables", + "userLogin": "mrtndwrd", + "milestone": "3.6.2", + "contributors": [ + "mrtndwrd", + "web-flow" + ] + }, + { + "pr": "18666", + "title": "Refactor: Omnichannel Realtime Monitoring", + "userLogin": "gabriellsh", + "milestone": "3.7.0", + "contributors": [ + "gabriellsh", + "web-flow", + "ggazzo" + ] + }, + { + "pr": "18746", + "title": "Fix french translations", + "userLogin": "lsignac", + "contributors": [ + "lsignac", + "web-flow" + ] + }, + { + "pr": "18761", + "title": "LingoHub based on develop", + "userLogin": "engelgabriel", + "contributors": [ + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "18754", + "title": "Update Meteor to 1.11", + "userLogin": "sampaiodiego", + "milestone": "3.7.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "18707", + "title": "Replace copying assets on post-install with symlinks", + "userLogin": "tassoevan", + "milestone": "3.7.0", + "contributors": [ + "tassoevan", + "web-flow" + ] + }, + { + "pr": "18686", + "title": "Do not use deprecated express API", + "userLogin": "sampaiodiego", + "milestone": "3.7.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "18752", + "title": "Merge master into develop & Set version to 3.7.0-develop", + "userLogin": "sampaiodiego", + "contributors": [ + "gabriellsh", + "sampaiodiego", + "ggazzo", + "renatobecker", + "thirsch", + "web-flow", + "pierre-lehnen-rc" + ] + } + ] + }, "3.6.1": { "node_version": "12.16.1", "npm_version": "6.14.0", @@ -49344,6 +50001,66 @@ } ] }, + "3.7.0-rc.1": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.18.0-beta.3848", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "18994", + "title": "[FIX] LDAP avatar upload", + "userLogin": "pierre-lehnen-rc", + "milestone": "3.7.0", + "contributors": [ + "pierre-lehnen-rc" + ] + }, + { + "pr": "18993", + "title": "Regression: Handle MongoDB authentication issues", + "userLogin": "rodrigok", + "milestone": "3.7.0", + "contributors": [ + "rodrigok" + ] + } + ] + }, + "3.7.0-rc.2": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.18.0-beta.3848", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "19020", + "title": "Obey to settings properties", + "userLogin": "sampaiodiego", + "milestone": "3.6.3", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "18978", + "title": "[FIX] Federation issues", + "userLogin": "alansikora", + "milestone": "3.6.3", + "contributors": [ + "alansikora" + ] + } + ] + }, "3.6.3": { "node_version": "12.16.1", "npm_version": "6.14.0", @@ -49354,6 +50071,17 @@ "4.0" ], "pull_requests": [ + { + "pr": "19022", + "title": "Release 3.6.3", + "userLogin": "sampaiodiego", + "contributors": [ + "gabriellsh", + "sampaiodiego", + "pierre-lehnen-rc", + "alansikora" + ] + }, { "pr": "19020", "title": "Obey to settings properties", @@ -49401,6 +50129,98 @@ ] } ] + }, + "3.7.0-rc.3": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.18.0", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "19005", + "title": "Regression: Elements select & multiSelect not rendered correctly in the App Settings", + "userLogin": "lolimay", + "milestone": "3.7.0", + "contributors": [ + "lolimay", + "d-gubert", + "web-flow" + ] + }, + { + "pr": "19047", + "title": "[NEW] Apps-Engine v1.18.0", + "userLogin": "d-gubert", + "contributors": [ + "d-gubert" + ] + }, + { + "pr": "18995", + "title": "Regression: File upload via apps not working in some scenarios", + "userLogin": "lolimay", + "milestone": "3.7.0", + "contributors": [ + "lolimay", + "d-gubert" + ] + }, + { + "pr": "19002", + "title": "[NEW][Apps] Add support to the \"encoding\" option in http requests from Apps", + "userLogin": "lolimay", + "milestone": "3.7.0", + "contributors": [ + "lolimay", + "d-gubert", + "web-flow" + ] + }, + { + "pr": "19033", + "title": "Regression: Fix login screen reactivity of external login providers", + "userLogin": "rodrigok", + "milestone": "3.7.0", + "contributors": [ + "rodrigok" + ] + } + ] + }, + "3.7.0-rc.4": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.18.0", + "mongo_versions": [ + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "18919", + "title": "[FIX] invite-all-from and invite-all-to commands don't work with multibyte room names", + "userLogin": "FelipeParreira", + "contributors": [ + "FelipeParreira" + ] + } + ] + }, + "3.7.0": { + "node_version": "12.18.4", + "npm_version": "6.14.8", + "apps_engine_version": "1.18.0", + "mongo_versions": [ + "3.4", + "3.6", + "4.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 6e2ce7b4819c..32ef0e1927a1 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -30,10 +30,10 @@ jobs: echo "github.event_name: ${{ github.event_name }}" cat $GITHUB_EVENT_PATH - - name: Use Node.js 12.16.1 + - name: Use Node.js 12.18.4 uses: actions/setup-node@v1 with: - node-version: "12.16.1" + node-version: "12.18.4" - uses: actions/checkout@v2 @@ -111,6 +111,8 @@ jobs: - run: meteor npm run lint + - run: meteor npm run translation-check + - name: Launch MongoDB uses: wbari/start-mongoDB@v0.2 with: @@ -175,7 +177,7 @@ jobs: strategy: matrix: - node-version: ["12.16.1"] + node-version: ["12.18.4"] mongodb-version: ["3.4", "3.6", "4.0"] steps: diff --git a/.meteor/packages b/.meteor/packages index 7447d19c9698..2a2c903bbd21 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -9,17 +9,17 @@ accounts-facebook@1.3.2 accounts-github@1.4.3 accounts-google@1.3.3 accounts-meteor-developer@1.4.2 -accounts-password@1.6.0 +accounts-password@1.6.2 accounts-twitter@1.4.2 blaze-html-templates check@1.3.1 -ddp-rate-limiter@1.0.7 +ddp-rate-limiter@1.0.9 ddp-common@1.4.0 dynamic-import@0.5.2 ecmascript@0.14.3 typescript@3.7.6 ejson@1.1.1 -email@1.2.3 +email@2.0.0 fastclick@1.0.13 http@1.4.2 jquery@1.11.10 @@ -70,7 +70,7 @@ littledata:synced-cron edgee:slingshot jalik:ufs-local@1.0.2 -accounts-base@1.6.0 +accounts-base@1.7.0 accounts-oauth@1.2.0 autoupdate@1.6.0 babel-compiler@7.5.3 diff --git a/.meteor/release b/.meteor/release index 3ea26528c096..019e3aefb00f 100644 --- a/.meteor/release +++ b/.meteor/release @@ -1 +1 @@ -METEOR@1.10.2 +METEOR@1.11.1 diff --git a/.meteor/versions b/.meteor/versions index 67b22e07d1c2..811fbd386ad1 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -1,10 +1,10 @@ -accounts-base@1.6.0 +accounts-base@1.7.0 accounts-facebook@1.3.2 accounts-github@1.4.3 accounts-google@1.3.3 accounts-meteor-developer@1.4.2 accounts-oauth@1.2.0 -accounts-password@1.6.1 +accounts-password@1.6.2 accounts-twitter@1.4.2 aldeed:simple-schema@1.5.4 allow-deny@1.1.0 @@ -16,7 +16,7 @@ binary-heap@1.0.11 blaze@2.3.4 blaze-html-templates@1.1.2 blaze-tools@1.0.10 -boilerplate-generator@1.7.0 +boilerplate-generator@1.7.1 caching-compiler@1.2.2 caching-html-compiler@1.1.3 callback-hook@1.3.0 @@ -27,7 +27,7 @@ dandv:caret-position@2.1.1 ddp@1.4.0 ddp-client@2.3.3 ddp-common@1.4.0 -ddp-rate-limiter@1.0.8 +ddp-rate-limiter@1.0.9 ddp-server@2.3.2 deepwell:bootstrap-datepicker2@1.3.0 deps@1.0.12 @@ -36,11 +36,11 @@ dispatch:run-as-user@1.1.1 dynamic-import@0.5.2 ecmascript@0.14.3 ecmascript-runtime@0.7.0 -ecmascript-runtime-client@0.10.0 -ecmascript-runtime-server@0.9.0 +ecmascript-runtime-client@0.11.0 +ecmascript-runtime-server@0.10.0 edgee:slingshot@0.7.1 ejson@1.1.1 -email@1.2.3 +email@2.0.0 es5-shim@4.8.0 facebook-oauth@1.7.0 facts-base@1.0.1 @@ -79,7 +79,7 @@ meteor-base@1.4.0 meteor-developer-oauth@1.2.1 meteorhacks:inject-initial@1.0.4 meteorspark:util@0.2.0 -minifier-css@1.5.2 +minifier-css@1.5.3 minifier-js@2.6.0 minimongo@1.6.0 mizzao:timesync@0.3.4 @@ -97,7 +97,7 @@ mystor:device-detection@0.2.0 nimble:restivus@0.8.12 nooitaf:colors@1.1.2_1 npm-bcrypt@0.9.3 -npm-mongo@3.7.1 +npm-mongo@3.8.1 oauth@1.3.0 oauth1@1.3.0 oauth2@1.3.0 diff --git a/.scripts/check-i18n.js b/.scripts/check-i18n.js new file mode 100644 index 000000000000..f6707402ca17 --- /dev/null +++ b/.scripts/check-i18n.js @@ -0,0 +1,90 @@ +const fs = require('fs'); + +const fg = require('fast-glob'); + +const checkFiles = async (path, source) => { + const sourceFile = JSON.parse(fs.readFileSync(`${ path }${ source }`, 'utf8')); + + const regexVar = /__([a-zA-Z_]+?)__/g; + + const usedKeys = Object.entries(sourceFile) + .filter(([, value]) => regexVar.exec(value)) + .map(([key, value]) => { + const replaces = value.match(regexVar); + return { + key, + replaces, + }; + }); + + const validateKeys = (json) => + usedKeys + .filter(({ key }) => typeof json[key] !== 'undefined') + .reduce((prev, cur) => { + const { key, replaces } = cur; + + const miss = replaces.filter((replace) => json[key].indexOf(replace) === -1); + + if (miss.length > 0) { + prev.push({ key, miss }); + } + + return prev; + }, []); + + const i18nFiles = await fg([`${ path }/**/*.i18n.json`]); + + // const getInvalidKeys = (json) => + // usedKeys + // .filter(({ key }) => typeof json[key] !== 'undefined') + // .filter(({ key, replaces }) => { + // const miss = replaces.filter((replace) => json[key].indexOf(replace) === -1); + + // return miss.length > 0; + // }) + // .map(({ key }) => key); + + // const removeMissingKeys = () => { + // const allKeys = Object.keys(sourceFile); + // i18nFiles.forEach((file) => { + // const json = JSON.parse(fs.readFileSync(file, 'utf8')); + + // const invalidKeys = getInvalidKeys(json); + + // const validKeys = allKeys.filter((key) => !invalidKeys.includes(key)); + // // console.log('validKeys', file, validKeys); + + // fs.writeFileSync(file, JSON.stringify(json, validKeys, 2)); + // }); + // }; + + let totalErrors = 0; + i18nFiles.filter((file) => { + const json = JSON.parse(fs.readFileSync(file, 'utf8')); + + const result = validateKeys(json); + + if (result.length === 0) { + return true; + } + + totalErrors += result.length; + + console.log('\n## File', file, `(${ result.length } errors)`); + + result.forEach(({ key, miss }) => { + console.log('\n- Key:', key, '\n Missing variables:', miss.join(', ')); + }); + + return false; + }); + + if (totalErrors > 0) { + console.error(`\n${ totalErrors } errors found`); + process.exit(1); + } + + process.exit(0); +}; + +checkFiles('./packages/rocketchat-i18n', '/i18n/en.i18n.json'); diff --git a/.scripts/npm-postinstall.js b/.scripts/npm-postinstall.js deleted file mode 100644 index b8362a2401de..000000000000 --- a/.scripts/npm-postinstall.js +++ /dev/null @@ -1,11 +0,0 @@ - -const { execSync } = require('child_process'); - -console.log('Running npm-postinstall.js'); - -execSync('cp node_modules/katex/dist/katex.min.css app/katex/'); - -execSync('mkdir -p public/fonts/'); -execSync('cp node_modules/katex/dist/fonts/* public/fonts/'); - -execSync('cp node_modules/pdfjs-dist/build/pdf.worker.min.js public/'); diff --git a/.snapcraft/resources/prepareRocketChat b/.snapcraft/resources/prepareRocketChat index f3fa54fb1f0e..5495097da7b2 100755 --- a/.snapcraft/resources/prepareRocketChat +++ b/.snapcraft/resources/prepareRocketChat @@ -1,6 +1,6 @@ #!/bin/bash -curl -SLf "https://releases.rocket.chat/3.6.3/download/" -o rocket.chat.tgz +curl -SLf "https://releases.rocket.chat/3.7.0/download/" -o rocket.chat.tgz tar xf rocket.chat.tgz --strip 1 diff --git a/.snapcraft/resources/preparenode b/.snapcraft/resources/preparenode index 4af07dea1c3d..abe5bba3afa7 100755 --- a/.snapcraft/resources/preparenode +++ b/.snapcraft/resources/preparenode @@ -1,6 +1,6 @@ #!/bin/bash -node_version="v12.16.1" +node_version="v12.18.4" unamem="$(uname -m)" if [[ $unamem == *aarch64* ]]; then diff --git a/.snapcraft/snap/snapcraft.yaml b/.snapcraft/snap/snapcraft.yaml index 9b36a2e528f2..cd6e6e545e82 100644 --- a/.snapcraft/snap/snapcraft.yaml +++ b/.snapcraft/snap/snapcraft.yaml @@ -7,7 +7,7 @@ # 5. `snapcraft snap` name: rocketchat-server -version: 3.6.3 +version: 3.7.0 summary: Rocket.Chat server description: Have your own Slack like online chat, built with Meteor. https://rocket.chat/ confinement: strict diff --git a/HISTORY.md b/HISTORY.md index 7204e6baf805..1002c3c1ab76 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,230 @@ +# 3.7.0 +`2020-09-28 · 10 🎉 · 3 🚀 · 39 🐛 · 26 🔍 · 22 👩‍💻👨‍💻` + +### Engine versions +- Node: `12.18.4` +- NPM: `6.14.8` +- MongoDB: `3.4, 3.6, 4.0` +- Apps-Engine: `1.18.0` + +### 🎉 New features + + +- "Room avatar changed" system messages ([#18839](https://github.com/RocketChat/Rocket.Chat/pull/18839)) + +- **Apps:** Add a Livechat API - setCustomFields ([#18912](https://github.com/RocketChat/Rocket.Chat/pull/18912)) + +- **Apps:** Add a new upload API ([#18955](https://github.com/RocketChat/Rocket.Chat/pull/18955)) + +- **Apps:** Add support for new livechat guest's and room's events ([#18946](https://github.com/RocketChat/Rocket.Chat/pull/18946)) + +- **Apps:** Add support to the "encoding" option in http requests from Apps ([#19002](https://github.com/RocketChat/Rocket.Chat/pull/19002)) + +- Apps-Engine v1.18.0 ([#19047](https://github.com/RocketChat/Rocket.Chat/pull/19047)) + +- Option to require settings on wizard UI via ENV variables ([#18974](https://github.com/RocketChat/Rocket.Chat/pull/18974)) + + [NEW] Option to require settings on wizard UI via ENV variables + +- Retention policy precision defined by a cron job expression ([#18975](https://github.com/RocketChat/Rocket.Chat/pull/18975)) + +- Send E2E encrypted messages’ content on push notifications ([#18882](https://github.com/RocketChat/Rocket.Chat/pull/18882)) + + Sends the content of end to end encrypted messages on Push Notifications allowing new versions of mobile apps to decrypt them and displays the content correctly. + +- UploadFS respects $TMPDIR environment variable ([#17012](https://github.com/RocketChat/Rocket.Chat/pull/17012) by [@d-sko](https://github.com/d-sko)) + +### 🚀 Improvements + + +- Add "Allow_Save_Media_to_Gallery" setting ([#18875](https://github.com/RocketChat/Rocket.Chat/pull/18875)) + + - Added a new setting to allow/disallow saving media to device's gallery on mobile client + +- Move jump to message outside menu ([#18928](https://github.com/RocketChat/Rocket.Chat/pull/18928)) + +- Stop re-sending push notifications rejected by the gateway ([#18608](https://github.com/RocketChat/Rocket.Chat/pull/18608)) + +### 🐛 Bug fixes + + +- "Download my data" popup showing HTML code. ([#18947](https://github.com/RocketChat/Rocket.Chat/pull/18947)) + +- "Save to WebDav" not working ([#18883](https://github.com/RocketChat/Rocket.Chat/pull/18883)) + +- **ENTERPRISE:** Omnichannel service status switching to unavailable ([#18835](https://github.com/RocketChat/Rocket.Chat/pull/18835)) + +- API call users.setStatus does not trigger status update of clients ([#18961](https://github.com/RocketChat/Rocket.Chat/pull/18961) by [@FelipeParreira](https://github.com/FelipeParreira)) + + Notify logged users via WebSockets message when a user changes status via REST API. + +- Block user action ([#18950](https://github.com/RocketChat/Rocket.Chat/pull/18950)) + +- Can't change password ([#18836](https://github.com/RocketChat/Rocket.Chat/pull/18836)) + +- Create Custom OAuth services from environment variables ([#17377](https://github.com/RocketChat/Rocket.Chat/pull/17377) by [@mrtndwrd](https://github.com/mrtndwrd)) + +- Custom fields required if minLength set and no text typed. ([#18838](https://github.com/RocketChat/Rocket.Chat/pull/18838)) + +- Deactivate users that are the last owner of a room using REST API ([#18864](https://github.com/RocketChat/Rocket.Chat/pull/18864) by [@FelipeParreira](https://github.com/FelipeParreira)) + + Allow for user deactivation through REST API (even if user is the last owner of a room) + +- Deactivated users show as offline ([#18767](https://github.com/RocketChat/Rocket.Chat/pull/18767)) + +- Dutch: add translations for missing variables ([#18814](https://github.com/RocketChat/Rocket.Chat/pull/18814) by [@Karting06](https://github.com/Karting06)) + +- e.sendToBottomIfNecessaryDebounced is not a function ([#18834](https://github.com/RocketChat/Rocket.Chat/pull/18834)) + +- Errors in LDAP avatar sync preventing login ([#18948](https://github.com/RocketChat/Rocket.Chat/pull/18948)) + +- Federation issues ([#18978](https://github.com/RocketChat/Rocket.Chat/pull/18978)) + +- File upload (Avatars, Emoji, Sounds) ([#18841](https://github.com/RocketChat/Rocket.Chat/pull/18841)) + +- French: Add missing __online__ var ([#18813](https://github.com/RocketChat/Rocket.Chat/pull/18813) by [@Karting06](https://github.com/Karting06)) + +- IE11 support livechat widget ([#18850](https://github.com/RocketChat/Rocket.Chat/pull/18850)) + +- If there is `ufs` somewhere in url the request to api always returns 404 ([#18874](https://github.com/RocketChat/Rocket.Chat/pull/18874) by [@FelipeParreira](https://github.com/FelipeParreira)) + +- Ignore User action from user card ([#18866](https://github.com/RocketChat/Rocket.Chat/pull/18866)) + +- invite-all-from and invite-all-to commands don't work with multibyte room names ([#18919](https://github.com/RocketChat/Rocket.Chat/pull/18919) by [@FelipeParreira](https://github.com/FelipeParreira)) + +- Jitsi call start updating subscriptions ([#18837](https://github.com/RocketChat/Rocket.Chat/pull/18837)) + +- LDAP avatar upload ([#18994](https://github.com/RocketChat/Rocket.Chat/pull/18994)) + +- Non-upload requests being passed to UFS proxy middleware ([#18931](https://github.com/RocketChat/Rocket.Chat/pull/18931) by [@FelipeParreira](https://github.com/FelipeParreira)) + + Avoid non-upload request to be caught by UFS proxy middleware. + +- Omnichannel Current Chats open status filter not working ([#18795](https://github.com/RocketChat/Rocket.Chat/pull/18795)) + +- Open room after guest registration ([#18755](https://github.com/RocketChat/Rocket.Chat/pull/18755)) + +- PDF not rendering ([#18956](https://github.com/RocketChat/Rocket.Chat/pull/18956)) + +- Purged threads still show as unread ([#18944](https://github.com/RocketChat/Rocket.Chat/pull/18944) by [@FelipeParreira](https://github.com/FelipeParreira)) + + Remove threads from subscription (and update counter) when messages are purged (or threads are disabled). + +- Reaction buttons not behaving properly ([#18832](https://github.com/RocketChat/Rocket.Chat/pull/18832)) + +- Read receipts showing blank names and not marking messages as read ([#18918](https://github.com/RocketChat/Rocket.Chat/pull/18918) by [@wreiske](https://github.com/wreiske)) + +- Scrollbar mention ticks always rendering as white ([#18979](https://github.com/RocketChat/Rocket.Chat/pull/18979)) + +- Show custom fields of invalid type ([#18794](https://github.com/RocketChat/Rocket.Chat/pull/18794)) + +- Showing alerts during setup wizard ([#18862](https://github.com/RocketChat/Rocket.Chat/pull/18862)) + +- Spurious expert role in startup data ([#18667](https://github.com/RocketChat/Rocket.Chat/pull/18667)) + +- Stop adding push messages to queue if push is disabled ([#18830](https://github.com/RocketChat/Rocket.Chat/pull/18830)) + +- User administration throwing a blank page if user has no role ([#18851](https://github.com/RocketChat/Rocket.Chat/pull/18851)) + +- User can't invite or join other Omnichannel rooms ([#18852](https://github.com/RocketChat/Rocket.Chat/pull/18852)) + +- User Info: Email and name/username display, alignment on big screens, make admin action ([#18976](https://github.com/RocketChat/Rocket.Chat/pull/18976)) + +- Users not being able to activate/deactivate E2E in DMs ([#18943](https://github.com/RocketChat/Rocket.Chat/pull/18943)) + + [FIX] Users not being able to activate/deactivate E2E in DMs + +- Version update check cron job ([#18916](https://github.com/RocketChat/Rocket.Chat/pull/18916) by [@wreiske](https://github.com/wreiske)) + +
+🔍 Minor changes + + +- Bump Livechat widget ([#18977](https://github.com/RocketChat/Rocket.Chat/pull/18977)) + +- Bump lodash.merge from 4.6.1 to 4.6.2 ([#18800](https://github.com/RocketChat/Rocket.Chat/pull/18800) by [@dependabot[bot]](https://github.com/dependabot[bot])) + +- Bump marked from 0.6.3 to 0.7.0 ([#18801](https://github.com/RocketChat/Rocket.Chat/pull/18801) by [@dependabot[bot]](https://github.com/dependabot[bot])) + +- Check i18n file for missing variables ([#18762](https://github.com/RocketChat/Rocket.Chat/pull/18762)) + +- Do not use deprecated express API ([#18686](https://github.com/RocketChat/Rocket.Chat/pull/18686)) + +- Fix french translations ([#18746](https://github.com/RocketChat/Rocket.Chat/pull/18746) by [@lsignac](https://github.com/lsignac)) + +- Fix saveRoomSettings method complexity ([#18840](https://github.com/RocketChat/Rocket.Chat/pull/18840)) + +- Fix: Missing WebDav upload errors logs ([#18849](https://github.com/RocketChat/Rocket.Chat/pull/18849)) + +- LingoHub based on develop ([#18973](https://github.com/RocketChat/Rocket.Chat/pull/18973)) + +- LingoHub based on develop ([#18828](https://github.com/RocketChat/Rocket.Chat/pull/18828)) + +- LingoHub based on develop ([#18761](https://github.com/RocketChat/Rocket.Chat/pull/18761)) + +- Merge master into develop & Set version to 3.7.0-develop ([#18752](https://github.com/RocketChat/Rocket.Chat/pull/18752) by [@thirsch](https://github.com/thirsch)) + +- New: Use database change streams when available ([#18892](https://github.com/RocketChat/Rocket.Chat/pull/18892)) + +- Obey to settings properties ([#19020](https://github.com/RocketChat/Rocket.Chat/pull/19020)) + +- Refactor: Admin permissions page ([#18932](https://github.com/RocketChat/Rocket.Chat/pull/18932)) + +- Refactor: Message Audit page & Audit logs ([#18808](https://github.com/RocketChat/Rocket.Chat/pull/18808)) + +- Refactor: Omnichannel Analytics ([#18766](https://github.com/RocketChat/Rocket.Chat/pull/18766)) + +- Refactor: Omnichannel Realtime Monitoring ([#18666](https://github.com/RocketChat/Rocket.Chat/pull/18666)) + +- Regression: Elements select & multiSelect not rendered correctly in the App Settings ([#19005](https://github.com/RocketChat/Rocket.Chat/pull/19005)) + +- Regression: File upload via apps not working in some scenarios ([#18995](https://github.com/RocketChat/Rocket.Chat/pull/18995)) + +- Regression: Fix login screen reactivity of external login providers ([#19033](https://github.com/RocketChat/Rocket.Chat/pull/19033)) + +- Regression: Handle MongoDB authentication issues ([#18993](https://github.com/RocketChat/Rocket.Chat/pull/18993)) + +- Replace copying assets on post-install with symlinks ([#18707](https://github.com/RocketChat/Rocket.Chat/pull/18707)) + +- Set some queries to prefer the secondary database ([#18887](https://github.com/RocketChat/Rocket.Chat/pull/18887)) + +- Update Meteor to 1.11 ([#18754](https://github.com/RocketChat/Rocket.Chat/pull/18754)) + +- Update Meteor to 1.11.1 ([#18959](https://github.com/RocketChat/Rocket.Chat/pull/18959)) + +
+ +### 👩‍💻👨‍💻 Contributors 😍 + +- [@FelipeParreira](https://github.com/FelipeParreira) +- [@Karting06](https://github.com/Karting06) +- [@d-sko](https://github.com/d-sko) +- [@dependabot[bot]](https://github.com/dependabot[bot]) +- [@lsignac](https://github.com/lsignac) +- [@mrtndwrd](https://github.com/mrtndwrd) +- [@thirsch](https://github.com/thirsch) +- [@wreiske](https://github.com/wreiske) + +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@MartinSchoeler](https://github.com/MartinSchoeler) +- [@alansikora](https://github.com/alansikora) +- [@d-gubert](https://github.com/d-gubert) +- [@diegolmello](https://github.com/diegolmello) +- [@engelgabriel](https://github.com/engelgabriel) +- [@gabriellsh](https://github.com/gabriellsh) +- [@ggazzo](https://github.com/ggazzo) +- [@lolimay](https://github.com/lolimay) +- [@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) +- [@thassiov](https://github.com/thassiov) + # 3.6.3 -`2020-09-25 · 4 🐛 · 1 🔍 · 4 👩‍💻👨‍💻` +`2020-09-25 · 4 🐛 · 2 🔍 · 4 👩‍💻👨‍💻` ### Engine versions - Node: `12.16.1` @@ -25,6 +249,8 @@ - Obey to settings properties ([#19020](https://github.com/RocketChat/Rocket.Chat/pull/19020)) +- Release 3.6.3 ([#19022](https://github.com/RocketChat/Rocket.Chat/pull/19022)) + ### 👩‍💻👨‍💻 Core Team 🤓 diff --git a/README.md b/README.md index fc7c9786eff7..7b526a027b93 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The WideChat repo is a fork of RocketChat for development of features to be merg [![Code Climate](https://codeclimate.com/github/RocketChat/Rocket.Chat/badges/gpa.svg)](https://codeclimate.com/github/RocketChat/Rocket.Chat) [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://github.com/RocketChat/Rocket.Chat/raw/master/LICENSE) -* [**NEW!** Rocket.Chat Moving to a Single Codebase](#moving-to-a-single-codebase) +* [**NEW!** Rocket.Chat Moving to a Single Codebase](#moving-to-a-single-codebase) * [Community](#community) * [Mobile Apps](#mobile-apps) * [Desktop Apps](#desktop-apps) @@ -366,7 +366,7 @@ Prerequisites: * [Git](http://git-scm.com/book/en/v2/Getting-Started-Installing-Git) * [Meteor](https://www.meteor.com/install) -> Meteor automatically installs a hidden [NodeJS v12](https://nodejs.org/download/release/v12.16.1/) and [MongoDB v4.2](https://docs.mongodb.com/manual/introduction/) to be used when you run your app in development mode using the `meteor` command. +> Meteor automatically installs a hidden [NodeJS v12](https://nodejs.org/download/release/v12.18.4/) and [MongoDB v4.2](https://docs.mongodb.com/manual/introduction/) to be used when you run your app in development mode using the `meteor` command. Now just clone and start the app: diff --git a/VIP Sponsors.md b/VIP Sponsors.md new file mode 100644 index 000000000000..58c2ae6f6c2f --- /dev/null +++ b/VIP Sponsors.md @@ -0,0 +1,7 @@ +# Rocket.Chat VIP Sponsors + +These are the people supporting Rocket.Chat. Thank you very much!! + + +- [Johannes Kinast](https://github.com/goaround) +- [sAuCE](https://github.com/lukejw) diff --git a/app/api/server/lib/rooms.js b/app/api/server/lib/rooms.js index 93535314e319..12d807f3cf4c 100644 --- a/app/api/server/lib/rooms.js +++ b/app/api/server/lib/rooms.js @@ -94,6 +94,8 @@ export async function findChannelAndPrivateAutocomplete({ uid, selector }) { fields: { _id: 1, name: 1, + t: 1, + avatarETag: 1, }, limit: 10, sort: { diff --git a/app/api/server/v1/roles.js b/app/api/server/v1/roles.js index 7772f80e29d7..dbf967b84833 100644 --- a/app/api/server/v1/roles.js +++ b/app/api/server/v1/roles.js @@ -83,6 +83,7 @@ API.v1.addRoute('roles.getUsersInRole', { authRequired: true }, { name: 1, username: 1, emails: 1, + avatarETag: 1, }; if (!role) { @@ -99,7 +100,7 @@ API.v1.addRoute('roles.getUsersInRole', { authRequired: true }, { sort: { username: 1 }, skip: offset, fields, - }).fetch(); - return API.v1.success({ users }); + }); + return API.v1.success({ users: users.fetch(), total: users.count() }); }, }); diff --git a/app/api/server/v1/settings.js b/app/api/server/v1/settings.js index e323a635ddf2..cf926f44d478 100644 --- a/app/api/server/v1/settings.js +++ b/app/api/server/v1/settings.js @@ -6,7 +6,7 @@ import _ from 'underscore'; import { Settings } from '../../../models/server'; import { hasPermission } from '../../../authorization'; import { API } from '../api'; -import { SettingsEvents } from '../../../settings/server'; +import { SettingsEvents, settings } from '../../../settings/server'; const fetchSettings = (query, sort, offset, count, fields) => { const settings = Settings.find(query, { @@ -146,6 +146,10 @@ API.v1.addRoute('settings/:_id', { authRequired: true }, { value: Match.Any, }); if (Settings.updateValueNotHiddenById(this.urlParams._id, this.bodyParams.value)) { + settings.storeSettingValue({ + _id: this.urlParams._id, + value: this.bodyParams.value, + }); return API.v1.success(); } diff --git a/app/api/server/v1/users.js b/app/api/server/v1/users.js index 789848739e5b..b4adea3365d3 100644 --- a/app/api/server/v1/users.js +++ b/app/api/server/v1/users.js @@ -22,6 +22,7 @@ import { setStatusText } from '../../../lib/server'; import { findUsersToAutocomplete } from '../lib/users'; import { getUserForCheck, emailCheck } from '../../../2fa/server/code'; import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKey'; +import { setUserStatus } from '../../../../imports/users-presence/server/activeUsers'; API.v1.addRoute('users.create', { authRequired: true }, { post() { @@ -417,12 +418,14 @@ API.v1.addRoute('users.setStatus', { authRequired: true }, { if (this.bodyParams.status) { const validStatus = ['online', 'away', 'offline', 'busy']; if (validStatus.includes(this.bodyParams.status)) { + const { status } = this.bodyParams; Meteor.users.update(user._id, { $set: { - status: this.bodyParams.status, - statusDefault: this.bodyParams.status, + status, + statusDefault: status, }, }); + setUserStatus(user, status); } else { throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', { method: 'users.setStatus', diff --git a/app/apps/lib/misc/determineFileType.js b/app/apps/lib/misc/determineFileType.js new file mode 100644 index 000000000000..8cc15ebfcf3b --- /dev/null +++ b/app/apps/lib/misc/determineFileType.js @@ -0,0 +1,19 @@ +import fileType from 'file-type'; + +import { mime as MIME } from '../../../utils/lib/mimeTypes'; + +export function determineFileType(buffer, details) { + const mime = MIME.lookup(details.name); + + if (mime) { + return Array.isArray(mime) ? mime[0] : mime; + } + + const detectedType = fileType(buffer); + + if (detectedType) { + return detectedType.mime; + } + + return 'application/octet-stream'; +} diff --git a/app/apps/server/bridges/api.js b/app/apps/server/bridges/api.js index 47801c7d644a..ab2fbbfd626e 100644 --- a/app/apps/server/bridges/api.js +++ b/app/apps/server/bridges/api.js @@ -24,7 +24,7 @@ export class AppApisBridge { // }); apiServer.use('/api/apps/private/:appId/:hash', (req, res) => { - const notFound = () => res.send(404); + const notFound = () => res.sendStatus(404); const router = this.appRouters.get(req.params.appId); @@ -37,7 +37,7 @@ export class AppApisBridge { }); apiServer.use('/api/apps/public/:appId', (req, res) => { - const notFound = () => res.send(404); + const notFound = () => res.sendStatus(404); const router = this.appRouters.get(req.params.appId); diff --git a/app/apps/server/bridges/http.js b/app/apps/server/bridges/http.js index 1cfc983b9a99..035b7e0ee99b 100644 --- a/app/apps/server/bridges/http.js +++ b/app/apps/server/bridges/http.js @@ -10,6 +10,11 @@ import { HTTP } from 'meteor/http'; function normalizeHttpOptions(options) { const npmRequestOptions = {}; + if (options.hasOwnProperty('encoding')) { + npmRequestOptions.encoding = options.encoding; + delete options.encoding; + } + if (options.hasOwnProperty('strictSSL')) { npmRequestOptions.strictSSL = options.strictSSL; delete options.strictSSL; diff --git a/app/apps/server/bridges/listeners.js b/app/apps/server/bridges/listeners.js index d0cf92eec0bd..c387ff755980 100644 --- a/app/apps/server/bridges/listeners.js +++ b/app/apps/server/bridges/listeners.js @@ -38,6 +38,8 @@ export class AppListenerBridge { case AppInterface.IPostLivechatAgentAssigned: case AppInterface.IPostLivechatAgentUnassigned: case AppInterface.IPostLivechatRoomTransferred: + case AppInterface.IPostLivechatGuestSaved: + case AppInterface.IPostLivechatRoomSaved: return 'livechatEvent'; case AppInterface.IUIKitInteractionHandler: case AppInterface.IUIKitLivechatInteractionHandler: @@ -107,7 +109,10 @@ export class AppListenerBridge { from: this.orch.getConverters().get(converter).convertById(data.from), to: this.orch.getConverters().get(converter).convertById(data.to), }); - + case AppInterface.IPostLivechatGuestSaved: + return this.orch.getManager().getListenerManager().executeListener(inte, this.orch.getConverters().get('visitors').convertById(data)); + case AppInterface.IPostLivechatRoomSaved: + return this.orch.getManager().getListenerManager().executeListener(inte, this.orch.getConverters().get('rooms').convertById(data)); default: const room = this.orch.getConverters().get('rooms').convertRoom(data); diff --git a/app/apps/server/bridges/livechat.js b/app/apps/server/bridges/livechat.js index 0e4559a88615..28d635ffcf21 100644 --- a/app/apps/server/bridges/livechat.js +++ b/app/apps/server/bridges/livechat.js @@ -194,12 +194,6 @@ export class AppLivechatBridge { return LivechatVisitors.find(query).fetch().map((visitor) => this.orch.getConverters().get('visitors').convertVisitor(visitor)); } - async setCustomFields(data, appId) { - this.orch.debugLog(`The App ${ appId } is setting livechat visitor's custom fields.`); - - return Livechat.setCustomFields(data); - } - async findVisitorById(id, appId) { this.orch.debugLog(`The App ${ appId } is looking for livechat visitors.`); @@ -229,4 +223,10 @@ export class AppLivechatBridge { return this.orch.getConverters().get('departments').convertDepartment(LivechatDepartment.findOneByIdOrName(value)); } + + async setCustomFields(data, appId) { + this.orch.debugLog(`The App ${ appId } is setting livechat visitor's custom fields.`); + + return Livechat.setCustomFields(data); + } } diff --git a/app/apps/server/bridges/uploads.js b/app/apps/server/bridges/uploads.js index fced605713f9..99a3745811e8 100644 --- a/app/apps/server/bridges/uploads.js +++ b/app/apps/server/bridges/uploads.js @@ -1,4 +1,7 @@ +import { Meteor } from 'meteor/meteor'; + import { FileUpload } from '../../../file-upload/server'; +import { determineFileType } from '../../lib/misc/determineFileType'; export class AppUploadBridge { constructor(orch) { @@ -26,4 +29,41 @@ export class AppUploadBridge { }); }); } + + async createUpload(details, buffer, appId) { + this.orch.debugLog(`The App ${ appId } is creating an upload "${ details.name }"`); + + if (!details.userId && !details.visitorToken) { + throw new Error('Missing user to perform the upload operation'); + } + + if (details.visitorToken) { + delete details.userId; + } + + const fileStore = FileUpload.getStore('Uploads'); + const insertSync = details.userId + ? (...args) => Meteor.runAsUser(details.userId, () => fileStore.insertSync(...args)) + : Meteor.wrapAsync(fileStore.insert.bind(fileStore)); + + details.type = determineFileType(buffer, details); + + return new Promise(Meteor.bindEnvironment((resolve, reject) => { + try { + const uploadedFile = insertSync(details, buffer); + + if (details.visitorToken) { + Meteor.call('sendFileLivechatMessage', details.rid, details.visitorToken, uploadedFile); + } else { + Meteor.runAsUser(details.userId, () => { + Meteor.call('sendFileMessage', details.rid, null, uploadedFile); + }); + } + + resolve(this.orch.getConverters().get('uploads').convertToApp(uploadedFile)); + } catch (err) { + reject(err); + } + })); + } } diff --git a/app/apps/server/communication/uikit.js b/app/apps/server/communication/uikit.js index 0eb8bb87d42d..15cd35338c5b 100644 --- a/app/apps/server/communication/uikit.js +++ b/app/apps/server/communication/uikit.js @@ -141,7 +141,7 @@ export class AppUIKitInteractionApi { try { Promise.await(this.orch.triggerEvent('IUIKitInteractionHandler', action)); - res.send(200); + res.sendStatus(200); } catch (e) { console.log(e); res.status(500).send(e.message); diff --git a/app/assets/server/assets.js b/app/assets/server/assets.js index 0c5f70e2724c..73562e165200 100644 --- a/app/assets/server/assets.js +++ b/app/assets/server/assets.js @@ -7,8 +7,7 @@ import _ from 'underscore'; import sizeOf from 'image-size'; import sharp from 'sharp'; -import { settings } from '../../settings'; -import { Settings } from '../../models'; +import { settings } from '../../settings/server'; import { getURL } from '../../utils/lib/getURL'; import { mime } from '../../utils/lib/mimeTypes'; import { hasPermission } from '../../authorization'; @@ -367,19 +366,7 @@ for (const key of Object.keys(assets)) { addAssetToSetting(key, value); } -Settings.find().observe({ - added(record) { - return RocketChatAssets.processAsset(record._id, record.value); - }, - - changed(record) { - return RocketChatAssets.processAsset(record._id, record.value); - }, - - removed(record) { - return RocketChatAssets.processAsset(record._id, undefined); - }, -}); +settings.get(/^Assets_/, (key, value) => RocketChatAssets.processAsset(key, value)); Meteor.startup(function() { return Meteor.setTimeout(function() { diff --git a/app/authorization/client/index.js b/app/authorization/client/index.js index ca382bacc82b..bf484b064551 100644 --- a/app/authorization/client/index.js +++ b/app/authorization/client/index.js @@ -3,9 +3,7 @@ import { hasRole } from './hasRole'; import { AuthorizationUtils } from '../lib/AuthorizationUtils'; import './usersNameChanged'; import './requiresPermission.html'; -import './route'; import './startup'; -import './stylesheets/permissions.css'; export { hasAllPermission, diff --git a/app/authorization/client/route.js b/app/authorization/client/route.js deleted file mode 100644 index 2a09c78da6d7..000000000000 --- a/app/authorization/client/route.js +++ /dev/null @@ -1,39 +0,0 @@ -import { BlazeLayout } from 'meteor/kadira:blaze-layout'; - -import { registerAdminRoute } from '../../../client/admin'; -import { t } from '../../utils/client'; - -registerAdminRoute('/permissions', { - name: 'admin-permissions', - async action(/* params*/) { - await import('./views'); - return BlazeLayout.render('main', { - center: 'permissions', - pageTitle: t('Permissions'), - }); - }, -}); - -registerAdminRoute('/permissions/:name?/edit', { - name: 'admin-permissions-edit', - async action(/* params*/) { - await import('./views'); - return BlazeLayout.render('main', { - center: 'pageContainer', - pageTitle: t('Role_Editing'), - pageTemplate: 'permissionsRole', - }); - }, -}); - -registerAdminRoute('/permissions/new', { - name: 'admin-permissions-new', - async action(/* params*/) { - await import('./views'); - return BlazeLayout.render('main', { - center: 'pageContainer', - pageTitle: t('Role_Editing'), - pageTemplate: 'permissionsRole', - }); - }, -}); diff --git a/app/authorization/client/startup.js b/app/authorization/client/startup.js index 2dd50b95fa24..3f4c4637a0bb 100644 --- a/app/authorization/client/startup.js +++ b/app/authorization/client/startup.js @@ -27,7 +27,9 @@ Meteor.startup(() => { delete role.type; Roles.upsert({ _id: role.name }, role); }, - removed: (role) => Roles.remove({ _id: role.name }), + removed: (role) => { + Roles.remove({ _id: role.name }); + }, }; Tracker.autorun((c) => { diff --git a/app/authorization/client/stylesheets/permissions.css b/app/authorization/client/stylesheets/permissions.css deleted file mode 100644 index 1561d2ebf536..000000000000 --- a/app/authorization/client/stylesheets/permissions.css +++ /dev/null @@ -1,122 +0,0 @@ -.permissions-manager { - display: flex; - flex-direction: column; - - height: 100%; - - &.page-container { - padding-bottom: 0 !important; - } - - .permission-edit { - display: flex; - flex-direction: column; - - height: 100%; - - padding: 10px; - align-items: center; - } - - .permission-label, - .permission-icon { - display: flex; - - align-items: flex-end; - flex-grow: 1; - } - - .permission-icon { - width: 30px; - - margin-bottom: 0; - flex-grow: 0; - } - - .content { - padding: 0 !important; - } - - .permission-grid { - overflow-x: scroll; - - table-layout: fixed; - - border-collapse: collapse; - - .id-styler { - white-space: nowrap; - - color: #7f7f7f; - - font-size: smaller; - } - - .edit-icon.role-name-edit-icon { - height: 30px; - } - - .role-name { - position: sticky; - - top: 0; - - width: 70px; - - text-align: left; - - vertical-align: middle; - - background: white; - } - - .role-name-edit-icon { - width: 70px; - height: 70px; - - text-align: center; - - vertical-align: middle; - } - - .rotator { - overflow: hidden; - - width: 30px; - height: 130px; - - padding: 10px 0; - - transform: rotate(-180deg); - - white-space: nowrap; - - text-overflow: ellipsis; - writing-mode: vertical-rl; - } - - .admin-table-row { - height: 50px; - } - - td { - overflow: hidden; - } - - .permission-name { - width: 25%; - padding-left: 14px; - - vertical-align: middle; - } - - .permission-checkbox { - text-align: center; - vertical-align: middle; - } - - .icon-edit { - font-size: 1.5em; - } - } -} diff --git a/app/authorization/client/views/index.js b/app/authorization/client/views/index.js deleted file mode 100644 index ba54dffa5c02..000000000000 --- a/app/authorization/client/views/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import './permissions.html'; -import './permissions'; -import './permissionsRole.html'; -import './permissionsRole'; diff --git a/app/authorization/client/views/permissions.html b/app/authorization/client/views/permissions.html deleted file mode 100644 index d332e471849d..000000000000 --- a/app/authorization/client/views/permissions.html +++ /dev/null @@ -1,80 +0,0 @@ - - diff --git a/app/authorization/client/views/permissions.js b/app/authorization/client/views/permissions.js deleted file mode 100644 index a01a9d182faf..000000000000 --- a/app/authorization/client/views/permissions.js +++ /dev/null @@ -1,205 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import _ from 'underscore'; -import s from 'underscore.string'; -import { ReactiveDict } from 'meteor/reactive-dict'; -import { Tracker } from 'meteor/tracker'; -import { Template } from 'meteor/templating'; - -import { Roles } from '../../../models/client'; -import { ChatPermissions } from '../lib/ChatPermissions'; -import { hasAllPermission } from '../hasPermission'; -import { t } from '../../../utils/client'; -import { SideNav } from '../../../ui-utils/client/lib/SideNav'; -import { CONSTANTS, AuthorizationUtils } from '../../lib'; -import { hasAtLeastOnePermission } from '..'; - -Template.permissions.helpers({ - tabsData() { - const { - state, - } = Template.instance(); - - const permissionsTab = { - label: t('Permissions'), - value: 'permissions', - condition() { - return true; - }, - }; - - const settingsTab = { - label: t('Settings'), - value: 'settings', - condition() { - return true; - }, - }; - - const tabs = [permissionsTab]; - - const settingsPermissions = hasAllPermission('access-setting-permissions'); - - if (settingsPermissions) { - tabs.push(settingsTab); - } - switch (settingsPermissions && state.get('tab')) { - case 'settings': - settingsTab.active = true; - break; - case 'permissions': - permissionsTab.active = true; - break; - default: - permissionsTab.active = true; - } - - - return { - tabs, - onChange(value) { - state.set({ - tab: value, - size: 50, - }); - }, - }; - }, - roles() { - return Roles.find(); - }, - - permissions() { - const { state } = Template.instance(); - const limit = state.get('size'); - const filter = new RegExp(s.escapeRegExp(state.get('filter')), 'i'); - - return ChatPermissions.find( - { - level: { $ne: CONSTANTS.SETTINGS_LEVEL }, - _id: filter, - }, - { - sort: { - _id: 1, - }, - limit, - }, - ); - }, - - settingPermissions() { - const { state } = Template.instance(); - const limit = state.get('size'); - const filter = new RegExp(s.escapeRegExp(state.get('filter')), 'i'); - return ChatPermissions.find( - { - _id: filter, - level: CONSTANTS.SETTINGS_LEVEL, - group: { $exists: true }, - }, - { - limit, - sort: { - group: 1, - section: 1, - }, - }, - ); - }, - - hasPermission() { - return hasAllPermission('access-permissions'); - }, - - hasNoPermission() { - return !hasAtLeastOnePermission([ - 'access-permissions', - 'access-setting-permissions', - ]); - }, - filter() { - return Template.instance().state.get('filter'); - }, - - tab() { - return Template.instance().state.get('tab'); - }, -}); - -Template.permissions.events({ - 'keyup #permissions-filter'(e, t) { - e.stopPropagation(); - e.preventDefault(); - t.state.set('filter', e.currentTarget.value); - }, - 'scroll .content': _.throttle(({ currentTarget }, i) => { - if ( - currentTarget.offsetHeight + currentTarget.scrollTop - >= currentTarget.scrollHeight - 100 - ) { - return i.state.set('size', i.state.get('size') + 50); - } - }, 300), -}); - -Template.permissions.onCreated(function() { - this.state = new ReactiveDict({ - filter: '', - tab: '', - size: 50, - }); - - this.autorun(() => { - this.state.get('filter'); - this.state.set('size', 50); - }); -}); - -Template.permissionsTable.helpers({ - granted(roles, role) { - return (roles && ~roles.indexOf(role._id) && 'checked') || null; - }, - - permissionName(permission) { - if (permission.level === CONSTANTS.SETTINGS_LEVEL) { - let path = ''; - if (permission.group) { - path = `${ t(permission.group) } > `; - } - if (permission.section) { - path = `${ path }${ t(permission.section) } > `; - } - return `${ path }${ t(permission.settingId) }`; - } - - return t(permission._id); - }, - - permissionDescription(permission) { - return t(`${ permission._id }_description`); - }, - - isRolePermissionEnabled(role, permission) { - return !AuthorizationUtils.isPermissionRestrictedForRole(permission._id, role._id); - }, -}); - -Template.permissionsTable.events({ - 'click .role-permission'(e) { - const permissionId = e.currentTarget.getAttribute('data-permission'); - const role = e.currentTarget.getAttribute('data-role'); - - const permission = permissionId && ChatPermissions.findOne(permissionId); - - const action = ~permission.roles.indexOf(role) ? 'authorization:removeRoleFromPermission' : 'authorization:addPermissionToRole'; - - return Meteor.call(action, permissionId, role); - }, -}); - -Template.permissions.onRendered(() => { - Tracker.afterFlush(() => { - SideNav.setFlex('adminFlex'); - SideNav.openFlex(); - }); -}); diff --git a/app/authorization/client/views/permissionsRole.html b/app/authorization/client/views/permissionsRole.html deleted file mode 100644 index 74579e16fe5d..000000000000 --- a/app/authorization/client/views/permissionsRole.html +++ /dev/null @@ -1,110 +0,0 @@ - diff --git a/app/authorization/client/views/permissionsRole.js b/app/authorization/client/views/permissionsRole.js deleted file mode 100644 index 4e16cd666f54..000000000000 --- a/app/authorization/client/views/permissionsRole.js +++ /dev/null @@ -1,277 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { ReactiveDict } from 'meteor/reactive-dict'; -import { ReactiveVar } from 'meteor/reactive-var'; -import { FlowRouter } from 'meteor/kadira:flow-router'; -import { Template } from 'meteor/templating'; -import { Tracker } from 'meteor/tracker'; -import toastr from 'toastr'; - -import { handleError } from '../../../utils/client/lib/handleError'; -import { t } from '../../../utils/lib/tapi18n'; -import { Roles } from '../../../models'; -import { hasAllPermission } from '../hasPermission'; -import { modal } from '../../../ui-utils/client/lib/modal'; -import { SideNav } from '../../../ui-utils/client/lib/SideNav'; -import { APIClient } from '../../../utils/client'; -import { call } from '../../../ui-utils/client'; - -const PAGE_SIZE = 50; - -const loadUsers = async (instance) => { - const offset = instance.state.get('offset'); - - const rid = instance.searchRoom.get(); - - const params = { - role: FlowRouter.getParam('name'), - offset, - count: PAGE_SIZE, - ...rid && { roomId: rid }, - }; - - instance.state.set('loading', true); - const { users } = await APIClient.v1.get('roles.getUsersInRole', params); - - instance.usersInRole.set(instance.usersInRole.curValue.concat(users)); - instance.state.set({ - loading: false, - hasMore: users.length === PAGE_SIZE, - }); -}; - -Template.permissionsRole.helpers({ - role() { - return Roles.findOne({ - _id: FlowRouter.getParam('name'), - }) || {}; - }, - - userInRole() { - return Template.instance().usersInRole.get(); - }, - - editing() { - return FlowRouter.getParam('name') != null; - }, - - emailAddress() { - if (this.emails && this.emails.length > 0) { - return this.emails[0].address; - } - }, - - hasPermission() { - return hasAllPermission('access-permissions'); - }, - - protected() { - return this.protected; - }, - - editable() { - return this._id && !this.protected; - }, - - hasUsers() { - return Template.instance().usersInRole.get().length > 0; - }, - - hasMore() { - return Template.instance().state.get('hasMore'); - }, - - isLoading() { - const instance = Template.instance(); - return (!instance.subscription.ready() || instance.state.get('loading')) && 'btn-loading'; - }, - - searchRoom() { - return Template.instance().searchRoom.get(); - }, - - autocompleteChannelSettings() { - return { - limit: 10, - rules: [ - { - collection: 'CachedChannelList', - endpoint: 'rooms.autocomplete.channelAndPrivate', - field: 'name', - template: Template.roomSearch, - noMatchTemplate: Template.roomSearchEmpty, - matchAll: true, - sort: 'name', - selector(match) { - return { - name: match, - }; - }, - }, - ], - }; - }, - - autocompleteUsernameSettings() { - const instance = Template.instance(); - return { - limit: 10, - rules: [ - { - collection: 'CachedUserList', - endpoint: 'users.autocomplete', - field: 'username', - template: Template.userSearch, - noMatchTemplate: Template.userSearchEmpty, - matchAll: true, - filter: { - exceptions: instance.usersInRole.get(), - }, - selector(match) { - return { - term: match, - }; - }, - sort: 'username', - }, - ], - }; - }, -}); - -Template.permissionsRole.events({ - async 'click .remove-user'(e, instance) { - e.preventDefault(); - modal.open({ - title: t('Are_you_sure'), - text: t('The_user_s_will_be_removed_from_role_s', this.username, FlowRouter.getParam('name')), - type: 'warning', - showCancelButton: true, - confirmButtonColor: '#DD6B55', - confirmButtonText: t('Yes'), - cancelButtonText: t('Cancel'), - closeOnConfirm: false, - html: false, - }, async () => { - await call('authorization:removeUserFromRole', FlowRouter.getParam('name'), this.username, instance.searchRoom.get()); - instance.usersInRole.set(instance.usersInRole.curValue.filter((user) => user.username !== this.username)); - modal.open({ - title: t('Removed'), - text: t('User_removed'), - type: 'success', - timer: 1000, - showConfirmButton: false, - }); - }); - }, - - 'submit #form-role'(e/* , instance*/) { - e.preventDefault(); - const oldBtnValue = e.currentTarget.elements.save.value; - e.currentTarget.elements.save.value = t('Saving'); - const roleData = { - description: e.currentTarget.elements.description.value, - scope: e.currentTarget.elements.scope.value, - mandatory2fa: e.currentTarget.elements.mandatory2fa.checked, - }; - - if (this._id) { - roleData.name = this._id; - } else { - roleData.name = e.currentTarget.elements.name.value; - } - - Meteor.call('authorization:saveRole', roleData, (error/* , result*/) => { - e.currentTarget.elements.save.value = oldBtnValue; - if (error) { - return handleError(error); - } - - toastr.success(t('Saved')); - - if (!this._id) { - return FlowRouter.go('admin-permissions-edit', { - name: roleData.name, - }); - } - }); - }, - - async 'submit #form-users'(e, instance) { - e.preventDefault(); - if (e.currentTarget.elements.username.value.trim() === '') { - return toastr.error(t('Please_fill_a_username')); - } - const oldBtnValue = e.currentTarget.elements.add.value; - e.currentTarget.elements.add.value = t('Saving'); - - try { - await call('authorization:addUserToRole', FlowRouter.getParam('name'), e.currentTarget.elements.username.value, instance.searchRoom.get()); - instance.usersInRole.set([]); - instance.state.set({ - offset: 0, - cache: Date.now(), - }); - toastr.success(t('User_added')); - e.currentTarget.reset(); - } finally { - e.currentTarget.elements.add.value = oldBtnValue; - } - }, - - 'submit #form-search-room'(e) { - return e.preventDefault(); - }, - - 'click .delete-role'(e/* , instance*/) { - e.preventDefault(); - if (this.protected) { - return toastr.error(t('error-delete-protected-role')); - } - - Meteor.call('authorization:deleteRole', this._id, function(error/* , result*/) { - if (error) { - return handleError(error); - } - toastr.success(t('Role_removed')); - FlowRouter.go('admin-permissions'); - }); - }, - - 'click .load-more'(e, t) { - t.state.set('offset', t.state.get('offset') + PAGE_SIZE); - }, - - 'autocompleteselect input[name=room]'(event, template, doc) { - template.searchRoom.set(doc._id); - }, -}); - -Template.permissionsRole.onCreated(async function() { - this.state = new ReactiveDict({ - offset: 0, - loading: false, - hasMore: true, - cache: 0, - }); - this.searchRoom = new ReactiveVar(); - this.searchUsername = new ReactiveVar(); - this.usersInRole = new ReactiveVar([]); -}); - -Template.permissionsRole.onRendered(function() { - this.autorun(() => { - this.searchRoom.get(); - this.usersInRole.set([]); - this.state.set({ offset: 0 }); - }); - - this.autorun(() => { - this.state.get('cache'); - loadUsers(this); - }); - - Tracker.afterFlush(() => { - SideNav.setFlex('adminFlex'); - SideNav.openFlex(); - }); -}); diff --git a/app/authorization/server/methods/deleteRole.js b/app/authorization/server/methods/deleteRole.js index 0b42263c23f8..56ab719fe981 100644 --- a/app/authorization/server/methods/deleteRole.js +++ b/app/authorization/server/methods/deleteRole.js @@ -35,6 +35,7 @@ Meteor.methods({ method: 'authorization:deleteRole', }); } + const removed = Models.Roles.remove(role.name); if (removed) { rolesStreamer.emit('roles', { diff --git a/app/authorization/server/streamer/permissions/emitter.js b/app/authorization/server/streamer/permissions/emitter.js index 5b0a2a13f9b4..aafdaf274189 100644 --- a/app/authorization/server/streamer/permissions/emitter.js +++ b/app/authorization/server/streamer/permissions/emitter.js @@ -12,7 +12,7 @@ Permissions.on('change', ({ clientAction, id, data, diff }) => { switch (clientAction) { case 'updated': case 'inserted': - data = data || Permissions.findOneById(id); + data = data ?? Permissions.findOneById(id); break; case 'removed': diff --git a/app/channel-settings/server/methods/saveRoomSettings.js b/app/channel-settings/server/methods/saveRoomSettings.js index 62974e4754eb..a5fbbbf8632a 100644 --- a/app/channel-settings/server/methods/saveRoomSettings.js +++ b/app/channel-settings/server/methods/saveRoomSettings.js @@ -19,6 +19,188 @@ import { saveStreamingOptions } from '../functions/saveStreamingOptions'; import { RoomSettingsEnum, roomTypes } from '../../../utils'; const fields = ['roomAvatar', 'featured', 'roomName', 'roomTopic', 'roomAnnouncement', 'roomCustomFields', 'roomDescription', 'roomType', 'readOnly', 'reactWhenReadOnly', 'systemMessages', 'default', 'joinCode', 'tokenpass', 'streamingOptions', 'retentionEnabled', 'retentionMaxAge', 'retentionExcludePinned', 'retentionFilesOnly', 'retentionIgnoreThreads', 'retentionOverrideGlobal', 'encrypted', 'favorite']; + +const validators = { + default({ userId }) { + if (!hasPermission(userId, 'view-room-administration')) { + throw new Meteor.Error('error-action-not-allowed', 'Viewing room administration is not allowed', { + method: 'saveRoomSettings', + action: 'Viewing_room_administration', + }); + } + }, + featured({ userId }) { + if (!hasPermission(userId, 'view-room-administration')) { + throw new Meteor.Error('error-action-not-allowed', 'Viewing room administration is not allowed', { + method: 'saveRoomSettings', + action: 'Viewing_room_administration', + }); + } + }, + roomType({ userId, room, value }) { + if (value === room.t) { + return; + } + + if (value === 'c' && !hasPermission(userId, 'create-c')) { + throw new Meteor.Error('error-action-not-allowed', 'Changing a private group to a public channel is not allowed', { + method: 'saveRoomSettings', + action: 'Change_Room_Type', + }); + } + + if (value === 'p' && !hasPermission(userId, 'create-p')) { + throw new Meteor.Error('error-action-not-allowed', 'Changing a public channel to a private room is not allowed', { + method: 'saveRoomSettings', + action: 'Change_Room_Type', + }); + } + }, + encrypted({ value, room }) { + if (value !== room.encrypted && !roomTypes.getConfig(room.t).allowRoomSettingChange(room, RoomSettingsEnum.E2E)) { + throw new Meteor.Error('error-action-not-allowed', 'Only groups or direct channels can enable encryption', { + method: 'saveRoomSettings', + action: 'Change_Room_Encrypted', + }); + } + }, + retentionEnabled({ userId, value, room, rid }) { + if (!hasPermission(userId, 'edit-room-retention-policy', rid) && value !== room.retention.enabled) { + throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { + method: 'saveRoomSettings', + action: 'Editing_room', + }); + } + }, + retentionMaxAge({ userId, value, room, rid }) { + if (!hasPermission(userId, 'edit-room-retention-policy', rid) && value !== room.retention.maxAge) { + throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { + method: 'saveRoomSettings', + action: 'Editing_room', + }); + } + }, + retentionExcludePinned({ userId, value, room, rid }) { + if (!hasPermission(userId, 'edit-room-retention-policy', rid) && value !== room.retention.excludePinned) { + throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { + method: 'saveRoomSettings', + action: 'Editing_room', + }); + } + }, + retentionFilesOnly({ userId, value, room, rid }) { + if (!hasPermission(userId, 'edit-room-retention-policy', rid) && value !== room.retention.filesOnly) { + throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { + method: 'saveRoomSettings', + action: 'Editing_room', + }); + } + }, + retentionIgnoreThreads({ userId, value, room, rid }) { + if (!hasPermission(userId, 'edit-room-retention-policy', rid) && value !== room.retention.ignoreThreads) { + throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { + method: 'saveRoomSettings', + action: 'Editing_room', + }); + } + }, +}; + +const settingSavers = { + roomName({ value, rid, user }) { + saveRoomName(rid, value, user); + }, + roomTopic({ value, room, rid, user }) { + if (value !== room.topic) { + saveRoomTopic(rid, value, user); + } + }, + roomAnnouncement({ value, room, rid, user }) { + if (value !== room.announcement) { + saveRoomAnnouncement(rid, value, user); + } + }, + roomCustomFields({ value, room, rid }) { + if (value !== room.customFields) { + saveRoomCustomFields(rid, value); + } + }, + roomDescription({ value, room, rid, user }) { + if (value !== room.description) { + saveRoomDescription(rid, value, user); + } + }, + roomType({ value, room, rid, user }) { + if (value !== room.t) { + saveRoomType(rid, value, user); + } + }, + tokenpass({ value, rid }) { + check(value, { + require: String, + tokens: [{ + token: String, + balance: String, + }], + }); + saveRoomTokenpass(rid, value); + }, + streamingOptions({ value, rid }) { + saveStreamingOptions(rid, value); + }, + readOnly({ value, room, rid, user }) { + if (value !== room.ro) { + saveRoomReadOnly(rid, value, user); + } + }, + reactWhenReadOnly({ value, room, rid, user }) { + if (value !== room.reactWhenReadOnly) { + saveReactWhenReadOnly(rid, value, user); + } + }, + systemMessages({ value, room, rid, user }) { + if (JSON.stringify(value) !== JSON.stringify(room.sysMes)) { + saveRoomSystemMessages(rid, value, user); + } + }, + joinCode({ value, rid }) { + Rooms.setJoinCodeById(rid, String(value)); + }, + default({ value, rid }) { + Rooms.saveDefaultById(rid, value); + }, + featured({ value, rid }) { + Rooms.saveFeaturedById(rid, value); + }, + retentionEnabled({ value, rid }) { + Rooms.saveRetentionEnabledById(rid, value); + }, + retentionMaxAge({ value, rid }) { + Rooms.saveRetentionMaxAgeById(rid, value); + }, + retentionExcludePinned({ value, rid }) { + Rooms.saveRetentionExcludePinnedById(rid, value); + }, + retentionFilesOnly({ value, rid }) { + Rooms.saveRetentionFilesOnlyById(rid, value); + }, + retentionIgnoreThreads({ value, rid }) { + Rooms.saveRetentionIgnoreThreadsById(rid, value); + }, + retentionOverrideGlobal({ value, rid }) { + Rooms.saveRetentionOverrideGlobalById(rid, value); + }, + encrypted({ value, rid }) { + Rooms.saveEncryptedById(rid, value); + }, + favorite({ value, rid }) { + Rooms.saveFavoriteById(rid, value.favorite, value.defaultValue); + }, + roomAvatar({ value, rid, user }) { + setRoomAvatar(rid, value, user); + }, +}; + Meteor.methods({ saveRoomSettings(rid, settings, value) { const userId = Meteor.userId(); @@ -46,13 +228,6 @@ Meteor.methods({ }); } - if (!hasPermission(userId, 'edit-room', rid)) { - throw new Meteor.Error('error-action-not-allowed', 'Editing room is not allowed', { - method: 'saveRoomSettings', - action: 'Editing_room', - }); - } - const room = Rooms.findOneById(rid); if (!room) { @@ -61,6 +236,16 @@ Meteor.methods({ }); } + if (!hasPermission(userId, 'edit-room', rid)) { + if (!(Object.keys(settings).includes('encrypted') && room.t === 'd')) { + throw new Meteor.Error('error-action-not-allowed', 'Editing room is not allowed', { + method: 'saveRoomSettings', + action: 'Editing_room', + }); + } + settings = { encrypted: settings.encrypted }; + } + if (room.broadcast && (settings.readOnly || settings.reactWhenReadOnly)) { throw new Meteor.Error('error-action-not-allowed', 'Editing readOnly/reactWhenReadOnly are not allowed for broadcast rooms', { method: 'saveRoomSettings', @@ -71,70 +256,19 @@ Meteor.methods({ const user = Meteor.user(); // validations - Object.keys(settings).forEach((setting) => { const value = settings[setting]; - if (settings === 'default' && !hasPermission(userId, 'view-room-administration')) { - throw new Meteor.Error('error-action-not-allowed', 'Viewing room administration is not allowed', { - method: 'saveRoomSettings', - action: 'Viewing_room_administration', - }); - } - if (settings === 'featured' && !hasPermission(userId, 'view-room-administration')) { - throw new Meteor.Error('error-action-not-allowed', 'Viewing room administration is not allowed', { - method: 'saveRoomSettings', - action: 'Viewing_room_administration', - }); - } - if (setting === 'roomType' && value !== room.t && value === 'c' && !hasPermission(userId, 'create-c')) { - throw new Meteor.Error('error-action-not-allowed', 'Changing a private group to a public channel is not allowed', { - method: 'saveRoomSettings', - action: 'Change_Room_Type', - }); - } - if (setting === 'roomType' && value !== room.t && value === 'p' && !hasPermission(userId, 'create-p')) { - throw new Meteor.Error('error-action-not-allowed', 'Changing a public channel to a private room is not allowed', { - method: 'saveRoomSettings', - action: 'Change_Room_Type', - }); - } - if (setting === 'encrypted' && value !== room.encrypted && !roomTypes.getConfig(room.t).allowRoomSettingChange(room, RoomSettingsEnum.E2E)) { - throw new Meteor.Error('error-action-not-allowed', 'Only groups or direct channels can enable encryption', { - method: 'saveRoomSettings', - action: 'Change_Room_Encrypted', - }); - } - if (setting === 'retentionEnabled' && !hasPermission(userId, 'edit-room-retention-policy', rid) && value !== room.retention.enabled) { - throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { - method: 'saveRoomSettings', - action: 'Editing_room', - }); - } - if (setting === 'retentionMaxAge' && !hasPermission(userId, 'edit-room-retention-policy', rid) && value !== room.retention.maxAge) { - throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { - method: 'saveRoomSettings', - action: 'Editing_room', - }); - } - if (setting === 'retentionExcludePinned' && !hasPermission(userId, 'edit-room-retention-policy', rid) && value !== room.retention.excludePinned) { - throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { - method: 'saveRoomSettings', - action: 'Editing_room', - }); - } - if (setting === 'retentionFilesOnly' && !hasPermission(userId, 'edit-room-retention-policy', rid) && value !== room.retention.filesOnly) { - throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { - method: 'saveRoomSettings', - action: 'Editing_room', - }); - } - if (setting === 'retentionIgnoreThreads' && !hasPermission(userId, 'edit-room-retention-policy', rid) && value !== room.retention.ignoreThreads) { - throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { - method: 'saveRoomSettings', - action: 'Editing_room', + const validator = validators[setting]; + if (validator) { + validator({ + userId, + value, + room, + rid, }); } + if (setting === 'retentionOverrideGlobal') { delete settings.retentionMaxAge; delete settings.retentionExcludePinned; @@ -143,101 +277,18 @@ Meteor.methods({ } }); + // saving data Object.keys(settings).forEach((setting) => { const value = settings[setting]; - switch (setting) { - case 'roomName': - saveRoomName(rid, value, user); - break; - case 'roomTopic': - if (value !== room.topic) { - saveRoomTopic(rid, value, user); - } - break; - case 'roomAnnouncement': - if (value !== room.announcement) { - saveRoomAnnouncement(rid, value, user); - } - break; - case 'roomCustomFields': - if (value !== room.customFields) { - saveRoomCustomFields(rid, value); - } - break; - case 'roomDescription': - if (value !== room.description) { - saveRoomDescription(rid, value, user); - } - break; - case 'roomType': - if (value !== room.t) { - saveRoomType(rid, value, user); - } - break; - case 'tokenpass': - check(value, { - require: String, - tokens: [{ - token: String, - balance: String, - }], - }); - saveRoomTokenpass(rid, value); - break; - case 'streamingOptions': - saveStreamingOptions(rid, value); - break; - case 'readOnly': - if (value !== room.ro) { - saveRoomReadOnly(rid, value, user); - } - break; - case 'reactWhenReadOnly': - if (value !== room.reactWhenReadOnly) { - saveReactWhenReadOnly(rid, value, user); - } - break; - case 'systemMessages': - if (JSON.stringify(value) !== JSON.stringify(room.sysMes)) { - saveRoomSystemMessages(rid, value, user); - } - break; - case 'joinCode': - Rooms.setJoinCodeById(rid, String(value)); - break; - case 'default': - Rooms.saveDefaultById(rid, value); - break; - case 'featured': - Rooms.saveFeaturedById(rid, value); - break; - case 'retentionEnabled': - Rooms.saveRetentionEnabledById(rid, value); - break; - case 'retentionMaxAge': - Rooms.saveRetentionMaxAgeById(rid, value); - break; - case 'retentionExcludePinned': - Rooms.saveRetentionExcludePinnedById(rid, value); - break; - case 'retentionFilesOnly': - Rooms.saveRetentionFilesOnlyById(rid, value); - break; - case 'retentionIgnoreThreads': - Rooms.saveRetentionIgnoreThreadsById(rid, value); - break; - case 'retentionOverrideGlobal': - Rooms.saveRetentionOverrideGlobalById(rid, value); - break; - case 'encrypted': - Rooms.saveEncryptedById(rid, value); - break; - case 'favorite': - Rooms.saveFavoriteById(rid, value.favorite, value.defaultValue); - break; - case 'roomAvatar': - setRoomAvatar(rid, value, user); - break; + + const saver = settingSavers[setting]; + if (saver) { + saver({ + value, + room, + rid, + user, + }); } }); diff --git a/app/discussion/server/permissions.js b/app/discussion/server/permissions.js index 2f17e05d9414..9e06792a2558 100644 --- a/app/discussion/server/permissions.js +++ b/app/discussion/server/permissions.js @@ -5,8 +5,8 @@ import { Permissions } from '../../models'; Meteor.startup(() => { // Add permissions for discussion const permissions = [ - { _id: 'start-discussion', roles: ['admin', 'user', 'expert', 'guest'] }, - { _id: 'start-discussion-other-user', roles: ['admin', 'user', 'expert', 'owner'] }, + { _id: 'start-discussion', roles: ['admin', 'user', 'guest'] }, + { _id: 'start-discussion-other-user', roles: ['admin', 'user', 'owner'] }, ]; for (const permission of permissions) { diff --git a/app/dolphin/lib/common.js b/app/dolphin/lib/common.js index 5366b5fecec0..0e74e6d1fca2 100644 --- a/app/dolphin/lib/common.js +++ b/app/dolphin/lib/common.js @@ -5,7 +5,6 @@ import { ServiceConfiguration } from 'meteor/service-configuration'; import { settings } from '../../settings'; import { CustomOAuth } from '../../custom-oauth'; import { callbacks } from '../../callbacks'; -import { Settings } from '../../models'; const config = { serverURL: '', @@ -31,15 +30,9 @@ function DolphinOnCreateUser(options, user) { if (Meteor.isServer) { Meteor.startup(() => - Settings.find({ _id: 'Accounts_OAuth_Dolphin_URL' }).observe({ - added() { - config.serverURL = settings.get('Accounts_OAuth_Dolphin_URL'); - return Dolphin.configure(config); - }, - changed() { - config.serverURL = settings.get('Accounts_OAuth_Dolphin_URL'); - return Dolphin.configure(config); - }, + settings.get('Accounts_OAuth_Dolphin_URL', (key, value) => { + config.serverURL = value; + return Dolphin.configure(config); }), ); diff --git a/app/e2e/client/tabbar.js b/app/e2e/client/tabbar.js index 4f7ef3cb0b8e..d90550732f0f 100644 --- a/app/e2e/client/tabbar.js +++ b/app/e2e/client/tabbar.js @@ -21,7 +21,14 @@ Meteor.startup(() => { call('saveRoomSettings', room._id, 'encrypted', !room.encrypted); }, order: 13, - condition: () => hasAllPermission('edit-room', Session.get('openedRoom')), + condition: () => { + const session = Session.get('openedRoom'); + const room = ChatRoom.findOne(session); + if (room && room.t === 'd') { + return true; + } + return hasAllPermission('edit-room', session); + }, }); } else { TabBar.removeButton('e2e'); diff --git a/app/file-upload/lib/FileUploadBase.js b/app/file-upload/lib/FileUploadBase.js index 1b282a446495..71328448567a 100644 --- a/app/file-upload/lib/FileUploadBase.js +++ b/app/file-upload/lib/FileUploadBase.js @@ -1,3 +1,5 @@ +import path from 'path'; + import { Meteor } from 'meteor/meteor'; import { Random } from 'meteor/random'; import { UploadFS } from 'meteor/jalik:ufs'; @@ -6,6 +8,11 @@ import _ from 'underscore'; import { canAccessRoom, hasPermission } from '../../authorization'; import { settings } from '../../settings'; +// set ufs temp dir to $TMPDIR/ufs instead of /tmp/ufs if the variable is set +if ('TMPDIR' in process.env) { + UploadFS.config.tmpDir = path.join(process.env.TMPDIR, 'ufs'); +} + UploadFS.config.defaultStorePermissions = new UploadFS.StorePermissions({ insert(userId, doc) { if (userId) { diff --git a/app/file-upload/server/lib/FileUpload.js b/app/file-upload/server/lib/FileUpload.js index b03ea21f337a..44aa37c170c3 100644 --- a/app/file-upload/server/lib/FileUpload.js +++ b/app/file-upload/server/lib/FileUpload.js @@ -66,7 +66,7 @@ export const FileUpload = { const room = Rooms.findOneById(file.rid); const directMessageAllowed = settings.get('FileUpload_Enabled_Direct'); const fileUploadAllowed = settings.get('FileUpload_Enabled'); - if (canAccessRoom(room, user, file) !== true) { + if (user?.type !== 'app' && canAccessRoom(room, user, file) !== true) { return false; } const language = user ? user.language : 'en'; diff --git a/app/file-upload/server/lib/proxy.js b/app/file-upload/server/lib/proxy.js index 8aaff7ce7088..0921956d111a 100644 --- a/app/file-upload/server/lib/proxy.js +++ b/app/file-upload/server/lib/proxy.js @@ -15,7 +15,7 @@ WebApp.connectHandlers.stack.unshift({ route: '', handle: Meteor.bindEnvironment(function(req, res, next) { // Quick check to see if request should be catch - if (req.url.indexOf(UploadFS.config.storesPath) === -1) { + if (!req.url.includes(`/${ UploadFS.config.storesPath }/`)) { return next(); } @@ -84,6 +84,7 @@ WebApp.connectHandlers.stack.unshift({ method: 'POST', }; + console.warn('UFS proxy middleware is deprecated as this upload method is not being used by Web/Mobile Clients. See this: https://docs.rocket.chat/api/rest-api/methods/rooms/upload'); const proxy = http.request(options, function(proxy_res) { proxy_res.pipe(res, { end: true, diff --git a/app/file-upload/server/methods/sendFileMessage.js b/app/file-upload/server/methods/sendFileMessage.js index 5f4b502a413f..593ebecd07da 100644 --- a/app/file-upload/server/methods/sendFileMessage.js +++ b/app/file-upload/server/methods/sendFileMessage.js @@ -4,8 +4,10 @@ import { Random } from 'meteor/random'; import _ from 'underscore'; import { Uploads } from '../../../models'; +import { Rooms } from '../../../models/server/raw'; import { callbacks } from '../../../callbacks'; import { FileUpload } from '../lib/FileUpload'; +import { canAccessRoom } from '../../../authorization/server/functions/canAccessRoom'; Meteor.methods({ async sendFileMessage(roomId, store, file, msgData = {}) { @@ -13,9 +15,10 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'sendFileMessage' }); } - const room = Meteor.call('canAccessRoom', roomId, Meteor.userId()); + const room = await Rooms.findOneById(roomId); + const user = Meteor.user(); - if (!room) { + if (user?.type !== 'app' && canAccessRoom(room, user) !== true) { return false; } @@ -66,8 +69,6 @@ Meteor.methods({ attachment.video_size = file.size; } - const user = Meteor.user(); - let id; if (msgData.id) { id = msgData.id; diff --git a/app/integrations/server/lib/triggerHandler.js b/app/integrations/server/lib/triggerHandler.js index 7ce09dc03f88..0541855cbf64 100644 --- a/app/integrations/server/lib/triggerHandler.js +++ b/app/integrations/server/lib/triggerHandler.js @@ -22,19 +22,26 @@ integrations.triggerHandler = new class RocketChatIntegrationHandler { this.compiledScripts = {}; this.triggers = {}; - Models.Integrations.find({ type: 'webhook-outgoing' }).observe({ - added: (record) => { - this.addIntegration(record); - }, - - changed: (record) => { - this.removeIntegration(record); - this.addIntegration(record); - }, + Models.Integrations.find({ type: 'webhook-outgoing' }).fetch().forEach((data) => this.addIntegration(data)); - removed: (record) => { - this.removeIntegration(record); - }, + Models.Integrations.on('change', ({ clientAction, id, data }) => { + switch (clientAction) { + case 'inserted': + if (data.type === 'webhook-outgoing') { + this.addIntegration(data); + } + break; + case 'updated': + data = data ?? Models.Integrations.findOneById(id); + if (data.type === 'webhook-outgoing') { + this.removeIntegration(data); + this.addIntegration(data); + } + break; + case 'removed': + this.removeIntegration({ _id: id }); + break; + } }); } diff --git a/app/katex/katex.min.css b/app/katex/katex.min.css deleted file mode 100644 index c0cd1451ae6d..000000000000 --- a/app/katex/katex.min.css +++ /dev/null @@ -1 +0,0 @@ -@font-face{font-family:KaTeX_AMS;src:url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"),url(fonts/KaTeX_AMS-Regular.woff) format("woff"),url(fonts/KaTeX_AMS-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Caligraphic;src:url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype");font-weight:700;font-style:normal}@font-face{font-family:KaTeX_Caligraphic;src:url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Fraktur;src:url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"),url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype");font-weight:700;font-style:normal}@font-face{font-family:KaTeX_Fraktur;src:url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"),url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Main;src:url(fonts/KaTeX_Main-Bold.woff2) format("woff2"),url(fonts/KaTeX_Main-Bold.woff) format("woff"),url(fonts/KaTeX_Main-Bold.ttf) format("truetype");font-weight:700;font-style:normal}@font-face{font-family:KaTeX_Main;src:url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype");font-weight:700;font-style:italic}@font-face{font-family:KaTeX_Main;src:url(fonts/KaTeX_Main-Italic.woff2) format("woff2"),url(fonts/KaTeX_Main-Italic.woff) format("woff"),url(fonts/KaTeX_Main-Italic.ttf) format("truetype");font-weight:400;font-style:italic}@font-face{font-family:KaTeX_Main;src:url(fonts/KaTeX_Main-Regular.woff2) format("woff2"),url(fonts/KaTeX_Main-Regular.woff) format("woff"),url(fonts/KaTeX_Main-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Math;src:url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype");font-weight:700;font-style:italic}@font-face{font-family:KaTeX_Math;src:url(fonts/KaTeX_Math-Italic.woff2) format("woff2"),url(fonts/KaTeX_Math-Italic.woff) format("woff"),url(fonts/KaTeX_Math-Italic.ttf) format("truetype");font-weight:400;font-style:italic}@font-face{font-family:"KaTeX_SansSerif";src:url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"),url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype");font-weight:700;font-style:normal}@font-face{font-family:"KaTeX_SansSerif";src:url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"),url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype");font-weight:400;font-style:italic}@font-face{font-family:"KaTeX_SansSerif";src:url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"),url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Script;src:url(fonts/KaTeX_Script-Regular.woff2) format("woff2"),url(fonts/KaTeX_Script-Regular.woff) format("woff"),url(fonts/KaTeX_Script-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Size1;src:url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size1-Regular.woff) format("woff"),url(fonts/KaTeX_Size1-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Size2;src:url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size2-Regular.woff) format("woff"),url(fonts/KaTeX_Size2-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Size3;src:url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size3-Regular.woff) format("woff"),url(fonts/KaTeX_Size3-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Size4;src:url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size4-Regular.woff) format("woff"),url(fonts/KaTeX_Size4-Regular.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:KaTeX_Typewriter;src:url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"),url(fonts/KaTeX_Typewriter-Regular.woff) format("woff"),url(fonts/KaTeX_Typewriter-Regular.ttf) format("truetype");font-weight:400;font-style:normal}.katex{font:normal 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important}.katex .katex-version:after{content:"0.11.1"}.katex .katex-mathml{position:absolute;clip:rect(1px,1px,1px,1px);padding:0;border:0;height:1px;width:1px;overflow:hidden}.katex .katex-html>.newline{display:block}.katex .base{position:relative;white-space:nowrap;width:min-content}.katex .base,.katex .strut{display:inline-block}.katex .textbf{font-weight:700}.katex .textit{font-style:italic}.katex .textrm{font-family:KaTeX_Main}.katex .textsf{font-family:KaTeX_SansSerif}.katex .texttt{font-family:KaTeX_Typewriter}.katex .mathdefault{font-family:KaTeX_Math;font-style:italic}.katex .mathit{font-family:KaTeX_Main;font-style:italic}.katex .mathrm{font-style:normal}.katex .mathbf{font-family:KaTeX_Main;font-weight:700}.katex .boldsymbol{font-family:KaTeX_Math;font-weight:700;font-style:italic}.katex .amsrm,.katex .mathbb,.katex .textbb{font-family:KaTeX_AMS}.katex .mathcal{font-family:KaTeX_Caligraphic}.katex .mathfrak,.katex .textfrak{font-family:KaTeX_Fraktur}.katex .mathtt{font-family:KaTeX_Typewriter}.katex .mathscr,.katex .textscr{font-family:KaTeX_Script}.katex .mathsf,.katex .textsf{font-family:KaTeX_SansSerif}.katex .mathboldsf,.katex .textboldsf{font-family:KaTeX_SansSerif;font-weight:700}.katex .mathitsf,.katex .textitsf{font-family:KaTeX_SansSerif;font-style:italic}.katex .mainrm{font-family:KaTeX_Main;font-style:normal}.katex .vlist-t{display:inline-table;table-layout:fixed}.katex .vlist-r{display:table-row}.katex .vlist{display:table-cell;vertical-align:bottom;position:relative}.katex .vlist>span{display:block;height:0;position:relative}.katex .vlist>span>span{display:inline-block}.katex .vlist>span>.pstrut{overflow:hidden;width:0}.katex .vlist-t2{margin-right:-2px}.katex .vlist-s{display:table-cell;vertical-align:bottom;font-size:1px;width:2px;min-width:2px}.katex .msupsub{text-align:left}.katex .mfrac>span>span{text-align:center}.katex .mfrac .frac-line{display:inline-block;width:100%;border-bottom-style:solid}.katex .hdashline,.katex .hline,.katex .mfrac .frac-line,.katex .overline .overline-line,.katex .rule,.katex .underline .underline-line{min-height:1px}.katex .mspace{display:inline-block}.katex .clap,.katex .llap,.katex .rlap{width:0;position:relative}.katex .clap>.inner,.katex .llap>.inner,.katex .rlap>.inner{position:absolute}.katex .clap>.fix,.katex .llap>.fix,.katex .rlap>.fix{display:inline-block}.katex .llap>.inner{right:0}.katex .clap>.inner,.katex .rlap>.inner{left:0}.katex .clap>.inner>span{margin-left:-50%;margin-right:50%}.katex .rule{display:inline-block;border:0 solid;position:relative}.katex .hline,.katex .overline .overline-line,.katex .underline .underline-line{display:inline-block;width:100%;border-bottom-style:solid}.katex .hdashline{display:inline-block;width:100%;border-bottom-style:dashed}.katex .sqrt>.root{margin-left:.27777778em;margin-right:-.55555556em}.katex .fontsize-ensurer.reset-size1.size1,.katex .sizing.reset-size1.size1{font-size:1em}.katex .fontsize-ensurer.reset-size1.size2,.katex .sizing.reset-size1.size2{font-size:1.2em}.katex .fontsize-ensurer.reset-size1.size3,.katex .sizing.reset-size1.size3{font-size:1.4em}.katex .fontsize-ensurer.reset-size1.size4,.katex .sizing.reset-size1.size4{font-size:1.6em}.katex .fontsize-ensurer.reset-size1.size5,.katex .sizing.reset-size1.size5{font-size:1.8em}.katex .fontsize-ensurer.reset-size1.size6,.katex .sizing.reset-size1.size6{font-size:2em}.katex .fontsize-ensurer.reset-size1.size7,.katex .sizing.reset-size1.size7{font-size:2.4em}.katex .fontsize-ensurer.reset-size1.size8,.katex .sizing.reset-size1.size8{font-size:2.88em}.katex .fontsize-ensurer.reset-size1.size9,.katex .sizing.reset-size1.size9{font-size:3.456em}.katex .fontsize-ensurer.reset-size1.size10,.katex .sizing.reset-size1.size10{font-size:4.148em}.katex .fontsize-ensurer.reset-size1.size11,.katex .sizing.reset-size1.size11{font-size:4.976em}.katex .fontsize-ensurer.reset-size2.size1,.katex .sizing.reset-size2.size1{font-size:.83333333em}.katex .fontsize-ensurer.reset-size2.size2,.katex .sizing.reset-size2.size2{font-size:1em}.katex .fontsize-ensurer.reset-size2.size3,.katex .sizing.reset-size2.size3{font-size:1.16666667em}.katex .fontsize-ensurer.reset-size2.size4,.katex .sizing.reset-size2.size4{font-size:1.33333333em}.katex .fontsize-ensurer.reset-size2.size5,.katex .sizing.reset-size2.size5{font-size:1.5em}.katex .fontsize-ensurer.reset-size2.size6,.katex .sizing.reset-size2.size6{font-size:1.66666667em}.katex .fontsize-ensurer.reset-size2.size7,.katex .sizing.reset-size2.size7{font-size:2em}.katex .fontsize-ensurer.reset-size2.size8,.katex .sizing.reset-size2.size8{font-size:2.4em}.katex .fontsize-ensurer.reset-size2.size9,.katex .sizing.reset-size2.size9{font-size:2.88em}.katex .fontsize-ensurer.reset-size2.size10,.katex .sizing.reset-size2.size10{font-size:3.45666667em}.katex .fontsize-ensurer.reset-size2.size11,.katex .sizing.reset-size2.size11{font-size:4.14666667em}.katex .fontsize-ensurer.reset-size3.size1,.katex .sizing.reset-size3.size1{font-size:.71428571em}.katex .fontsize-ensurer.reset-size3.size2,.katex .sizing.reset-size3.size2{font-size:.85714286em}.katex .fontsize-ensurer.reset-size3.size3,.katex .sizing.reset-size3.size3{font-size:1em}.katex .fontsize-ensurer.reset-size3.size4,.katex .sizing.reset-size3.size4{font-size:1.14285714em}.katex .fontsize-ensurer.reset-size3.size5,.katex .sizing.reset-size3.size5{font-size:1.28571429em}.katex .fontsize-ensurer.reset-size3.size6,.katex .sizing.reset-size3.size6{font-size:1.42857143em}.katex .fontsize-ensurer.reset-size3.size7,.katex .sizing.reset-size3.size7{font-size:1.71428571em}.katex .fontsize-ensurer.reset-size3.size8,.katex .sizing.reset-size3.size8{font-size:2.05714286em}.katex .fontsize-ensurer.reset-size3.size9,.katex .sizing.reset-size3.size9{font-size:2.46857143em}.katex .fontsize-ensurer.reset-size3.size10,.katex .sizing.reset-size3.size10{font-size:2.96285714em}.katex .fontsize-ensurer.reset-size3.size11,.katex .sizing.reset-size3.size11{font-size:3.55428571em}.katex .fontsize-ensurer.reset-size4.size1,.katex .sizing.reset-size4.size1{font-size:.625em}.katex .fontsize-ensurer.reset-size4.size2,.katex .sizing.reset-size4.size2{font-size:.75em}.katex .fontsize-ensurer.reset-size4.size3,.katex .sizing.reset-size4.size3{font-size:.875em}.katex .fontsize-ensurer.reset-size4.size4,.katex .sizing.reset-size4.size4{font-size:1em}.katex .fontsize-ensurer.reset-size4.size5,.katex .sizing.reset-size4.size5{font-size:1.125em}.katex .fontsize-ensurer.reset-size4.size6,.katex .sizing.reset-size4.size6{font-size:1.25em}.katex .fontsize-ensurer.reset-size4.size7,.katex .sizing.reset-size4.size7{font-size:1.5em}.katex .fontsize-ensurer.reset-size4.size8,.katex .sizing.reset-size4.size8{font-size:1.8em}.katex .fontsize-ensurer.reset-size4.size9,.katex .sizing.reset-size4.size9{font-size:2.16em}.katex .fontsize-ensurer.reset-size4.size10,.katex .sizing.reset-size4.size10{font-size:2.5925em}.katex .fontsize-ensurer.reset-size4.size11,.katex .sizing.reset-size4.size11{font-size:3.11em}.katex .fontsize-ensurer.reset-size5.size1,.katex .sizing.reset-size5.size1{font-size:.55555556em}.katex .fontsize-ensurer.reset-size5.size2,.katex .sizing.reset-size5.size2{font-size:.66666667em}.katex .fontsize-ensurer.reset-size5.size3,.katex .sizing.reset-size5.size3{font-size:.77777778em}.katex .fontsize-ensurer.reset-size5.size4,.katex .sizing.reset-size5.size4{font-size:.88888889em}.katex .fontsize-ensurer.reset-size5.size5,.katex .sizing.reset-size5.size5{font-size:1em}.katex .fontsize-ensurer.reset-size5.size6,.katex .sizing.reset-size5.size6{font-size:1.11111111em}.katex .fontsize-ensurer.reset-size5.size7,.katex .sizing.reset-size5.size7{font-size:1.33333333em}.katex .fontsize-ensurer.reset-size5.size8,.katex .sizing.reset-size5.size8{font-size:1.6em}.katex .fontsize-ensurer.reset-size5.size9,.katex .sizing.reset-size5.size9{font-size:1.92em}.katex .fontsize-ensurer.reset-size5.size10,.katex .sizing.reset-size5.size10{font-size:2.30444444em}.katex .fontsize-ensurer.reset-size5.size11,.katex .sizing.reset-size5.size11{font-size:2.76444444em}.katex .fontsize-ensurer.reset-size6.size1,.katex .sizing.reset-size6.size1{font-size:.5em}.katex .fontsize-ensurer.reset-size6.size2,.katex .sizing.reset-size6.size2{font-size:.6em}.katex .fontsize-ensurer.reset-size6.size3,.katex .sizing.reset-size6.size3{font-size:.7em}.katex .fontsize-ensurer.reset-size6.size4,.katex .sizing.reset-size6.size4{font-size:.8em}.katex .fontsize-ensurer.reset-size6.size5,.katex .sizing.reset-size6.size5{font-size:.9em}.katex .fontsize-ensurer.reset-size6.size6,.katex .sizing.reset-size6.size6{font-size:1em}.katex .fontsize-ensurer.reset-size6.size7,.katex .sizing.reset-size6.size7{font-size:1.2em}.katex .fontsize-ensurer.reset-size6.size8,.katex .sizing.reset-size6.size8{font-size:1.44em}.katex .fontsize-ensurer.reset-size6.size9,.katex .sizing.reset-size6.size9{font-size:1.728em}.katex .fontsize-ensurer.reset-size6.size10,.katex .sizing.reset-size6.size10{font-size:2.074em}.katex .fontsize-ensurer.reset-size6.size11,.katex .sizing.reset-size6.size11{font-size:2.488em}.katex .fontsize-ensurer.reset-size7.size1,.katex .sizing.reset-size7.size1{font-size:.41666667em}.katex .fontsize-ensurer.reset-size7.size2,.katex .sizing.reset-size7.size2{font-size:.5em}.katex .fontsize-ensurer.reset-size7.size3,.katex .sizing.reset-size7.size3{font-size:.58333333em}.katex .fontsize-ensurer.reset-size7.size4,.katex .sizing.reset-size7.size4{font-size:.66666667em}.katex .fontsize-ensurer.reset-size7.size5,.katex .sizing.reset-size7.size5{font-size:.75em}.katex .fontsize-ensurer.reset-size7.size6,.katex .sizing.reset-size7.size6{font-size:.83333333em}.katex .fontsize-ensurer.reset-size7.size7,.katex .sizing.reset-size7.size7{font-size:1em}.katex .fontsize-ensurer.reset-size7.size8,.katex .sizing.reset-size7.size8{font-size:1.2em}.katex .fontsize-ensurer.reset-size7.size9,.katex .sizing.reset-size7.size9{font-size:1.44em}.katex .fontsize-ensurer.reset-size7.size10,.katex .sizing.reset-size7.size10{font-size:1.72833333em}.katex .fontsize-ensurer.reset-size7.size11,.katex .sizing.reset-size7.size11{font-size:2.07333333em}.katex .fontsize-ensurer.reset-size8.size1,.katex .sizing.reset-size8.size1{font-size:.34722222em}.katex .fontsize-ensurer.reset-size8.size2,.katex .sizing.reset-size8.size2{font-size:.41666667em}.katex .fontsize-ensurer.reset-size8.size3,.katex .sizing.reset-size8.size3{font-size:.48611111em}.katex .fontsize-ensurer.reset-size8.size4,.katex .sizing.reset-size8.size4{font-size:.55555556em}.katex .fontsize-ensurer.reset-size8.size5,.katex .sizing.reset-size8.size5{font-size:.625em}.katex .fontsize-ensurer.reset-size8.size6,.katex .sizing.reset-size8.size6{font-size:.69444444em}.katex .fontsize-ensurer.reset-size8.size7,.katex .sizing.reset-size8.size7{font-size:.83333333em}.katex .fontsize-ensurer.reset-size8.size8,.katex .sizing.reset-size8.size8{font-size:1em}.katex .fontsize-ensurer.reset-size8.size9,.katex .sizing.reset-size8.size9{font-size:1.2em}.katex .fontsize-ensurer.reset-size8.size10,.katex .sizing.reset-size8.size10{font-size:1.44027778em}.katex .fontsize-ensurer.reset-size8.size11,.katex .sizing.reset-size8.size11{font-size:1.72777778em}.katex .fontsize-ensurer.reset-size9.size1,.katex .sizing.reset-size9.size1{font-size:.28935185em}.katex .fontsize-ensurer.reset-size9.size2,.katex .sizing.reset-size9.size2{font-size:.34722222em}.katex .fontsize-ensurer.reset-size9.size3,.katex .sizing.reset-size9.size3{font-size:.40509259em}.katex .fontsize-ensurer.reset-size9.size4,.katex .sizing.reset-size9.size4{font-size:.46296296em}.katex .fontsize-ensurer.reset-size9.size5,.katex .sizing.reset-size9.size5{font-size:.52083333em}.katex .fontsize-ensurer.reset-size9.size6,.katex .sizing.reset-size9.size6{font-size:.5787037em}.katex .fontsize-ensurer.reset-size9.size7,.katex .sizing.reset-size9.size7{font-size:.69444444em}.katex .fontsize-ensurer.reset-size9.size8,.katex .sizing.reset-size9.size8{font-size:.83333333em}.katex .fontsize-ensurer.reset-size9.size9,.katex .sizing.reset-size9.size9{font-size:1em}.katex .fontsize-ensurer.reset-size9.size10,.katex .sizing.reset-size9.size10{font-size:1.20023148em}.katex .fontsize-ensurer.reset-size9.size11,.katex .sizing.reset-size9.size11{font-size:1.43981481em}.katex .fontsize-ensurer.reset-size10.size1,.katex .sizing.reset-size10.size1{font-size:.24108004em}.katex .fontsize-ensurer.reset-size10.size2,.katex .sizing.reset-size10.size2{font-size:.28929605em}.katex .fontsize-ensurer.reset-size10.size3,.katex .sizing.reset-size10.size3{font-size:.33751205em}.katex .fontsize-ensurer.reset-size10.size4,.katex .sizing.reset-size10.size4{font-size:.38572806em}.katex .fontsize-ensurer.reset-size10.size5,.katex .sizing.reset-size10.size5{font-size:.43394407em}.katex .fontsize-ensurer.reset-size10.size6,.katex .sizing.reset-size10.size6{font-size:.48216008em}.katex .fontsize-ensurer.reset-size10.size7,.katex .sizing.reset-size10.size7{font-size:.57859209em}.katex .fontsize-ensurer.reset-size10.size8,.katex .sizing.reset-size10.size8{font-size:.69431051em}.katex .fontsize-ensurer.reset-size10.size9,.katex .sizing.reset-size10.size9{font-size:.83317261em}.katex .fontsize-ensurer.reset-size10.size10,.katex .sizing.reset-size10.size10{font-size:1em}.katex .fontsize-ensurer.reset-size10.size11,.katex .sizing.reset-size10.size11{font-size:1.19961427em}.katex .fontsize-ensurer.reset-size11.size1,.katex .sizing.reset-size11.size1{font-size:.20096463em}.katex .fontsize-ensurer.reset-size11.size2,.katex .sizing.reset-size11.size2{font-size:.24115756em}.katex .fontsize-ensurer.reset-size11.size3,.katex .sizing.reset-size11.size3{font-size:.28135048em}.katex .fontsize-ensurer.reset-size11.size4,.katex .sizing.reset-size11.size4{font-size:.32154341em}.katex .fontsize-ensurer.reset-size11.size5,.katex .sizing.reset-size11.size5{font-size:.36173633em}.katex .fontsize-ensurer.reset-size11.size6,.katex .sizing.reset-size11.size6{font-size:.40192926em}.katex .fontsize-ensurer.reset-size11.size7,.katex .sizing.reset-size11.size7{font-size:.48231511em}.katex .fontsize-ensurer.reset-size11.size8,.katex .sizing.reset-size11.size8{font-size:.57877814em}.katex .fontsize-ensurer.reset-size11.size9,.katex .sizing.reset-size11.size9{font-size:.69453376em}.katex .fontsize-ensurer.reset-size11.size10,.katex .sizing.reset-size11.size10{font-size:.83360129em}.katex .fontsize-ensurer.reset-size11.size11,.katex .sizing.reset-size11.size11{font-size:1em}.katex .delimsizing.size1{font-family:KaTeX_Size1}.katex .delimsizing.size2{font-family:KaTeX_Size2}.katex .delimsizing.size3{font-family:KaTeX_Size3}.katex .delimsizing.size4{font-family:KaTeX_Size4}.katex .delimsizing.mult .delim-size1>span{font-family:KaTeX_Size1}.katex .delimsizing.mult .delim-size4>span{font-family:KaTeX_Size4}.katex .nulldelimiter{display:inline-block;width:.12em}.katex .delimcenter,.katex .op-symbol{position:relative}.katex .op-symbol.small-op{font-family:KaTeX_Size1}.katex .op-symbol.large-op{font-family:KaTeX_Size2}.katex .op-limits>.vlist-t{text-align:center}.katex .accent>.vlist-t{text-align:center}.katex .accent .accent-body{position:relative}.katex .accent .accent-body:not(.accent-full){width:0}.katex .overlay{display:block}.katex .mtable .vertical-separator{display:inline-block;min-width:1px}.katex .mtable .arraycolsep{display:inline-block}.katex .mtable .col-align-c>.vlist-t{text-align:center}.katex .mtable .col-align-l>.vlist-t{text-align:left}.katex .mtable .col-align-r>.vlist-t{text-align:right}.katex .svg-align{text-align:left}.katex svg{display:block;position:absolute;width:100%;height:inherit;fill:currentColor;stroke:currentColor;fill-rule:nonzero;fill-opacity:1;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1}.katex svg path{stroke:none}.katex img{border-style:none;min-width:0;min-height:0;max-width:none;max-height:none}.katex .stretchy{width:100%;display:block;position:relative;overflow:hidden}.katex .stretchy:after,.katex .stretchy:before{content:""}.katex .hide-tail{width:100%;position:relative;overflow:hidden}.katex .halfarrow-left{position:absolute;left:0;width:50.2%;overflow:hidden}.katex .halfarrow-right{position:absolute;right:0;width:50.2%;overflow:hidden}.katex .brace-left{position:absolute;left:0;width:25.1%;overflow:hidden}.katex .brace-center{position:absolute;left:25%;width:50%;overflow:hidden}.katex .brace-right{position:absolute;right:0;width:25.1%;overflow:hidden}.katex .x-arrow-pad{padding:0 .5em}.katex .mover,.katex .munder,.katex .x-arrow{text-align:center}.katex .boxpad{padding:0 .3em}.katex .fbox,.katex .fcolorbox{box-sizing:border-box;border:.04em solid}.katex .cancel-pad{padding:0 .2em}.katex .cancel-lap{margin-left:-.2em;margin-right:-.2em}.katex .sout{border-bottom-style:solid;border-bottom-width:.08em}.katex-display{display:block;margin:1em 0;text-align:center}.katex-display>.katex{display:block;text-align:center;white-space:nowrap}.katex-display>.katex>.katex-html{display:block;position:relative}.katex-display>.katex>.katex-html>.tag{position:absolute;right:0}.katex-display.leqno>.katex>.katex-html>.tag{left:0;right:auto}.katex-display.fleqn>.katex{text-align:left} diff --git a/app/katex/katex.min.css b/app/katex/katex.min.css new file mode 120000 index 000000000000..fd07e30be67b --- /dev/null +++ b/app/katex/katex.min.css @@ -0,0 +1 @@ +../../node_modules/katex/dist/katex.min.css \ No newline at end of file diff --git a/app/lib/lib/MessageTypes.js b/app/lib/lib/MessageTypes.js index 35df78e15288..8c6364f0ab13 100644 --- a/app/lib/lib/MessageTypes.js +++ b/app/lib/lib/MessageTypes.js @@ -206,4 +206,8 @@ export const MessageTypesValues = [ key: 'room_changed_privacy', i18nLabel: 'Message_HideType_room_changed_privacy', }, + { + key: 'room_changed_avatar', + i18nLabel: 'Message_HideType_room_changed_avatar', + }, ]; diff --git a/app/lib/server/functions/cleanRoomHistory.js b/app/lib/server/functions/cleanRoomHistory.js index 593fdaff09a4..a94f88d8ad50 100644 --- a/app/lib/server/functions/cleanRoomHistory.js +++ b/app/lib/server/functions/cleanRoomHistory.js @@ -1,9 +1,9 @@ import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; import { deleteRoom } from './deleteRoom'; -import { FileUpload } from '../../../file-upload'; -import { Messages, Rooms } from '../../../models'; -import { Notifications } from '../../../notifications'; +import { FileUpload } from '../../../file-upload/server'; +import { Messages, Rooms, Subscriptions } from '../../../models/server'; +import { Notifications } from '../../../notifications/server'; export const cleanRoomHistory = function({ rid, latest = new Date(), oldest = new Date('0001-01-01T00:00:00Z'), inclusive = true, limit = 0, excludePinned = true, ignoreDiscussion = true, filesOnly = false, fromUsers = [], ignoreThreads = true }) { const gt = inclusive ? '$gte' : '$gt'; @@ -39,7 +39,17 @@ export const cleanRoomHistory = function({ rid, latest = new Date(), oldest = ne .forEach(({ drid }) => deleteRoom(drid)); } - const count = Messages.removeByIdPinnedTimestampLimitAndUsers(rid, excludePinned, ignoreDiscussion, ts, limit, fromUsers); + if (!ignoreThreads) { + const threads = new Set(); + Messages.findThreadsByRoomIdPinnedTimestampAndUsers({ rid, pinned: excludePinned, ignoreDiscussion, ts, users: fromUsers }, { fields: { _id: 1 } }) + .forEach(({ _id }) => threads.add(_id)); + + if (threads.size > 0) { + Subscriptions.removeUnreadThreadsByRoomId(rid, [...threads]); + } + } + + const count = Messages.removeByIdPinnedTimestampLimitAndUsers(rid, excludePinned, ignoreDiscussion, ts, limit, fromUsers, ignoreThreads); if (count) { Rooms.resetLastMessageById(rid); Notifications.notifyRoom(rid, 'deleteMessageBulk', { diff --git a/app/lib/server/functions/notifications/mobile.js b/app/lib/server/functions/notifications/mobile.js index bd0c93245bae..7465cf7d4246 100644 --- a/app/lib/server/functions/notifications/mobile.js +++ b/app/lib/server/functions/notifications/mobile.js @@ -59,10 +59,12 @@ export async function getPushData({ room, message, userId, senderUsername, sende return { payload: { sender: message.u, + senderName: username, type: room.t, name: room.name, messageType: message.t, tmid: message.tmid, + ...message.t === 'e2e' && { msg: message.msg }, }, roomName: settings.get('Push_show_username_room') && roomTypes.getConfig(room.t).isGroupChat(room) ? `#${ roomTypes.getRoomName(room.t, room) }` : '', username, @@ -171,6 +173,10 @@ export function shouldNotifyMobile({ roomType, isThread, }) { + if (settings.get('Push_enable') !== true) { + return false; + } + if (disableAllMessageNotifications && mobilePushNotifications == null && !isHighlighted && !hasMentionToUser && !hasReplyToThread) { return false; } diff --git a/app/lib/server/functions/setRoomAvatar.js b/app/lib/server/functions/setRoomAvatar.js index d34eb221f516..cbcd6431eccb 100644 --- a/app/lib/server/functions/setRoomAvatar.js +++ b/app/lib/server/functions/setRoomAvatar.js @@ -3,7 +3,7 @@ import { Meteor } from 'meteor/meteor'; import { RocketChatFile } from '../../../file'; import { FileUpload } from '../../../file-upload'; import { Notifications } from '../../../notifications'; -import { Rooms, Avatars } from '../../../models/server'; +import { Rooms, Avatars, Messages } from '../../../models/server'; export const setRoomAvatar = function(rid, dataURI, user) { const fileStore = FileUpload.getStore('Avatars'); @@ -32,6 +32,7 @@ export const setRoomAvatar = function(rid, dataURI, user) { fileStore.deleteById(current._id); } Rooms.setAvatarData(rid, 'upload', result.etag); + Messages.createRoomSettingsChangedWithTypeRoomIdMessageAndUser('room_changed_avatar', rid, '', user); Notifications.notifyLogged('updateAvatar', { rid, etag: result.etag }); }, 500); }); diff --git a/app/lib/server/functions/validateCustomFields.js b/app/lib/server/functions/validateCustomFields.js index fc3834f29fd6..403e1792dda7 100644 --- a/app/lib/server/functions/validateCustomFields.js +++ b/app/lib/server/functions/validateCustomFields.js @@ -37,7 +37,7 @@ export const validateCustomFields = function(fields) { throw new Meteor.Error('error-user-registration-custom-field', `Max length of field ${ fieldName } ${ field.maxLength }`, { method: 'registerUser' }); } - if (field.minLength && fieldValue.length < field.minLength) { + if (field.minLength && fieldValue.length > 0 && fieldValue.length < field.minLength) { throw new Meteor.Error('error-user-registration-custom-field', `Min length of field ${ fieldName } ${ field.minLength }`, { method: 'registerUser' }); } }); diff --git a/app/lib/server/startup/settings.js b/app/lib/server/startup/settings.js index c846d27cad47..5787dfc9f61c 100644 --- a/app/lib/server/startup/settings.js +++ b/app/lib/server/startup/settings.js @@ -1205,6 +1205,10 @@ settings.addGroup('Meta', function() { }); settings.addGroup('Mobile', function() { + this.add('Allow_Save_Media_to_Gallery', true, { + type: 'boolean', + public: true, + }); this.section('Screen_Lock', function() { this.add('Force_Screen_Lock', false, { type: 'boolean', i18nDescription: 'Force_Screen_Lock_description', public: true }); this.add('Force_Screen_Lock_After', 1800, { type: 'int', i18nDescription: 'Force_Screen_Lock_After_description', enableQuery: { _id: 'Force_Screen_Lock', value: true }, public: true }); diff --git a/app/lib/server/startup/userDataStream.js b/app/lib/server/startup/userDataStream.js index 8cdc95e4b66b..b7c796d992ed 100644 --- a/app/lib/server/startup/userDataStream.js +++ b/app/lib/server/startup/userDataStream.js @@ -1,10 +1,98 @@ +import { MongoInternals } from 'meteor/mongo'; + import { Users } from '../../../models/server'; import { Notifications } from '../../../notifications/server'; +import loginServiceConfiguration from '../../../models/server/models/LoginServiceConfiguration'; + +let processOnChange; +// eslint-disable-next-line no-undef +const disableOplog = Package['disable-oplog']; + +if (disableOplog) { + // Stores the callbacks for the disconnection reactivity bellow + const userCallbacks = new Map(); + const serviceConfigCallbacks = new Set(); + + // Overrides the native observe changes to prevent database polling and stores the callbacks + // for the users' tokens to re-implement the reactivity based on our database listeners + const { mongo } = MongoInternals.defaultRemoteCollectionDriver(); + MongoInternals.Connection.prototype._observeChanges = function({ collectionName, selector, options = {} }, _ordered, callbacks) { + // console.error('Connection.Collection.prototype._observeChanges', collectionName, selector, options); + let cbs; + if (callbacks?.added) { + const records = Promise.await(mongo.rawCollection(collectionName).find(selector, { projection: options.fields }).toArray()); + for (const { _id, ...fields } of records) { + callbacks.added(_id, fields); + } + + if (collectionName === 'users' && selector['services.resume.loginTokens.hashedToken']) { + cbs = userCallbacks.get(selector._id) || new Set(); + cbs.add({ + hashedToken: selector['services.resume.loginTokens.hashedToken'], + callbacks, + }); + userCallbacks.set(selector._id, cbs); + } + } + + if (collectionName === 'meteor_accounts_loginServiceConfiguration') { + serviceConfigCallbacks.add(callbacks); + } + + return { + stop() { + if (cbs) { + cbs.delete(callbacks); + } + serviceConfigCallbacks.delete(callbacks); + }, + }; + }; + + // Re-implement meteor's reactivity that uses observe to disconnect sessions when the token + // associated was removed + processOnChange = (diff, id) => { + const loginTokens = diff['services.resume.loginTokens']; + if (loginTokens) { + const tokens = loginTokens.map(({ hashedToken }) => hashedToken); + + const cbs = userCallbacks.get(id); + if (cbs) { + [...cbs].filter(({ hashedToken }) => !tokens.includes(hashedToken)).forEach((item) => { + item.callbacks.removed(id); + cbs.delete(item); + }); + } + } + }; + + loginServiceConfiguration.on('change', ({ clientAction, id, data, diff }) => { + switch (clientAction) { + case 'inserted': + case 'updated': + const record = { ...data || diff }; + delete record.secret; + serviceConfigCallbacks.forEach((callbacks) => { + callbacks[clientAction === 'inserted' ? 'added' : 'changed']?.(id, record); + }); + break; + case 'removed': + serviceConfigCallbacks.forEach((callbacks) => { + callbacks.removed?.(id); + }); + } + }); +} Users.on('change', ({ clientAction, id, data, diff }) => { switch (clientAction) { case 'updated': Notifications.notifyUserInThisInstance(id, 'userData', { diff, type: clientAction }); + + if (disableOplog) { + processOnChange(diff, id); + } + break; case 'inserted': Notifications.notifyUserInThisInstance(id, 'userData', { data, type: clientAction }); diff --git a/app/livechat/client/route.js b/app/livechat/client/route.js index 98be3912c977..670aa2574ea0 100644 --- a/app/livechat/client/route.js +++ b/app/livechat/client/route.js @@ -18,22 +18,6 @@ AccountBox.addRoute({ pageTemplate: 'livechatDashboard', }, livechatManagerRoutes, load); -AccountBox.addRoute({ - name: 'livechat-analytics', - path: '/analytics', - sideNav: 'omnichannelFlex', - i18nPageTitle: 'Analytics', - pageTemplate: 'livechatAnalytics', -}, livechatManagerRoutes, load); - -AccountBox.addRoute({ - name: 'livechat-real-time-monitoring', - path: '/real-time-monitoring', - sideNav: 'omnichannelFlex', - i18nPageTitle: 'Real_Time_Monitoring', - pageTemplate: 'livechatRealTimeMonitoring', -}, livechatManagerRoutes, load); - AccountBox.addRoute({ name: 'livechat-departments', path: '/departments', diff --git a/app/livechat/client/views/admin.js b/app/livechat/client/views/admin.js index 495de3e9d6d7..78a7e39c94d5 100644 --- a/app/livechat/client/views/admin.js +++ b/app/livechat/client/views/admin.js @@ -1,7 +1,3 @@ -import './app/analytics/livechatAnalytics'; -import './app/analytics/livechatAnalyticsCustomDaterange'; -import './app/analytics/livechatAnalyticsDaterange'; -import './app/analytics/livechatRealTimeMonitoring'; import './app/livechatDashboard.html'; import './app/livechatDepartmentForm'; import './app/livechatDepartments'; diff --git a/app/livechat/client/views/app/analytics/livechatAnalytics.html b/app/livechat/client/views/app/analytics/livechatAnalytics.html deleted file mode 100644 index 05bacc18aabb..000000000000 --- a/app/livechat/client/views/app/analytics/livechatAnalytics.html +++ /dev/null @@ -1,122 +0,0 @@ - diff --git a/app/livechat/client/views/app/analytics/livechatAnalytics.js b/app/livechat/client/views/app/analytics/livechatAnalytics.js deleted file mode 100644 index ec74d3eb2411..000000000000 --- a/app/livechat/client/views/app/analytics/livechatAnalytics.js +++ /dev/null @@ -1,264 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { ReactiveVar } from 'meteor/reactive-var'; -import { Tracker } from 'meteor/tracker'; -import { Template } from 'meteor/templating'; -import moment from 'moment'; - -import { handleError } from '../../../../../utils'; -import { popover } from '../../../../../ui-utils'; -import { drawLineChart } from '../../../lib/chartHandler'; -import { setDateRange, updateDateRange } from '../../../lib/dateHandler'; -import { APIClient } from '../../../../../utils/client'; -import './livechatAnalytics.html'; - -let templateInstance; // current template instance/context -let chartContext; // stores context of current chart, used to clean when redrawing - -const analyticsAllOptions = () => [{ - name: 'Conversations', - value: 'conversations', - chartOptions: [{ - name: 'Total_conversations', - value: 'total-conversations', - }, { - name: 'Avg_chat_duration', - value: 'avg-chat-duration', - }, { - name: 'Total_messages', - value: 'total-messages', - }], -}, { - name: 'Productivity', - value: 'productivity', - chartOptions: [{ - name: 'Avg_first_response_time', - value: 'avg-first-response-time', - }, { - name: 'Best_first_response_time', - value: 'best_first_response_time', - }, { - name: 'Avg_response_time', - value: 'avg-response-time', - }, { - name: 'Avg_reaction_time', - value: 'avg-reaction-time', - }], -}]; - -/** - * - * @param {Array} arr - * @param {Integer} chunkCount - * - * @returns {Array{Array}} Array containing arrays - */ -const chunkArray = (arr, chunkCount) => { // split array into n almost equal arrays - const chunks = []; - while (arr.length) { - const chunkSize = Math.ceil(arr.length / chunkCount--); - const chunk = arr.slice(0, chunkSize); - chunks.push(chunk); - arr = arr.slice(chunkSize); - } - return chunks; -}; - -const getChartDepartment = (department) => department?._id; - -const updateAnalyticsChart = () => { - const [department] = templateInstance.selectedDepartments.get(); - const departmentId = getChartDepartment(department); - - const options = { - daterange: { - from: moment(templateInstance.daterange.get().from, 'MMM D YYYY').toISOString(), - to: moment(templateInstance.daterange.get().to, 'MMM D YYYY').toISOString(), - }, - chartOptions: templateInstance.chartOptions.get(), - ...departmentId && { departmentId }, - }; - - Meteor.call('livechat:getAnalyticsChartData', options, async function(error, result) { - if (error) { - return handleError(error); - } - - if (!(result && result.chartLabel && result.dataLabels && result.dataPoints)) { - console.log('livechat:getAnalyticsChartData => Missing Data'); - } - - chartContext = await drawLineChart(document.getElementById('lc-analytics-chart'), chartContext, [result.chartLabel], result.dataLabels, [result.dataPoints]); - }); - - Meteor.call('livechat:getAgentOverviewData', options, function(error, result) { - if (error) { - return handleError(error); - } - - if (!result) { - console.log('livechat:getAgentOverviewData => Missing Data'); - } - - templateInstance.agentOverviewData.set(result); - }); -}; - -const updateAnalyticsOverview = () => { - const [department] = templateInstance.selectedDepartments.get(); - const departmentId = getChartDepartment(department); - - const options = { - daterange: { - from: moment(templateInstance.daterange.get().from, 'MMM D YYYY').toISOString(), - to: moment(templateInstance.daterange.get().to, 'MMM D YYYY').toISOString(), - }, - analyticsOptions: templateInstance.analyticsOptions.get(), - ...departmentId && { departmentId }, - }; - - Meteor.call('livechat:getAnalyticsOverviewData', options, (error, result) => { - if (error) { - return handleError(error); - } - - if (!result) { - console.log('livechat:getAnalyticsOverviewData => Missing Data'); - } - - templateInstance.analyticsOverviewData.set(chunkArray(result, 3)); - }); -}; - -Template.livechatAnalytics.helpers({ - analyticsOverviewData() { - return templateInstance.analyticsOverviewData.get(); - }, - agentOverviewData() { - return templateInstance.agentOverviewData.get(); - }, - analyticsAllOptions() { - return analyticsAllOptions(); - }, - analyticsOptions() { - return templateInstance.analyticsOptions.get(); - }, - daterange() { - return templateInstance.daterange.get(); - }, - selected(value) { - if (value === templateInstance.analyticsOptions.get().value || value === templateInstance.chartOptions.get().value) { return 'selected'; } - return false; - }, - showLeftNavButton() { - if (templateInstance.daterange.get().value === 'custom') { - return false; - } - return true; - }, - showRightNavButton() { - if (templateInstance.daterange.get().value === 'custom' || templateInstance.daterange.get().value === 'today' || templateInstance.daterange.get().value === 'this-week' || templateInstance.daterange.get().value === 'this-month') { - return false; - } - return true; - }, - departmentModifier() { - return (filter, text = '') => { - const f = filter.get(); - return `${ f.length === 0 ? text : text.replace(new RegExp(filter.get(), 'i'), (part) => `${ part }`) }`; - }; - }, - onClickTagDepartment() { - return Template.instance().onClickTagDepartment; - }, - selectedDepartments() { - return Template.instance().selectedDepartments.get(); - }, - onSelectDepartments() { - return Template.instance().onSelectDepartments; - }, - hasDepartments() { - return Template.instance().hasDepartments.get(); - }, -}); - - -Template.livechatAnalytics.onCreated(async function() { - templateInstance = Template.instance(); - - this.analyticsOverviewData = new ReactiveVar(); - this.agentOverviewData = new ReactiveVar(); - this.daterange = new ReactiveVar({}); - this.analyticsOptions = new ReactiveVar(analyticsAllOptions()[0]); // default selected first - this.chartOptions = new ReactiveVar(analyticsAllOptions()[0].chartOptions[0]); // default selected first - this.selectedDepartments = new ReactiveVar([]); - this.hasDepartments = new ReactiveVar(false); - - this.onSelectDepartments = ({ item: department }) => { - department.text = department.name; - this.selectedDepartments.set([department]); - }; - - this.onClickTagDepartment = () => { - this.selectedDepartments.set([]); - }; - - const { departments } = await APIClient.v1.get('livechat/department?count=1'); - this.hasDepartments.set(departments?.length > 0); - - this.autorun(() => { - templateInstance.daterange.set(setDateRange()); - }); -}); - -Template.livechatAnalytics.onRendered(() => { - Tracker.autorun(() => { - if (templateInstance.daterange.get() - && templateInstance.analyticsOptions.get() - && templateInstance.chartOptions.get()) { - updateAnalyticsOverview(); - updateAnalyticsChart(); - } - }); -}); - -Template.livechatAnalytics.events({ - 'click .lc-date-picker-btn'(e) { - e.preventDefault(); - const options = []; - const config = { - template: 'livechatAnalyticsDaterange', - currentTarget: e.currentTarget, - data: { - options, - daterange: templateInstance.daterange, - }, - offsetVertical: e.currentTarget.clientHeight + 10, - }; - popover.open(config); - }, - 'click .lc-daterange-prev'(e) { - e.preventDefault(); - - templateInstance.daterange.set(updateDateRange(templateInstance.daterange.get(), -1)); - }, - 'click .lc-daterange-next'(e) { - e.preventDefault(); - - templateInstance.daterange.set(updateDateRange(templateInstance.daterange.get(), 1)); - }, - 'change #lc-analytics-options'(e) { - e.preventDefault(); - - templateInstance.analyticsOptions.set(analyticsAllOptions().filter(function(obj) { - return obj.value === e.currentTarget.value; - })[0]); - templateInstance.chartOptions.set(templateInstance.analyticsOptions.get().chartOptions[0]); - }, - 'change #lc-analytics-chart-options'(e) { - e.preventDefault(); - - templateInstance.chartOptions.set(templateInstance.analyticsOptions.get().chartOptions.filter(function(obj) { - return obj.value === e.currentTarget.value; - })[0]); - }, -}); diff --git a/app/livechat/client/views/app/analytics/livechatAnalyticsCustomDaterange.html b/app/livechat/client/views/app/analytics/livechatAnalyticsCustomDaterange.html deleted file mode 100644 index fa166b5a5b72..000000000000 --- a/app/livechat/client/views/app/analytics/livechatAnalyticsCustomDaterange.html +++ /dev/null @@ -1,15 +0,0 @@ - diff --git a/app/livechat/client/views/app/analytics/livechatAnalyticsCustomDaterange.js b/app/livechat/client/views/app/analytics/livechatAnalyticsCustomDaterange.js deleted file mode 100644 index d2c11e9cc449..000000000000 --- a/app/livechat/client/views/app/analytics/livechatAnalyticsCustomDaterange.js +++ /dev/null @@ -1,42 +0,0 @@ -import { Template } from 'meteor/templating'; -import moment from 'moment'; - -import { handleError } from '../../../../../utils'; -import { popover } from '../../../../../ui-utils'; -import { setDateRange } from '../../../lib/dateHandler'; -import './livechatAnalyticsCustomDaterange.html'; - - -Template.livechatAnalyticsCustomDaterange.helpers({ - from() { - return moment(Template.currentData().daterange.get().from, 'MMM D YYYY').format('L'); - }, - to() { - return moment(Template.currentData().daterange.get().to, 'MMM D YYYY').format('L'); - }, -}); - -Template.livechatAnalyticsCustomDaterange.onRendered(function() { - this.$('.lc-custom-daterange').datepicker({ - autoclose: true, - todayHighlight: true, - format: moment.localeData().longDateFormat('L').toLowerCase(), - }); -}); - - -Template.livechatAnalyticsCustomDaterange.events({ - 'click .lc-custom-daterange-submit'(e) { - e.preventDefault(); - const from = document.getElementsByClassName('lc-custom-daterange-from')[0].value; - const to = document.getElementsByClassName('lc-custom-daterange-to')[0].value; - - if (moment(from).isValid() && moment(to).isValid()) { - Template.currentData().daterange.set(setDateRange('custom', moment(new Date(from)), moment(new Date(to)))); - } else { - handleError({ details: { errorTitle: 'Invalid_dates' }, error: 'Error_in_custom_dates' }); - } - - popover.close(); - }, -}); diff --git a/app/livechat/client/views/app/analytics/livechatAnalyticsDaterange.html b/app/livechat/client/views/app/analytics/livechatAnalyticsDaterange.html deleted file mode 100644 index e6f87416ac91..000000000000 --- a/app/livechat/client/views/app/analytics/livechatAnalyticsDaterange.html +++ /dev/null @@ -1,48 +0,0 @@ - diff --git a/app/livechat/client/views/app/analytics/livechatAnalyticsDaterange.js b/app/livechat/client/views/app/analytics/livechatAnalyticsDaterange.js deleted file mode 100644 index fa0f851a357e..000000000000 --- a/app/livechat/client/views/app/analytics/livechatAnalyticsDaterange.js +++ /dev/null @@ -1,57 +0,0 @@ -import { Template } from 'meteor/templating'; -import moment from 'moment'; - -import { popover } from '../../../../../ui-utils'; -import { setDateRange } from '../../../lib/dateHandler'; -import './livechatAnalyticsDaterange.html'; - -Template.livechatAnalyticsDaterange.helpers({ - bold(prop) { - return prop === Template.currentData().daterange.get().value ? 'rc-popover__item--bold' : ''; - }, -}); - -Template.livechatAnalyticsDaterange.events({ - 'change input'(e) { - e.preventDefault(); - - const value = e.currentTarget.getAttribute('type') === 'checkbox' ? e.currentTarget.checked : e.currentTarget.value; - - popover.close(); - - switch (value) { - case 'custom': - const target = document.getElementsByClassName('lc-date-picker-btn')[0]; - const options = []; - const config = { - template: 'livechatAnalyticsCustomDaterange', - currentTarget: target, - data: { - options, - daterange: Template.currentData().daterange, - }, - offsetVertical: target.clientHeight + 10, - }; - popover.open(config); - break; - case 'today': - Template.currentData().daterange.set(setDateRange(value, moment().startOf('day'), moment().startOf('day'))); - break; - case 'yesterday': - Template.currentData().daterange.set(setDateRange(value, moment().subtract(1, 'days').startOf('day'), moment().subtract(1, 'days').startOf('day'))); - break; - case 'this-week': - Template.currentData().daterange.set(setDateRange(value, moment().startOf('week'), moment().endOf('week'))); - break; - case 'prev-week': - Template.currentData().daterange.set(setDateRange(value, moment().subtract(1, 'weeks').startOf('week'), moment().subtract(1, 'weeks').endOf('week'))); - break; - case 'this-month': - Template.currentData().daterange.set(setDateRange(value, moment().startOf('month'), moment().endOf('month'))); - break; - case 'prev-month': - Template.currentData().daterange.set(setDateRange(value, moment().subtract(1, 'months').startOf('month'), moment().subtract(1, 'months').endOf('month'))); - break; - } - }, -}); diff --git a/app/livechat/client/views/app/analytics/livechatRealTimeMonitoring.html b/app/livechat/client/views/app/analytics/livechatRealTimeMonitoring.html deleted file mode 100644 index 04818769e352..000000000000 --- a/app/livechat/client/views/app/analytics/livechatRealTimeMonitoring.html +++ /dev/null @@ -1,164 +0,0 @@ - diff --git a/app/livechat/client/views/app/analytics/livechatRealTimeMonitoring.js b/app/livechat/client/views/app/analytics/livechatRealTimeMonitoring.js deleted file mode 100644 index 584e0f003b1f..000000000000 --- a/app/livechat/client/views/app/analytics/livechatRealTimeMonitoring.js +++ /dev/null @@ -1,343 +0,0 @@ -import { Template } from 'meteor/templating'; -import moment from 'moment'; -import { ReactiveVar } from 'meteor/reactive-var'; - -import { drawLineChart, drawDoughnutChart, updateChart } from '../../../lib/chartHandler'; -import { APIClient } from '../../../../../utils/client'; -import './livechatRealTimeMonitoring.html'; - -const chartContexts = {}; // stores context of current chart, used to clean when redrawing -let templateInstance; - -const initChart = { - 'lc-chats-chart'() { - return drawDoughnutChart( - document.getElementById('lc-chats-chart'), - 'Chats', - chartContexts['lc-chats-chart'], - ['Open', 'Queue', 'Closed'], [0, 0, 0]); - }, - - 'lc-agents-chart'() { - return drawDoughnutChart( - document.getElementById('lc-agents-chart'), - 'Agents', - chartContexts['lc-agents-chart'], - ['Available', 'Away', 'Busy', 'Offline'], [0, 0, 0, 0]); - }, - - 'lc-chats-per-agent-chart'() { - return drawLineChart( - document.getElementById('lc-chats-per-agent-chart'), - chartContexts['lc-chats-per-agent-chart'], - ['Open', 'Closed'], - [], [[], []], { legends: true, anim: true, smallTicks: true }); - }, - - 'lc-chats-per-dept-chart'() { - if (!document.getElementById('lc-chats-per-dept-chart')) { - return null; - } - - return drawLineChart( - document.getElementById('lc-chats-per-dept-chart'), - chartContexts['lc-chats-per-dept-chart'], - ['Open', 'Closed'], - [], [[], []], { legends: true, anim: true, smallTicks: true }); - }, - - 'lc-reaction-response-times-chart'() { - const timingLabels = []; - const initData = []; - const today = moment().startOf('day'); - for (let m = today; m.diff(moment(), 'hours') < 0; m.add(1, 'hours')) { - const hour = m.format('H'); - timingLabels.push(`${ moment(hour, ['H']).format('hA') }-${ moment((parseInt(hour) + 1) % 24, ['H']).format('hA') }`); - initData.push(0); - } - - return drawLineChart( - document.getElementById('lc-reaction-response-times-chart'), - chartContexts['lc-reaction-response-times-chart'], - ['Avg_reaction_time', 'Longest_reaction_time', 'Avg_response_time', 'Longest_response_time'], - timingLabels.slice(), - [initData.slice(), initData.slice(), initData.slice(), initData.slice()], { legends: true, anim: true, smallTicks: true }); - }, - - 'lc-chat-duration-chart'() { - const timingLabels = []; - const initData = []; - const today = moment().startOf('day'); - for (let m = today; m.diff(moment(), 'hours') < 0; m.add(1, 'hours')) { - const hour = m.format('H'); - timingLabels.push(`${ moment(hour, ['H']).format('hA') }-${ moment((parseInt(hour) + 1) % 24, ['H']).format('hA') }`); - initData.push(0); - } - - return drawLineChart( - document.getElementById('lc-chat-duration-chart'), - chartContexts['lc-chat-duration-chart'], - ['Avg_chat_duration', 'Longest_chat_duration'], - timingLabels.slice(), - [initData.slice(), initData.slice()], { legends: true, anim: true, smallTicks: true }); - }, -}; - -const initAllCharts = async () => { - chartContexts['lc-chats-chart'] = await initChart['lc-chats-chart'](); - chartContexts['lc-agents-chart'] = await initChart['lc-agents-chart'](); - chartContexts['lc-chats-per-agent-chart'] = await initChart['lc-chats-per-agent-chart'](); - chartContexts['lc-chats-per-dept-chart'] = await initChart['lc-chats-per-dept-chart'](); - chartContexts['lc-reaction-response-times-chart'] = await initChart['lc-reaction-response-times-chart'](); - chartContexts['lc-chat-duration-chart'] = await initChart['lc-chat-duration-chart'](); -}; - -const updateChartData = async (chartId, label, data) => { - if (!chartContexts[chartId]) { - chartContexts[chartId] = await initChart[chartId](); - } - - await updateChart(chartContexts[chartId], label, data); -}; - -let timer; - -const getChartDepartment = (department) => department?._id; - -const getDaterange = () => { - const today = moment(new Date()); - return { - start: `${ moment(new Date(today.year(), today.month(), today.date(), 0, 0, 0)).utc().format('YYYY-MM-DDTHH:mm:ss') }Z`, - end: `${ moment(new Date(today.year(), today.month(), today.date(), 23, 59, 59)).utc().format('YYYY-MM-DDTHH:mm:ss') }Z`, - }; -}; - -const parseAdditionalParams = (options = {}, prefix = '') => `${ prefix }${ Object.keys(options).map((key) => `${ key }=${ options[key] }`).join('&') }`; - -const loadConversationOverview = async ({ start, end, ...options }) => { - const additionalParams = parseAdditionalParams(options, '&'); - const { totalizers } = await APIClient.v1.get(`livechat/analytics/dashboards/conversation-totalizers?start=${ start }&end=${ end }${ additionalParams }`); - return totalizers; -}; - -const updateConversationOverview = async (totalizers) => { - if (totalizers && Array.isArray(totalizers)) { - templateInstance.conversationsOverview.set(totalizers); - } -}; - -const loadAgentsOverview = async ({ start, end, ...options }) => { - const additionalParams = parseAdditionalParams(options, '&'); - const { totalizers } = await APIClient.v1.get(`livechat/analytics/dashboards/agents-productivity-totalizers?start=${ start }&end=${ end }${ additionalParams }`); - return totalizers; -}; - -const updateAgentsOverview = async (totalizers) => { - if (totalizers && Array.isArray(totalizers)) { - templateInstance.agentsOverview.set(totalizers); - } -}; -const loadChatsOverview = async ({ start, end, ...options }) => { - const additionalParams = parseAdditionalParams(options, '&'); - const { totalizers } = await APIClient.v1.get(`livechat/analytics/dashboards/chats-totalizers?start=${ start }&end=${ end }${ additionalParams }`); - return totalizers; -}; - -const updateChatsOverview = async (totalizers) => { - if (totalizers && Array.isArray(totalizers)) { - templateInstance.chatsOverview.set(totalizers); - } -}; - -const loadProductivityOverview = async ({ start, end, ...options }) => { - const additionalParams = parseAdditionalParams(options, '&'); - const { totalizers } = await APIClient.v1.get(`livechat/analytics/dashboards/productivity-totalizers?start=${ start }&end=${ end }${ additionalParams }`); - return totalizers; -}; - -const updateProductivityOverview = async (totalizers) => { - if (totalizers && Array.isArray(totalizers)) { - templateInstance.timingOverview.set(totalizers); - } -}; - -const loadChatsChartData = ({ start, end, ...options }) => { - const additionalParams = parseAdditionalParams(options, '&'); - return APIClient.v1.get(`livechat/analytics/dashboards/charts/chats?start=${ start }&end=${ end }${ additionalParams }`); -}; - -const updateChatsChart = async ({ open, closed, queued }) => { - await updateChartData('lc-chats-chart', 'Open', [open]); - await updateChartData('lc-chats-chart', 'Closed', [closed]); - await updateChartData('lc-chats-chart', 'Queue', [queued]); -}; - -const loadChatsPerAgentChartData = async ({ start, end, ...options }) => { - const additionalParams = parseAdditionalParams(options, '&'); - const result = await APIClient.v1.get(`livechat/analytics/dashboards/charts/chats-per-agent?start=${ start }&end=${ end }${ additionalParams }`); - delete result.success; - return result; -}; - -const updateChatsPerAgentChart = async (agents) => { - // this chart need to reset before new updates - chartContexts['lc-chats-per-agent-chart'] = await initChart['lc-chats-per-agent-chart'](); - - Object - .keys(agents) - .forEach((agent) => updateChartData('lc-chats-per-agent-chart', agent, [agents[agent].open, agents[agent].closed])); -}; - -const loadAgentsStatusChartData = ({ departmentId }) => { - const additionalParams = parseAdditionalParams({ departmentId }, '?'); - return APIClient.v1.get(`livechat/analytics/dashboards/charts/agents-status${ additionalParams }`); -}; - -const updateAgentStatusChart = async (statusData) => { - if (!statusData) { - return; - } - - await updateChartData('lc-agents-chart', 'Offline', [statusData.offline]); - await updateChartData('lc-agents-chart', 'Available', [statusData.available]); - await updateChartData('lc-agents-chart', 'Away', [statusData.away]); - await updateChartData('lc-agents-chart', 'Busy', [statusData.busy]); -}; - -const loadChatsPerDepartmentChartData = async ({ start, end, ...options }) => { - const additionalParams = parseAdditionalParams(options, '&'); - const result = await APIClient.v1.get(`livechat/analytics/dashboards/charts/chats-per-department?start=${ start }&end=${ end }${ additionalParams }`); - delete result.success; - return result; -}; - -const updateDepartmentsChart = async (departments) => { - // this chart need to reset before new updates - chartContexts['lc-chats-per-dept-chart'] = await initChart['lc-chats-per-dept-chart'](); - - Object - .keys(departments) - .forEach((department) => updateChartData('lc-chats-per-dept-chart', department, [departments[department].open, departments[department].closed])); -}; - -const loadTimingsChartData = ({ start, end, ...options }) => { - const additionalParams = parseAdditionalParams(options, '&'); - return APIClient.v1.get(`livechat/analytics/dashboards/charts/timings?start=${ start }&end=${ end }${ additionalParams }`); -}; - -const updateTimingsChart = async (timingsData) => { - const hour = moment(new Date()).format('H'); - const label = `${ moment(hour, ['H']).format('hA') }-${ moment((parseInt(hour) + 1) % 24, ['H']).format('hA') }`; - - await updateChartData('lc-reaction-response-times-chart', label, [timingsData.reaction.avg, timingsData.reaction.longest, timingsData.response.avg, timingsData.response.longest]); - await updateChartData('lc-chat-duration-chart', label, [timingsData.chatDuration.avg, timingsData.chatDuration.longest]); -}; - -const getIntervalInMS = () => templateInstance.interval.get() * 1000; - -Template.livechatRealTimeMonitoring.helpers({ - selected(value) { - return value === templateInstance.analyticsOptions.get().value || value === templateInstance.chartOptions.get().value ? 'selected' : false; - }, - conversationsOverview() { - return templateInstance.conversationsOverview.get(); - }, - timingOverview() { - return templateInstance.timingOverview.get(); - }, - agentsOverview() { - return templateInstance.agentsOverview.get(); - }, - chatsOverview() { - return templateInstance.chatsOverview.get(); - }, - isLoading() { - return Template.instance().isLoading.get(); - }, - departmentModifier() { - return (filter, text = '') => { - const f = filter.get(); - return `${ f.length === 0 ? text : text.replace(new RegExp(filter.get(), 'i'), (part) => `${ part }`) }`; - }; - }, - onClickTagDepartment() { - return Template.instance().onClickTagDepartment; - }, - selectedDepartments() { - return Template.instance().selectedDepartments.get(); - }, - onSelectDepartments() { - return Template.instance().onSelectDepartments; - }, - hasDepartments() { - return Template.instance().hasDepartments.get(); - }, -}); - -Template.livechatRealTimeMonitoring.onCreated(async function() { - templateInstance = Template.instance(); - this.isLoading = new ReactiveVar(false); - this.conversationsOverview = new ReactiveVar(); - this.timingOverview = new ReactiveVar(); - this.chatsOverview = new ReactiveVar(); - this.agentsOverview = new ReactiveVar(); - this.conversationTotalizers = new ReactiveVar([]); - this.interval = new ReactiveVar(5); - this.selectedDepartments = new ReactiveVar([]); - this.hasDepartments = new ReactiveVar(false); - - this.onSelectDepartments = ({ item: department }) => { - department.text = department.name; - this.selectedDepartments.set([department]); - }; - - this.onClickTagDepartment = () => { - this.selectedDepartments.set([]); - }; - - const { departments } = await APIClient.v1.get('livechat/department?count=1'); - this.hasDepartments.set(departments?.length > 0); -}); - -Template.livechatRealTimeMonitoring.onRendered(async function() { - await initAllCharts(); - - this.updateDashboard = async () => { - const [department] = this.selectedDepartments.get(); - const departmentId = getChartDepartment(department); - const daterange = getDaterange(); - const filters = Object.assign( - { ...daterange }, - departmentId && { departmentId }, - ); - - updateConversationOverview(await loadConversationOverview(filters)); - updateProductivityOverview(await loadProductivityOverview(filters)); - updateChatsChart(await loadChatsChartData(filters)); - updateChatsPerAgentChart(await loadChatsPerAgentChartData(filters)); - updateAgentStatusChart(await loadAgentsStatusChartData(filters)); - updateDepartmentsChart(await loadChatsPerDepartmentChartData(filters)); - updateTimingsChart(await loadTimingsChartData(filters)); - updateAgentsOverview(await loadAgentsOverview(filters)); - updateChatsOverview(await loadChatsOverview(filters)); - }; - this.autorun(() => { - if (timer) { - clearInterval(timer); - } - timer = setInterval(() => this.updateDashboard(), getIntervalInMS()); - }); - this.isLoading.set(true); - await this.updateDashboard(); - this.isLoading.set(false); -}); - -Template.livechatRealTimeMonitoring.events({ - 'change .js-interval': (event, instance) => { - instance.interval.set(event.target.value); - }, -}); - -Template.livechatRealTimeMonitoring.onDestroyed(function() { - clearInterval(timer); -}); diff --git a/app/livechat/server/lib/Helper.js b/app/livechat/server/lib/Helper.js index 4f7d4be897f0..42b6e890da1f 100644 --- a/app/livechat/server/lib/Helper.js +++ b/app/livechat/server/lib/Helper.js @@ -136,10 +136,16 @@ export const createLivechatSubscription = (rid, name, guest, agent) => { return Subscriptions.insert(subscriptionData); }; -export const createLivechatQueueView = () => { +export const createLivechatQueueView = async () => { const { mongo } = MongoInternals.defaultRemoteCollectionDriver(); - mongo.db.createCollection('view_livechat_queue_status', { // name of the view to create + // recreate the view on every startup + const list = await mongo.db.listCollections({ name: 'view_livechat_queue_status' }).toArray(); + if (list.length > 0) { + await mongo.db.dropCollection('view_livechat_queue_status'); + } + + await mongo.db.createCollection('view_livechat_queue_status', { // name of the view to create viewOn: 'rocketchat_room', // name of source collection from which to create the view pipeline: [ { diff --git a/app/livechat/server/lib/Livechat.js b/app/livechat/server/lib/Livechat.js index ce9244a5b96e..f81345327e0e 100644 --- a/app/livechat/server/lib/Livechat.js +++ b/app/livechat/server/lib/Livechat.js @@ -312,6 +312,7 @@ export const Livechat = { const ret = LivechatVisitors.saveGuestById(_id, updateData); Meteor.defer(() => { + Apps.triggerEvent(AppEvents.IPostLivechatGuestSaved, _id); callbacks.run('livechat.saveGuest', updateData); }); @@ -497,6 +498,7 @@ export const Livechat = { } Meteor.defer(() => { + Apps.triggerEvent(AppEvents.IPostLivechatRoomSaved, roomData._id); callbacks.run('livechat.saveRoom', roomData); }); diff --git a/app/mentions-flextab/client/actionButton.js b/app/mentions-flextab/client/actionButton.js index 52247c78fed3..e98b66524301 100644 --- a/app/mentions-flextab/client/actionButton.js +++ b/app/mentions-flextab/client/actionButton.js @@ -11,7 +11,7 @@ Meteor.startup(function() { id: 'jump-to-message', icon: 'jump', label: 'Jump_to_message', - context: ['mentions', 'threads', 'message'], + context: ['mentions', 'threads'], action() { const { msg: message } = messageArgs(this); if (window.matchMedia('(max-width: 500px)').matches) { @@ -30,6 +30,6 @@ Meteor.startup(function() { RoomHistoryManager.getSurroundingMessages(message, 50); }, order: 100, - group: 'menu', + group: ['message', 'menu'], }); }); diff --git a/app/message-pin/client/actionButton.js b/app/message-pin/client/actionButton.js index fd86421e7ba1..6690edfdf83b 100644 --- a/app/message-pin/client/actionButton.js +++ b/app/message-pin/client/actionButton.js @@ -89,7 +89,7 @@ Meteor.startup(function() { return msg.pinned && !!subscription; }, order: 100, - group: 'menu', + group: ['message', 'menu'], }); MessageAction.addButton({ diff --git a/app/message-star/client/actionButton.js b/app/message-star/client/actionButton.js index 89690f3da7c0..32d18a9f49fe 100644 --- a/app/message-star/client/actionButton.js +++ b/app/message-star/client/actionButton.js @@ -65,7 +65,7 @@ Meteor.startup(function() { id: 'jump-to-star-message', icon: 'jump', label: 'Jump_to_message', - context: ['starred', 'threads', 'message', 'message-mobile'], + context: ['starred', 'threads', 'message-mobile'], action() { const { msg: message } = messageArgs(this); if (window.matchMedia('(max-width: 500px)').matches) { @@ -92,7 +92,7 @@ Meteor.startup(function() { return msg.starred && msg.starred.find((star) => star._id === u._id); }, order: 100, - group: 'menu', + group: ['message', 'menu'], }); MessageAction.addButton({ diff --git a/app/metrics/server/index.js b/app/metrics/server/index.js index 3f176d629163..be589206b65b 100644 --- a/app/metrics/server/index.js +++ b/app/metrics/server/index.js @@ -1,6 +1,7 @@ import { metrics } from './lib/metrics'; import StatsTracker from './lib/statsTracker'; +import './lib/collectMetrics'; import './callbacksMetrics'; export { diff --git a/app/metrics/server/lib/collectMetrics.js b/app/metrics/server/lib/collectMetrics.js new file mode 100644 index 000000000000..31625134bed7 --- /dev/null +++ b/app/metrics/server/lib/collectMetrics.js @@ -0,0 +1,177 @@ +import http from 'http'; + +import client from 'prom-client'; +import connect from 'connect'; +import _ from 'underscore'; +import gcStats from 'prometheus-gc-stats'; +import { Meteor } from 'meteor/meteor'; +import { Facts } from 'meteor/facts-base'; + +import { Info, getOplogInfo } from '../../../utils/server'; +import { Migrations } from '../../../migrations'; +import { settings } from '../../../settings'; +import { Statistics } from '../../../models'; +import { metrics } from './metrics'; + +Facts.incrementServerFact = function(pkg, fact, increment) { + metrics.meteorFacts.inc({ pkg, fact }, increment); +}; + +const setPrometheusData = async () => { + metrics.info.set({ + version: Info.version, + unique_id: settings.get('uniqueID'), + site_url: settings.get('Site_Url'), + }, 1); + + const sessions = Array.from(Meteor.server.sessions.values()); + const authenticatedSessions = sessions.filter((s) => s.userId); + metrics.ddpSessions.set(Meteor.server.sessions.size); + metrics.ddpAuthenticatedSessions.set(authenticatedSessions.length); + metrics.ddpConnectedUsers.set(_.unique(authenticatedSessions.map((s) => s.userId)).length); + + const statistics = Statistics.findLast(); + if (!statistics) { + return; + } + + metrics.version.set({ version: statistics.version }, 1); + metrics.migration.set(Migrations._getControl().version); + metrics.instanceCount.set(statistics.instanceCount); + metrics.oplogEnabled.set({ enabled: statistics.oplogEnabled }, 1); + + // User statistics + metrics.totalUsers.set(statistics.totalUsers); + metrics.activeUsers.set(statistics.activeUsers); + metrics.nonActiveUsers.set(statistics.nonActiveUsers); + metrics.onlineUsers.set(statistics.onlineUsers); + metrics.awayUsers.set(statistics.awayUsers); + metrics.offlineUsers.set(statistics.offlineUsers); + + // Room statistics + metrics.totalRooms.set(statistics.totalRooms); + metrics.totalChannels.set(statistics.totalChannels); + metrics.totalPrivateGroups.set(statistics.totalPrivateGroups); + metrics.totalDirect.set(statistics.totalDirect); + metrics.totalLivechat.set(statistics.totalLivechat); + + // Message statistics + metrics.totalMessages.set(statistics.totalMessages); + metrics.totalChannelMessages.set(statistics.totalChannelMessages); + metrics.totalPrivateGroupMessages.set(statistics.totalPrivateGroupMessages); + metrics.totalDirectMessages.set(statistics.totalDirectMessages); + metrics.totalLivechatMessages.set(statistics.totalLivechatMessages); + + const oplogQueue = getOplogInfo().mongo._oplogHandle?._entryQueue?.length || 0; + metrics.oplogQueue.set(oplogQueue); + + metrics.pushQueue.set(statistics.pushQueue || 0); +}; + +const app = connect(); + +// const compression = require('compression'); +// app.use(compression()); + +app.use('/metrics', (req, res) => { + res.setHeader('Content-Type', 'text/plain'); + const data = client.register.metrics(); + + metrics.metricsRequests.inc(); + metrics.metricsSize.set(data.length); + + res.end(data); +}); + +app.use('/', (req, res) => { + const html = ` + + Rocket.Chat Prometheus Exporter + + +

Rocket.Chat Prometheus Exporter

+

Metrics

+ + `; + + res.write(html); + res.end(); +}); + +const server = http.createServer(app); + +let timer; +let resetTimer; +let defaultMetricsInitiated = false; +let gcStatsInitiated = false; +const was = { + enabled: false, + port: 9458, + resetInterval: 0, + collectGC: false, +}; +const updatePrometheusConfig = async () => { + const is = { + port: process.env.PROMETHEUS_PORT || settings.get('Prometheus_Port'), + enabled: settings.get('Prometheus_Enabled'), + resetInterval: settings.get('Prometheus_Reset_Interval'), + collectGC: settings.get('Prometheus_Garbage_Collector'), + }; + + if (Object.values(is).some((s) => s == null)) { + return; + } + + if (Object.entries(is).every(([k, v]) => v === was[k])) { + return; + } + + if (!is.enabled) { + if (was.enabled) { + console.log('Disabling Prometheus'); + server.close(); + Meteor.clearInterval(timer); + } + Object.assign(was, is); + return; + } + + console.log('Configuring Prometheus', is); + + if (!was.enabled) { + server.listen({ + port: is.port, + host: process.env.BIND_IP || '0.0.0.0', + }); + + timer = Meteor.setInterval(setPrometheusData, 5000); + } + + Meteor.clearInterval(resetTimer); + if (is.resetInterval) { + resetTimer = Meteor.setInterval(() => { + client.register.getMetricsAsArray().forEach((metric) => { metric.hashMap = {}; }); + }, is.resetInterval); + } + + // Prevent exceptions on calling those methods twice since + // it's not possible to stop them to be able to restart + try { + if (defaultMetricsInitiated === false) { + defaultMetricsInitiated = true; + client.collectDefaultMetrics(); + } + if (is.collectGC && gcStatsInitiated === false) { + gcStatsInitiated = true; + gcStats()(); + } + } catch (error) { + console.error(error); + } + + Object.assign(was, is); +}; + +Meteor.startup(async () => { + settings.get(/^Prometheus_.+/, updatePrometheusConfig); +}); diff --git a/app/metrics/server/lib/metrics.js b/app/metrics/server/lib/metrics.js index a3d5de8c8dbe..288daf0d90f2 100644 --- a/app/metrics/server/lib/metrics.js +++ b/app/metrics/server/lib/metrics.js @@ -1,17 +1,4 @@ -import http from 'http'; - import client from 'prom-client'; -import connect from 'connect'; -import _ from 'underscore'; -import gcStats from 'prometheus-gc-stats'; -import { Meteor } from 'meteor/meteor'; -import { Facts } from 'meteor/facts-base'; - -import { Info, getOplogInfo } from '../../../utils/server'; -import { Migrations } from '../../../migrations'; -import { settings } from '../../../settings'; -import { Statistics } from '../../../models'; -import { oplogEvents } from '../../../models/server/oplogEvents'; export const metrics = {}; const percentiles = [0.01, 0.1, 0.9, 0.99]; @@ -102,175 +89,3 @@ metrics.totalLivechatMessages = new client.Gauge({ name: 'rocketchat_livechat_me // Meteor Facts metrics.meteorFacts = new client.Gauge({ name: 'rocketchat_meteor_facts', labelNames: ['pkg', 'fact'], help: 'internal meteor facts' }); - -Facts.incrementServerFact = function(pkg, fact, increment) { - metrics.meteorFacts.inc({ pkg, fact }, increment); -}; - -const setPrometheusData = async () => { - metrics.info.set({ - version: Info.version, - unique_id: settings.get('uniqueID'), - site_url: settings.get('Site_Url'), - }, 1); - - const sessions = Array.from(Meteor.server.sessions.values()); - const authenticatedSessions = sessions.filter((s) => s.userId); - metrics.ddpSessions.set(Meteor.server.sessions.size); - metrics.ddpAuthenticatedSessions.set(authenticatedSessions.length); - metrics.ddpConnectedUsers.set(_.unique(authenticatedSessions.map((s) => s.userId)).length); - - const statistics = Statistics.findLast(); - if (!statistics) { - return; - } - - metrics.version.set({ version: statistics.version }, 1); - metrics.migration.set(Migrations._getControl().version); - metrics.instanceCount.set(statistics.instanceCount); - metrics.oplogEnabled.set({ enabled: statistics.oplogEnabled }, 1); - - // User statistics - metrics.totalUsers.set(statistics.totalUsers); - metrics.activeUsers.set(statistics.activeUsers); - metrics.nonActiveUsers.set(statistics.nonActiveUsers); - metrics.onlineUsers.set(statistics.onlineUsers); - metrics.awayUsers.set(statistics.awayUsers); - metrics.offlineUsers.set(statistics.offlineUsers); - - // Room statistics - metrics.totalRooms.set(statistics.totalRooms); - metrics.totalChannels.set(statistics.totalChannels); - metrics.totalPrivateGroups.set(statistics.totalPrivateGroups); - metrics.totalDirect.set(statistics.totalDirect); - metrics.totalLivechat.set(statistics.totalLivechat); - - // Message statistics - metrics.totalMessages.set(statistics.totalMessages); - metrics.totalChannelMessages.set(statistics.totalChannelMessages); - metrics.totalPrivateGroupMessages.set(statistics.totalPrivateGroupMessages); - metrics.totalDirectMessages.set(statistics.totalDirectMessages); - metrics.totalLivechatMessages.set(statistics.totalLivechatMessages); - - const oplogQueue = getOplogInfo().mongo._oplogHandle?._entryQueue?.length || 0; - metrics.oplogQueue.set(oplogQueue); - - metrics.pushQueue.set(statistics.pushQueue || 0); -}; - -const app = connect(); - -// const compression = require('compression'); -// app.use(compression()); - -app.use('/metrics', (req, res) => { - res.setHeader('Content-Type', 'text/plain'); - const data = client.register.metrics(); - - metrics.metricsRequests.inc(); - metrics.metricsSize.set(data.length); - - res.end(data); -}); - -app.use('/', (req, res) => { - const html = ` - - Rocket.Chat Prometheus Exporter - - -

Rocket.Chat Prometheus Exporter

-

Metrics

- - `; - - res.write(html); - res.end(); -}); - -const server = http.createServer(app); - -const oplogMetric = ({ collection, op }) => { - metrics.oplog.inc({ - collection, - op, - }); -}; - -let timer; -let resetTimer; -let defaultMetricsInitiated = false; -let gcStatsInitiated = false; -const was = { - enabled: false, - port: 9458, - resetInterval: 0, - collectGC: false, -}; -const updatePrometheusConfig = async () => { - const is = { - port: process.env.PROMETHEUS_PORT || settings.get('Prometheus_Port'), - enabled: settings.get('Prometheus_Enabled'), - resetInterval: settings.get('Prometheus_Reset_Interval'), - collectGC: settings.get('Prometheus_Garbage_Collector'), - }; - - if (Object.values(is).some((s) => s == null)) { - return; - } - - if (Object.entries(is).every(([k, v]) => v === was[k])) { - return; - } - - if (!is.enabled) { - if (was.enabled) { - console.log('Disabling Prometheus'); - server.close(); - Meteor.clearInterval(timer); - oplogEvents.removeListener('record', oplogMetric); - } - Object.assign(was, is); - return; - } - - console.log('Configuring Prometheus', is); - - if (!was.enabled) { - server.listen({ - port: is.port, - host: process.env.BIND_IP || '0.0.0.0', - }); - - timer = Meteor.setInterval(setPrometheusData, 5000); - oplogEvents.on('record', oplogMetric); - } - - Meteor.clearInterval(resetTimer); - if (is.resetInterval) { - resetTimer = Meteor.setInterval(() => { - client.register.getMetricsAsArray().forEach((metric) => { metric.hashMap = {}; }); - }, is.resetInterval); - } - - // Prevent exceptions on calling those methods twice since - // it's not possible to stop them to be able to restart - try { - if (defaultMetricsInitiated === false) { - defaultMetricsInitiated = true; - client.collectDefaultMetrics(); - } - if (is.collectGC && gcStatsInitiated === false) { - gcStatsInitiated = true; - gcStats()(); - } - } catch (error) { - console.error(error); - } - - Object.assign(was, is); -}; - -Meteor.startup(async () => { - settings.get(/^Prometheus_.+/, updatePrometheusConfig); -}); diff --git a/app/models/server/models/InstanceStatus.js b/app/models/server/models/InstanceStatus.js new file mode 100644 index 000000000000..344381e44266 --- /dev/null +++ b/app/models/server/models/InstanceStatus.js @@ -0,0 +1,7 @@ +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/LoginServiceConfiguration.js b/app/models/server/models/LoginServiceConfiguration.js new file mode 100644 index 000000000000..8d3a31d6ee69 --- /dev/null +++ b/app/models/server/models/LoginServiceConfiguration.js @@ -0,0 +1,7 @@ +import { ServiceConfiguration } from 'meteor/service-configuration'; + +import { Base } from './_Base'; + +export class LoginServiceConfiguration extends Base {} + +export default new LoginServiceConfiguration(ServiceConfiguration.configurations, { preventSetUpdatedAt: true }); diff --git a/app/models/server/models/Messages.js b/app/models/server/models/Messages.js index e152fa6aff59..10a8ae34efcb 100644 --- a/app/models/server/models/Messages.js +++ b/app/models/server/models/Messages.js @@ -451,13 +451,11 @@ export class Messages extends Base { return this.find(query, options); } - getLastTimestamp(options) { - if (options == null) { options = {}; } - const query = { ts: { $exists: 1 } }; + getLastTimestamp(options = { fields: { _id: 0, ts: 1 } }) { options.sort = { ts: -1 }; options.limit = 1; - const [message] = this.find(query, options).fetch(); - return message && message.ts; + const [message] = this.find({}, options).fetch(); + return message?.ts; } findByRoomIdAndMessageIds(rid, messageIds, options) { @@ -935,6 +933,29 @@ export class Messages extends Base { return this.remove({ rid: { $in: rids } }); } + findThreadsByRoomIdPinnedTimestampAndUsers({ rid, pinned, ignoreDiscussion = true, ts, users = [] }, options) { + const query = { + rid, + ts, + tlm: { $exists: 1 }, + tcount: { $exists: 1 }, + }; + + if (pinned) { + query.pinned = { $ne: true }; + } + + if (ignoreDiscussion) { + query.drid = { $exists: 0 }; + } + + if (users.length > 0) { + query['u.username'] = { $in: users }; + } + + return this.find(query, options); + } + removeByIdPinnedTimestampLimitAndUsers(rid, pinned, ignoreDiscussion = true, ts, limit, users = [], ignoreThreads = true) { const query = { rid, diff --git a/app/models/server/models/Sessions.js b/app/models/server/models/Sessions.js index d53fa57ba666..8fd299c080a6 100644 --- a/app/models/server/models/Sessions.js +++ b/app/models/server/models/Sessions.js @@ -1,4 +1,5 @@ import { Base } from './_Base'; +import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred'; export const aggregates = { dailySessionsOfYesterday(collection, { year, month, day }) { @@ -381,6 +382,9 @@ export class Sessions extends Base { 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() { @@ -395,7 +399,7 @@ export class Sessions extends Base { year, month, day, - data: Promise.await(aggregates.getUniqueUsersOfYesterday(this.model.rawCollection(), { year, month, day })), + data: Promise.await(aggregates.getUniqueUsersOfYesterday(this.secondaryCollection, { year, month, day })), }; } @@ -411,7 +415,7 @@ export class Sessions extends Base { year, month, day, - data: Promise.await(aggregates.getUniqueUsersOfLastMonth(this.model.rawCollection(), { year, month, day })), + data: Promise.await(aggregates.getUniqueUsersOfLastMonth(this.secondaryCollection, { year, month, day })), }; } @@ -427,7 +431,7 @@ export class Sessions extends Base { year, month, day, - data: Promise.await(aggregates.getUniqueDevicesOfYesterday(this.model.rawCollection(), { year, month, day })), + data: Promise.await(aggregates.getUniqueDevicesOfYesterday(this.secondaryCollection, { year, month, day })), }; } @@ -443,7 +447,7 @@ export class Sessions extends Base { year, month, day, - data: Promise.await(aggregates.getUniqueDevicesOfLastMonth(this.model.rawCollection(), { year, month, day })), + data: Promise.await(aggregates.getUniqueDevicesOfLastMonth(this.secondaryCollection, { year, month, day })), }; } @@ -459,7 +463,7 @@ export class Sessions extends Base { year, month, day, - data: Promise.await(aggregates.getUniqueOSOfYesterday(this.model.rawCollection(), { year, month, day })), + data: Promise.await(aggregates.getUniqueOSOfYesterday(this.secondaryCollection, { year, month, day })), }; } @@ -475,7 +479,7 @@ export class Sessions extends Base { year, month, day, - data: Promise.await(aggregates.getUniqueOSOfLastMonth(this.model.rawCollection(), { year, month, day })), + data: Promise.await(aggregates.getUniqueOSOfLastMonth(this.secondaryCollection, { year, month, day })), }; } diff --git a/app/models/server/models/Sessions.mocks.js b/app/models/server/models/Sessions.mocks.js index 731c40948b78..ac4b22bf7780 100644 --- a/app/models/server/models/Sessions.mocks.js +++ b/app/models/server/models/Sessions.mocks.js @@ -2,6 +2,15 @@ import mock from 'mock-require'; mock('./_Base', { Base: class Base { + model = { + rawDatabase() { + return { + collection() {}, + options: {}, + }; + }, + } + tryEnsureIndex() {} }, }); diff --git a/app/models/server/models/Settings.js b/app/models/server/models/Settings.js index 2c766c24ca5b..83169ac126f9 100644 --- a/app/models/server/models/Settings.js +++ b/app/models/server/models/Settings.js @@ -58,7 +58,7 @@ export class Settings extends Base { filter._id = { $in: ids }; } - return this.find(filter, { fields: { _id: 1, value: 1, editor: 1, enterprise: 1, invalidValue: 1, modules: 1 } }); + return this.find(filter, { fields: { _id: 1, value: 1, editor: 1, enterprise: 1, invalidValue: 1, modules: 1, requiredOnWizard: 1 } }); } findNotHiddenPublicUpdatedAfter(updatedAt) { @@ -70,7 +70,7 @@ export class Settings extends Base { }, }; - return this.find(filter, { fields: { _id: 1, value: 1, editor: 1, enterprise: 1, invalidValue: 1, modules: 1 } }); + return this.find(filter, { fields: { _id: 1, value: 1, editor: 1, enterprise: 1, invalidValue: 1, modules: 1, requiredOnWizard: 1 } }); } findNotHiddenPrivate() { diff --git a/app/models/server/models/Subscriptions.js b/app/models/server/models/Subscriptions.js index f0e8e4514e2d..3615dbf2ae20 100644 --- a/app/models/server/models/Subscriptions.js +++ b/app/models/server/models/Subscriptions.js @@ -567,15 +567,11 @@ export class Subscriptions extends Base { return this.find(query, options); } - getLastSeen(options) { - if (options == null) { - options = {}; - } - const query = { ls: { $exists: 1 } }; + getLastSeen(options = { fields: { _id: 0, ls: 1 } }) { options.sort = { ls: -1 }; options.limit = 1; - const [subscription] = this.find(query, options).fetch(); - return subscription && subscription.ls; + const [subscription] = this.find({}, options).fetch(); + return subscription?.ls; } findByRoomIdAndUserIds(roomId, userIds, options) { @@ -1424,11 +1420,30 @@ export class Subscriptions extends Base { const update = { $unset: { tunread: 1, + tunreadUser: 1, + tunreadGroup: 1, }, }; return this.update(query, update); } + + removeUnreadThreadsByRoomId(rid, tunread) { + const query = { + rid, + tunread, + }; + + const update = { + $pullAll: { + tunread, + tunreadUser: tunread, + tunreadGroup: tunread, + }, + }; + + return this.update(query, update, { multi: true }); + } } export default new Subscriptions('subscription', true); diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index 07bf09b38dcb..ffafb6317b0e 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -853,13 +853,11 @@ export class Users extends Base { return this.find(query, options); } - getLastLogin(options) { - if (options == null) { options = {}; } - const query = { lastLogin: { $exists: 1 } }; + getLastLogin(options = { fields: { _id: 0, lastLogin: 1 } }) { options.sort = { lastLogin: -1 }; options.limit = 1; - const [user] = this.find(query, options).fetch(); - return user && user.lastLogin; + const [user] = this.find({}, options).fetch(); + return user?.lastLogin; } findUsersByUsernames(usernames, options) { diff --git a/app/models/server/models/UsersSessions.js b/app/models/server/models/UsersSessions.js new file mode 100644 index 000000000000..43aec902d343 --- /dev/null +++ b/app/models/server/models/UsersSessions.js @@ -0,0 +1,7 @@ +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/_Base.js b/app/models/server/models/_Base.js index d5ba676bd7e2..c668d3761ecf 100644 --- a/app/models/server/models/_Base.js +++ b/app/models/server/models/_Base.js @@ -4,7 +4,6 @@ import objectPath from 'object-path'; import _ from 'underscore'; import { BaseDb } from './_BaseDb'; -import { oplogEvents } from '../oplogEvents'; export class Base { constructor(nameOrModel, options) { @@ -13,20 +12,11 @@ export class Base { this.collectionName = this._db.collectionName; this.name = this._db.name; + this.removeListener = this._db.removeListener.bind(this._db); this.on = this._db.on.bind(this._db); this.emit = this._db.emit.bind(this._db); this.db = this; - - this._db.on('change', ({ action, oplog }) => { - if (!oplog) { - return; - } - oplogEvents.emit('record', { - collection: this.collectionName, - op: action, - }); - }); } get origin() { diff --git a/app/models/server/models/_BaseDb.js b/app/models/server/models/_BaseDb.js index 87785f6a5ec2..7201335c92e1 100644 --- a/app/models/server/models/_BaseDb.js +++ b/app/models/server/models/_BaseDb.js @@ -4,7 +4,8 @@ import { Match } from 'meteor/check'; import { Mongo } from 'meteor/mongo'; import _ from 'underscore'; -import { getMongoInfo } from '../../../utils/server/functions/getMongoInfo'; +import { metrics } from '../../../metrics/server/lib/metrics'; +import { getOplogHandle } from './_oplogHandle'; const baseName = 'rocketchat_'; @@ -21,6 +22,12 @@ try { console.log(e); } +const actions = { + i: 'insert', + u: 'update', + d: 'remove', +}; + export class BaseDb extends EventEmitter { constructor(model, baseModel, options = {}) { super(); @@ -37,12 +44,14 @@ export class BaseDb extends EventEmitter { this.baseModel = baseModel; + this.preventSetUpdatedAt = !!options.preventSetUpdatedAt; + this.wrapModel(); - const { oplogEnabled, mongo } = getMongoInfo(); + const _oplogHandle = Promise.await(getOplogHandle()); // When someone start listening for changes we start oplog if available - const handleListener = (event /* , listener*/) => { + const handleListener = async (event /* , listener*/) => { if (event !== 'change') { return; } @@ -53,7 +62,7 @@ export class BaseDb extends EventEmitter { collection: this.collectionName, }; - if (!mongo._oplogHandle) { + if (!_oplogHandle) { throw new Error(`Error: Unable to find Mongodb Oplog. You must run the server with oplog enabled. Try the following:\n 1. Start your mongodb in a replicaset mode: mongod --smallfiles --oplogSize 128 --replSet rs0\n 2. Start the replicaset via mongodb shell: mongo mongo/meteor --eval "rs.initiate({ _id: ''rs0'', members: [ { _id: 0, host: ''localhost:27017'' } ]})"\n @@ -61,19 +70,19 @@ export class BaseDb extends EventEmitter { `); } - mongo._oplogHandle.onOplogEntry( + _oplogHandle.onOplogEntry( query, this.processOplogRecord.bind(this), ); // Meteor will handle if we have a value https://github.com/meteor/meteor/blob/5dcd0b2eb9c8bf881ffbee98bc4cb7631772c4da/packages/mongo/oplog_tailing.js#L5 if (process.env.METEOR_OPLOG_TOO_FAR_BEHIND == null) { - mongo._oplogHandle._defineTooFarBehind( + _oplogHandle._defineTooFarBehind( Number.MAX_SAFE_INTEGER, ); } }; - if (oplogEnabled) { + if (_oplogHandle) { this.on('newListener', handleListener); } @@ -85,6 +94,9 @@ export class BaseDb extends EventEmitter { } setUpdatedAt(record = {}) { + if (this.preventSetUpdatedAt) { + return record; + } // TODO: Check if this can be deleted, Rodrigo does not rememebr WHY he added it. So he removed it to fix issue #5541 // setUpdatedAt(record = {}, checkQuery = false, query) { // if (checkQuery === true) { @@ -189,62 +201,68 @@ export class BaseDb extends EventEmitter { ); } - processOplogRecord(action) { - if (action.op.op === 'i') { + processOplogRecord({ id, op }) { + const action = actions[op.op]; + metrics.oplog.inc({ + collection: this.collectionName, + op: action, + }); + + if (action === 'insert') { this.emit('change', { - action: 'insert', + action, clientAction: 'inserted', - id: action.op.o._id, - data: action.op.o, + id: op.o._id, + data: op.o, oplog: true, }); return; } - if (action.op.op === 'u') { - if (!action.op.o.$set && !action.op.o.$unset) { + if (action === 'update') { + if (!op.o.$set && !op.o.$unset) { this.emit('change', { - action: 'update', + action, clientAction: 'updated', - id: action.id, - data: action.op.o, + id, + data: op.o, oplog: true, }); return; } const diff = {}; - if (action.op.o.$set) { - for (const key in action.op.o.$set) { - if (action.op.o.$set.hasOwnProperty(key)) { - diff[key] = action.op.o.$set[key]; + if (op.o.$set) { + for (const key in op.o.$set) { + if (op.o.$set.hasOwnProperty(key)) { + diff[key] = op.o.$set[key]; } } } - if (action.op.o.$unset) { - for (const key in action.op.o.$unset) { - if (action.op.o.$unset.hasOwnProperty(key)) { + if (op.o.$unset) { + for (const key in op.o.$unset) { + if (op.o.$unset.hasOwnProperty(key)) { diff[key] = undefined; } } } this.emit('change', { - action: 'update', + action, clientAction: 'updated', - id: action.id, + id, diff, oplog: true, }); return; } - if (action.op.op === 'd') { + if (action === 'remove') { this.emit('change', { - action: 'remove', + action, clientAction: 'removed', - id: action.id, + id, oplog: true, }); } diff --git a/app/models/server/models/_oplogHandle.ts b/app/models/server/models/_oplogHandle.ts new file mode 100644 index 000000000000..eb0e7eba779a --- /dev/null +++ b/app/models/server/models/_oplogHandle.ts @@ -0,0 +1,198 @@ +import { Meteor } from 'meteor/meteor'; +import { Promise } from 'meteor/promise'; +import { MongoInternals } from 'meteor/mongo'; +import semver from 'semver'; +import s from 'underscore.string'; +import { MongoClient, Cursor, Timestamp, Db } from 'mongodb'; + +import { urlParser } from './_oplogUrlParser'; + +class OplogHandle { + dbName: string; + + client: MongoClient; + + stream: Cursor; + + db: Db; + + usingChangeStream: boolean; + + async isChangeStreamAvailable(): Promise { + if (process.env.IGNORE_CHANGE_STREAM) { + return false; + } + + const { mongo } = MongoInternals.defaultRemoteCollectionDriver(); + + try { + const { version, storageEngine } = await mongo.db.command({ serverStatus: 1 }); + + if (!storageEngine || storageEngine.name !== 'wiredTiger' || !semver.satisfies(semver.coerce(version) || '', '>=3.6.0')) { + return false; + } + + await mongo.db.admin().command({ replSetGetStatus: 1 }); + } catch (e) { + if (e.message.startsWith('not authorized')) { + console.info('Change Stream is available for your installation, give admin permissions to your database user to use this improved version.'); + } + return false; + } + + return true; + } + + async start(): Promise { + this.usingChangeStream = await this.isChangeStreamAvailable(); + const oplogUrl = this.usingChangeStream ? process.env.MONGO_URL : process.env.MONGO_OPLOG_URL; + + let urlParsed; + try { + urlParsed = await urlParser(oplogUrl); + } catch (e) { + throw Error(`Error parsing database URL (${ oplogUrl })`); + } + + if (!this.usingChangeStream && (!oplogUrl || urlParsed.dbName !== 'local')) { + throw Error("$MONGO_OPLOG_URL must be set to the 'local' database of a Mongo replica set"); + } + + if (!oplogUrl) { + throw Error('$MONGO_URL must be set'); + } + + if (process.env.MONGO_OPLOG_URL) { + const urlParsed = await urlParser(process.env.MONGO_URL); + this.dbName = urlParsed.dbName; + } + + this.client = new MongoClient(oplogUrl, { + useUnifiedTopology: true, + useNewUrlParser: true, + ...!this.usingChangeStream && { poolSize: 1 }, + }); + + await this.client.connect(); + this.db = this.client.db(); + + if (!this.usingChangeStream) { + await this.startOplog(); + } + + return this; + } + + async startOplog(): Promise { + const isMasterDoc = await this.db.admin().command({ ismaster: 1 }); + if (!isMasterDoc || !isMasterDoc.setName) { + throw Error("$MONGO_OPLOG_URL must be set to the 'local' database of a Mongo replica set"); + } + + const oplogCollection = this.db.collection('oplog.rs'); + + const lastOplogEntry = await oplogCollection.findOne<{ts: Timestamp}>({}, { sort: { $natural: -1 }, projection: { _id: 0, ts: 1 } }); + + const oplogSelector = { + ns: new RegExp(`^(?:${ [ + s.escapeRegExp(`${ this.dbName }.`), + s.escapeRegExp('admin.$cmd'), + ].join('|') })`), + + op: { $in: ['i', 'u', 'd'] }, + ...lastOplogEntry && { ts: { $gt: lastOplogEntry.ts } }, + }; + + this.stream = oplogCollection.find(oplogSelector, { + tailable: true, + // awaitData: true, + }).stream(); + + // Prevent warning about many listeners, we add 11 + this.stream.setMaxListeners(20); + } + + onOplogEntry(query: {collection: string}, callback: Function): void { + if (this.usingChangeStream) { + return this._onOplogEntryChangeStream(query, callback); + } + + return this._onOplogEntryOplog(query, callback); + } + + _onOplogEntryOplog(query: {collection: string}, callback: Function): void { + this.stream.on('data', Meteor.bindEnvironment((buffer) => { + const doc = buffer as any; + if (doc.ns === `${ this.dbName }.${ query.collection }`) { + callback({ + id: doc.op === 'u' ? doc.o2._id : doc.o._id, + op: doc, + }); + } + })); + } + + _onOplogEntryChangeStream(query: {collection: string}, callback: Function): void { + this.db.collection(query.collection).watch([], { /* fullDocument: 'updateLookup' */ }).on('change', Meteor.bindEnvironment((event) => { + switch (event.operationType) { + case 'insert': + callback({ + id: event.documentKey._id, + op: { + op: 'i', + o: event.fullDocument, + }, + }); + break; + case 'update': + callback({ + id: event.documentKey._id, + op: { + op: 'u', + // o: event.fullDocument, + o: { + $set: event.updateDescription.updatedFields, + $unset: event.updateDescription.removedFields, + }, + }, + }); + break; + case 'delete': + callback({ + id: event.documentKey._id, + op: { + op: 'd', + }, + }); + break; + } + })); + } + + _defineTooFarBehind(): void { + // + } +} + +let oplogHandle: Promise; + +// @ts-ignore +// eslint-disable-next-line no-undef +if (Package['disable-oplog']) { + try { + oplogHandle = Promise.await(new OplogHandle().start()); + } catch (e) { + console.error(e.message); + } +} + +export const getOplogHandle = async (): Promise => { + if (oplogHandle) { + return oplogHandle; + } + + const { mongo } = MongoInternals.defaultRemoteCollectionDriver(); + if (mongo._oplogHandle?.onOplogEntry) { + return mongo._oplogHandle; + } +}; diff --git a/app/models/server/models/_oplogUrlParser.js b/app/models/server/models/_oplogUrlParser.js new file mode 100644 index 000000000000..8bf3fdd2e836 --- /dev/null +++ b/app/models/server/models/_oplogUrlParser.js @@ -0,0 +1,5 @@ +import { promisify } from 'util'; + +import _urlParser from 'mongodb/lib/url_parser'; + +export const urlParser = promisify(_urlParser); diff --git a/app/models/server/oplogEvents.js b/app/models/server/oplogEvents.js deleted file mode 100644 index 1508641e9d9a..000000000000 --- a/app/models/server/oplogEvents.js +++ /dev/null @@ -1,3 +0,0 @@ -import { EventEmitter } from 'events'; - -export const oplogEvents = new EventEmitter(); diff --git a/app/models/server/raw/Analytics.js b/app/models/server/raw/Analytics.js index 9fc929795f2b..81e0ad335c26 100644 --- a/app/models/server/raw/Analytics.js +++ b/app/models/server/raw/Analytics.js @@ -2,6 +2,7 @@ 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 }) { @@ -146,4 +147,5 @@ export class AnalyticsRaw extends BaseRaw { } } -export default new AnalyticsRaw(Analytics.model.rawCollection()); +const db = Analytics.model.rawDatabase(); +export default new AnalyticsRaw(db.collection(Analytics.model._name, { readPreference: readSecondaryPreferred(db) })); diff --git a/app/models/server/raw/Users.js b/app/models/server/raw/Users.js index 5edbe960a35d..f3b02ad63c84 100644 --- a/app/models/server/raw/Users.js +++ b/app/models/server/raw/Users.js @@ -1,3 +1,5 @@ +import s from 'underscore.string'; + import { BaseRaw } from './BaseRaw'; export class UsersRaw extends BaseRaw { @@ -33,6 +35,74 @@ export class UsersRaw extends BaseRaw { return this.find(query, options); } + findOneByUsernameAndRoomIgnoringCase(username, rid, options) { + if (typeof username === 'string') { + username = new RegExp(`^${ s.escapeRegExp(username) }$`, 'i'); + } + + const query = { + __rooms: rid, + username, + }; + + return this.findOne(query, options); + } + + findByActiveUsersExcept(searchTerm, exceptions, options, searchFields, extraQuery = [], { startsWith = false, endsWith = false } = {}) { + if (exceptions == null) { exceptions = []; } + if (options == null) { options = {}; } + if (Array.isArray(exceptions)) { + exceptions = [exceptions]; + } + + // if the search term is empty, don't need to have the $or statement (because it would be an empty regex) + if (searchTerm === '') { + const query = { + $and: [ + { + active: true, + username: { $exists: true, $nin: exceptions }, + }, + ...extraQuery, + ], + }; + + return this.find(query, options); + } + + const termRegex = new RegExp((startsWith ? '^' : '') + s.escapeRegExp(searchTerm) + (endsWith ? '$' : ''), 'i'); + + // const searchFields = forcedSearchFields || settings.get('Accounts_SearchFields').trim().split(','); + + const orStmt = (searchFields || []).reduce(function(acc, el) { + acc.push({ [el.trim()]: termRegex }); + return acc; + }, []); + + const query = { + $and: [ + { + active: true, + username: { $exists: true, $nin: exceptions }, + $or: orStmt, + }, + ...extraQuery, + ], + }; + + return this.find(query, options); + } + + findOneByUsernameIgnoringCase(username, options) { + if (typeof username === 'string') { + username = new RegExp(`^${ s.escapeRegExp(username) }$`, 'i'); + } + + const query = { username }; + + return this.findOne(query, options); + } + isUserInRole(userId, roleName) { const query = { _id: userId, diff --git a/app/push/server/push.js b/app/push/server/push.js index 9750c3ef34ba..e95ca8f88716 100644 --- a/app/push/server/push.js +++ b/app/push/server/push.js @@ -128,6 +128,11 @@ export class PushClass { return; } + if (response?.statusCode === 422) { + logger.info('gateway rejected push notification. not retrying.', response); + return; + } + if (response?.statusCode === 401) { logger.warn('Error sending push to gateway (not authorized)', response); return; diff --git a/app/reactions/client/init.js b/app/reactions/client/init.js index 756557827e6a..36dd81338e8d 100644 --- a/app/reactions/client/init.js +++ b/app/reactions/client/init.js @@ -10,15 +10,27 @@ import { EmojiPicker } from '../../emoji'; import { tooltip } from '../../ui/client/components/tooltip'; Template.room.events({ - 'click .add-reaction, click [data-message-action="reaction-message"]'(event) { + 'click .add-reaction'(event, instance) { event.preventDefault(); event.stopPropagation(); const data = Blaze.getData(event.currentTarget); - const { msg: { rid, _id: mid } } = messageArgs(data); + const { msg: { rid, _id: mid, private: isPrivate } } = messageArgs(data); const user = Meteor.user(); const room = Rooms.findOne({ _id: rid }); - if (roomTypes.readOnly(room._id, user._id)) { + if (!room) { + return false; + } + + if (!instance.subscription.get()) { + return false; + } + + if (isPrivate) { + return false; + } + + if (roomTypes.readOnly(room._id, user._id) && !room.reactWhenReadOnly) { return false; } diff --git a/app/retention-policy/server/cronPruneMessages.js b/app/retention-policy/server/cronPruneMessages.js index cb5b0fab1865..5508747d1f36 100644 --- a/app/retention-policy/server/cronPruneMessages.js +++ b/app/retention-policy/server/cronPruneMessages.js @@ -1,15 +1,15 @@ import { Meteor } from 'meteor/meteor'; import { SyncedCron } from 'meteor/littledata:synced-cron'; +import { debounce } from 'underscore'; -import { settings } from '../../settings'; -import { Rooms, Settings } from '../../models'; +import { settings } from '../../settings/server'; +import { Rooms } from '../../models/server'; import { cleanRoomHistory } from '../../lib'; let types = []; const oldest = new Date('0001-01-01T00:00:00Z'); - const maxTimes = { c: 0, p: 0, @@ -56,20 +56,20 @@ function job() { function getSchedule(precision) { switch (precision) { case '0': - return '0 */30 * * * *'; // 30 minutes + return '*/30 * * * *'; // 30 minutes case '1': - return '0 0 * * * *'; // hour + return '0 * * * *'; // hour case '2': - return '0 0 */6 * * *'; // 6 hours + return '0 */6 * * *'; // 6 hours case '3': - return '0 0 0 * * *'; // day + return '0 0 * * *'; // day } } const pruneCronName = 'Prune old messages by retention policy'; function deployCron(precision) { - const schedule = (parser) => parser.cron(getSchedule(precision), true); + const schedule = (parser) => parser.cron(precision); SyncedCron.remove(pruneCronName); SyncedCron.add({ @@ -79,7 +79,7 @@ function deployCron(precision) { }); } -function reloadPolicy() { +const reloadPolicy = debounce(Meteor.bindEnvironment(function reloadPolicy() { types = []; if (!settings.get('RetentionPolicy_Enabled')) { @@ -101,30 +101,15 @@ function reloadPolicy() { maxTimes.p = settings.get('RetentionPolicy_MaxAge_Groups'); maxTimes.d = settings.get('RetentionPolicy_MaxAge_DMs'); - return deployCron(settings.get('RetentionPolicy_Precision')); -} + + const precision = (settings.get('RetentionPolicy_Advanced_Precision') && settings.get('RetentionPolicy_Advanced_Precision_Cron')) || getSchedule(settings.get('RetentionPolicy_Precision')); + + return deployCron(precision); +}), 500); Meteor.startup(function() { Meteor.defer(function() { - Settings.find({ - _id: { - $in: [ - 'RetentionPolicy_Enabled', - 'RetentionPolicy_Precision', - 'RetentionPolicy_AppliesToChannels', - 'RetentionPolicy_AppliesToGroups', - 'RetentionPolicy_AppliesToDMs', - 'RetentionPolicy_MaxAge_Channels', - 'RetentionPolicy_MaxAge_Groups', - 'RetentionPolicy_MaxAge_DMs', - ], - }, - }).observe({ - changed() { - reloadPolicy(); - }, - }); - + settings.get(/^RetentionPolicy_/, reloadPolicy); reloadPolicy(); }); }); diff --git a/app/retention-policy/server/startup/settings.js b/app/retention-policy/server/startup/settings.js index 6389cbe6b551..94696f7651f4 100644 --- a/app/retention-policy/server/startup/settings.js +++ b/app/retention-policy/server/startup/settings.js @@ -1,6 +1,11 @@ import { settings } from '../../../settings'; settings.addGroup('RetentionPolicy', function() { + const globalQuery = { + _id: 'RetentionPolicy_Enabled', + value: true, + }; + this.add('RetentionPolicy_Enabled', false, { type: 'boolean', public: true, @@ -28,18 +33,30 @@ settings.addGroup('RetentionPolicy', function() { public: true, i18nLabel: 'RetentionPolicy_Precision', i18nDescription: 'RetentionPolicy_Precision_Description', - enableQuery: { - _id: 'RetentionPolicy_Enabled', - value: true, - }, + enableQuery: [globalQuery, { + _id: 'RetentionPolicy_Advanced_Precision', + value: false, + }], + }); + + this.add('RetentionPolicy_Advanced_Precision', false, { + type: 'boolean', + public: true, + i18nLabel: 'RetentionPolicy_Advanced_Precision', + i18nDescription: 'RetentionPolicy_Advanced_Precision_Description', + enableQuery: globalQuery, + }); + + this.add('RetentionPolicy_Advanced_Precision_Cron', '*/30 * * * *', { + type: 'string', + public: true, + i18nLabel: 'RetentionPolicy_Advanced_Precision_Cron', + i18nDescription: 'RetentionPolicy_Advanced_Precision_Cron_Description', + enableQuery: [globalQuery, { _id: 'RetentionPolicy_Advanced_Precision', value: true }], }); - this.section('Global Policy', function() { - const globalQuery = { - _id: 'RetentionPolicy_Enabled', - value: true, - }; + this.section('Global Policy', function() { this.add('RetentionPolicy_AppliesToChannels', false, { type: 'boolean', public: true, @@ -80,6 +97,7 @@ settings.addGroup('RetentionPolicy', function() { i18nLabel: 'RetentionPolicy_AppliesToDMs', enableQuery: globalQuery, }); + this.add('RetentionPolicy_MaxAge_DMs', 30, { type: 'int', public: true, @@ -97,6 +115,7 @@ settings.addGroup('RetentionPolicy', function() { i18nLabel: 'RetentionPolicy_DoNotPrunePinned', enableQuery: globalQuery, }); + this.add('RetentionPolicy_FilesOnly', false, { type: 'boolean', public: true, diff --git a/app/search/server/events/events.js b/app/search/server/events/events.js index 7f0f5032a1d7..41a1e217b45c 100644 --- a/app/search/server/events/events.js +++ b/app/search/server/events/events.js @@ -1,11 +1,13 @@ -import { callbacks } from '../../../callbacks'; -import { Users, Rooms } from '../../../models'; +import _ from 'underscore'; + +import { settings } from '../../../settings/server'; +import { callbacks } from '../../../callbacks/server'; +import { Users, Rooms } from '../../../models/server'; import { searchProviderService } from '../service/providerService'; import SearchLogger from '../logger/logger'; class EventService { - /* eslint no-unused-vars: [2, { "args": "none" }]*/ - _pushError(name, value, payload) { + _pushError(name, value/* , payload */) { // TODO implement a (performant) cache SearchLogger.debug(`Error on event '${ name }' with id '${ value }'`); } @@ -22,26 +24,24 @@ const eventService = new EventService(); /** * Listen to message changes via Hooks */ -callbacks.add('afterSaveMessage', function(m) { +function afterSaveMessage(m) { eventService.promoteEvent('message.save', m._id, m); return m; -}, callbacks.priority.MEDIUM, 'search-events'); +} -callbacks.add('afterDeleteMessage', function(m) { +function afterDeleteMessage(m) { eventService.promoteEvent('message.delete', m._id); return m; -}, callbacks.priority.MEDIUM, 'search-events-delete'); +} /** * Listen to user and room changes via cursor */ - - -Users.on('change', ({ clientAction, id, data }) => { +function onUsersChange({ clientAction, id, data }) { switch (clientAction) { case 'updated': case 'inserted': - const user = data || Users.findOneById(id); + const user = data ?? Users.findOneById(id); eventService.promoteEvent('user.save', id, user); break; @@ -49,13 +49,13 @@ Users.on('change', ({ clientAction, id, data }) => { eventService.promoteEvent('user.delete', id); break; } -}); +} -Rooms.on('change', ({ clientAction, id, data }) => { +function onRoomsChange({ clientAction, id, data }) { switch (clientAction) { case 'updated': case 'inserted': - const room = data || Rooms.findOneById(id); + const room = data ?? Rooms.findOneById(id); eventService.promoteEvent('room.save', id, room); break; @@ -63,4 +63,18 @@ Rooms.on('change', ({ clientAction, id, data }) => { eventService.promoteEvent('room.delete', id); break; } -}); +} + +settings.get('Search.Provider', _.debounce(() => { + if (searchProviderService.activeProvider?.on) { + Users.on('change', onUsersChange); + Rooms.on('change', onRoomsChange); + callbacks.add('afterSaveMessage', afterSaveMessage, callbacks.priority.MEDIUM, 'search-events'); + callbacks.add('afterDeleteMessage', afterDeleteMessage, callbacks.priority.MEDIUM, 'search-events-delete'); + } else { + Users.removeListener('change', onUsersChange); + Rooms.removeListener('change', onRoomsChange); + callbacks.remove('afterSaveMessage', 'search-events'); + callbacks.remove('afterDeleteMessage', 'search-events-delete'); + } +}, 1000)); diff --git a/app/settings/server/functions/settings.ts b/app/settings/server/functions/settings.ts index 480c2f936832..3bff85f1f1b8 100644 --- a/app/settings/server/functions/settings.ts +++ b/app/settings/server/functions/settings.ts @@ -5,9 +5,11 @@ import _ from 'underscore'; import { SettingsBase, SettingValue } from '../../lib/settings'; import SettingsModel from '../../../models/server/models/Settings'; +import { setValue, updateValue } from '../raw'; const blockedSettings = new Set(); const hiddenSettings = new Set(); +const wizardRequiredSettings = new Set(); if (process.env.SETTINGS_BLOCKED) { process.env.SETTINGS_BLOCKED.split(',').forEach((settingId) => blockedSettings.add(settingId.trim())); @@ -17,6 +19,10 @@ if (process.env.SETTINGS_HIDDEN) { process.env.SETTINGS_HIDDEN.split(',').forEach((settingId) => hiddenSettings.add(settingId.trim())); } +if (process.env.SETTINGS_REQUIRED_ON_WIZARD) { + process.env.SETTINGS_REQUIRED_ON_WIZARD.split(',').forEach((settingId) => wizardRequiredSettings.add(settingId.trim())); +} + export const SettingsEvents = new EventEmitter(); const overrideSetting = (_id: string, value: SettingValue, options: ISettingAddOptions): SettingValue => { @@ -67,6 +73,7 @@ export interface ISettingAddOptions { valueSource?: string; hidden?: boolean; blocked?: boolean; + requiredOnWizard?: boolean; secret?: boolean; sorter?: number; i18nLabel?: string; @@ -154,6 +161,7 @@ class Settings extends SettingsBase { options.valueSource = 'packageValue'; options.hidden = options.hidden || false; options.blocked = options.blocked || false; + options.requiredOnWizard = options.requiredOnWizard || false; options.secret = options.secret || false; options.enterprise = options.enterprise || false; @@ -180,6 +188,9 @@ class Settings extends SettingsBase { if (hiddenSettings.has(_id)) { options.hidden = true; } + if (wizardRequiredSettings.has(_id)) { + options.requiredOnWizard = true; + } if (options.autocomplete == null) { options.autocomplete = true; } @@ -385,13 +396,28 @@ class Settings extends SettingsBase { */ init(): void { this.initialLoad = true; - SettingsModel.find().observe({ - added: (record: ISettingRecord) => this.storeSettingValue(record, this.initialLoad), - changed: (record: ISettingRecord) => this.storeSettingValue(record, this.initialLoad), - removed: (record: ISettingRecord) => this.removeSettingValue(record, this.initialLoad), + SettingsModel.find().fetch().forEach((record: ISettingRecord) => { + this.storeSettingValue(record, this.initialLoad); + updateValue(record._id, { value: record.value }); }); this.initialLoad = false; this.afterInitialLoad.forEach((fn) => fn(Meteor.settings)); + + SettingsModel.on('change', ({ clientAction, id, data }) => { + switch (clientAction) { + case 'inserted': + case 'updated': + data = data ?? SettingsModel.findOneById(id); + this.storeSettingValue(data, this.initialLoad); + updateValue(id, { value: data.value }); + break; + case 'removed': + data = SettingsModel.trashFindOneById(id); + this.removeSettingValue(data, this.initialLoad); + setValue(id, undefined); + break; + } + }); } onAfterInitialLoad(fn: (settings: Meteor.Settings) => void): void { diff --git a/app/settings/server/index.ts b/app/settings/server/index.ts index 7a4f6ebf00b4..3adfad5409b3 100644 --- a/app/settings/server/index.ts +++ b/app/settings/server/index.ts @@ -1,5 +1,4 @@ import { settings, SettingsEvents } from './functions/settings'; -import './observer'; export { settings, diff --git a/app/settings/server/observer.js b/app/settings/server/observer.js deleted file mode 100644 index 7c376078aaa4..000000000000 --- a/app/settings/server/observer.js +++ /dev/null @@ -1,19 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { Settings } from '../../models/server'; -import { setValue } from './raw'; - -const updateValue = (id, fields) => { - if (typeof fields.value === 'undefined') { - return; - } - setValue(id, fields.value); -}; - -Meteor.startup(() => Settings.find({}, { fields: { value: 1 } }).observeChanges({ - added: updateValue, - changed: updateValue, - removed(id) { - setValue(id, undefined); - }, -})); diff --git a/app/settings/server/raw.js b/app/settings/server/raw.js index 9bfd51fbc029..436643cbae44 100644 --- a/app/settings/server/raw.js +++ b/app/settings/server/raw.js @@ -1,15 +1,18 @@ -import { Settings } from '../../models/server/raw'; +import { Settings } from '../../models/server/models/Settings'; const cache = new Map(); export const setValue = (_id, value) => cache.set(_id, value); const setFromDB = async (_id) => { - const value = await Settings.getValueById(_id); + const setting = Settings.findOneById(_id, { fields: { value: 1 } }); + if (!setting) { + return; + } - setValue(_id, value); + setValue(_id, setting.value); - return value; + return setting.value; }; export const getValue = async (_id) => { @@ -19,3 +22,10 @@ export const getValue = async (_id) => { return cache.get(_id); }; + +export const updateValue = (id, fields) => { + if (typeof fields.value === 'undefined') { + return; + } + setValue(id, fields.value); +}; diff --git a/app/statistics/server/lib/statistics.js b/app/statistics/server/lib/statistics.js index a588ccda862d..c946ff9ed671 100644 --- a/app/statistics/server/lib/statistics.js +++ b/app/statistics/server/lib/statistics.js @@ -22,6 +22,7 @@ import { Migrations } from '../../../migrations/server'; import { Apps } from '../../../apps/server'; import { getStatistics as federationGetStatistics } from '../../../federation/server/functions/dashboard'; import { NotificationQueue } from '../../../models/server/raw'; +import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred'; const wizardFields = [ 'Organization_Type', @@ -35,6 +36,8 @@ const wizardFields = [ export const statistics = { get: function _getStatistics() { + const readPreference = readSecondaryPreferred(Uploads.model.rawDatabase()); + const statistics = {}; // Setup Wizard @@ -131,7 +134,9 @@ export const statistics = { statistics.enterpriseReady = true; statistics.uploadsTotal = Uploads.find().count(); - const [result] = Promise.await(Uploads.model.rawCollection().aggregate([{ $group: { _id: 'total', total: { $sum: '$size' } } }]).toArray()); + const [result] = Promise.await(Uploads.model.rawCollection().aggregate([{ + $group: { _id: 'total', total: { $sum: '$size' } }, + }], { readPreference }).toArray()); statistics.uploadsTotalSize = result ? result.total : 0; statistics.migration = Migrations._getControl(); @@ -156,7 +161,15 @@ export const statistics = { totalActive: Apps.isInitialized() && Apps.getManager().get({ enabled: true }).length, }; - const integrations = Integrations.find().fetch(); + const integrations = Promise.await(Integrations.model.rawCollection().find({}, { + projection: { + _id: 0, + type: 1, + enabled: 1, + scriptEnabled: 1, + }, + readPreference, + }).toArray()); statistics.integrations = { totalIntegrations: integrations.length, diff --git a/app/theme/client/imports/general/base_old.css b/app/theme/client/imports/general/base_old.css index 2ec99c148f7f..0bc4430e251d 100644 --- a/app/theme/client/imports/general/base_old.css +++ b/app/theme/client/imports/general/base_old.css @@ -2113,11 +2113,11 @@ border-radius: 2px; &--me { - background-color: var(--mention-link-me-background); + background-color: var(--mention-link-me-text-color); } &--group { - background-color: var(--mention-link-group-background); + background-color: var(--mention-link-group-text-color); } } } diff --git a/app/ui-master/server/inject.js b/app/ui-master/server/inject.js index 6c5f1d1a1cc8..8957ded92da0 100644 --- a/app/ui-master/server/inject.js +++ b/app/ui-master/server/inject.js @@ -6,7 +6,7 @@ import _ from 'underscore'; import s from 'underscore.string'; import { Settings } from '../../models'; -import { settings } from '../../settings'; +import { settings } from '../../settings/server'; const headInjections = new ReactiveDict(); @@ -157,9 +157,7 @@ renderDynamicCssList(); // changed: renderDynamicCssList // }); -Settings.find({ _id: /theme-color-rc/i }, { fields: { value: 1 } }).observe({ - changed: renderDynamicCssList, -}); +settings.get(/theme-color-rc/i, () => renderDynamicCssList()); injectIntoBody('icons', Assets.getText('public/icons.svg')); diff --git a/app/ui-message/client/message.html b/app/ui-message/client/message.html index bad255ec50d7..e2fd19466c89 100644 --- a/app/ui-message/client/message.html +++ b/app/ui-message/client/message.html @@ -222,9 +222,11 @@ {{/each}} -
  • - -
  • + {{#unless hideAddReaction}} +
  • + +
  • + {{/unless}} {{/unless}} {{/unless}} diff --git a/app/ui-message/client/message.js b/app/ui-message/client/message.js index 345162645f18..049d6e05dca9 100644 --- a/app/ui-message/client/message.js +++ b/app/ui-message/client/message.js @@ -156,7 +156,7 @@ Template.message.helpers({ return msg.alias || (settings.UI_Use_Real_Name && msg.u && msg.u.name); }, own() { - const { msg, u } = this; + const { msg, u = {} } = this; if (msg.u && msg.u._id === u._id) { return 'own'; } @@ -323,6 +323,25 @@ Template.message.helpers({ return 'hidden'; } }, + hideAddReaction() { + const { room, u, msg, subscription } = this; + + if (!room) { + return true; + } + + if (!subscription) { + return true; + } + + if (msg.private) { + return true; + } + + if (roomTypes.readOnly(room._id, u._id) && !room.reactWhenReadOnly) { + return true; + } + }, hideMessageActions() { const { msg } = this; diff --git a/app/ui-utils/client/lib/RoomManager.js b/app/ui-utils/client/lib/RoomManager.js index e94b50e1eacf..6cb2c0a4c40e 100644 --- a/app/ui-utils/client/lib/RoomManager.js +++ b/app/ui-utils/client/lib/RoomManager.js @@ -1,5 +1,6 @@ import { Meteor } from 'meteor/meteor'; import { ReactiveVar } from 'meteor/reactive-var'; +import { Session } from 'meteor/session'; import { Tracker } from 'meteor/tracker'; import { Blaze } from 'meteor/blaze'; import { FlowRouter } from 'meteor/kadira:flow-router'; @@ -177,6 +178,7 @@ export const RoomManager = new function() { const openedRoom = openedRooms[key]; this.close(openedRoom.typeName); }); + Session.set('openedRoom'); } diff --git a/app/ui-utils/client/lib/messageContext.js b/app/ui-utils/client/lib/messageContext.js index 52c516c621e5..b243762679f7 100644 --- a/app/ui-utils/client/lib/messageContext.js +++ b/app/ui-utils/client/lib/messageContext.js @@ -11,7 +11,7 @@ const fields = { name: 1, username: 1, 'settings.preferences.showMessageInMainTh export function messageContext({ rid } = Template.instance()) { const uid = Meteor.userId(); - const user = Users.findOne({ _id: uid }, { fields }); + const user = Users.findOne({ _id: uid }, { fields }) || {}; return { u: user, room: Rooms.findOne({ _id: rid }, { diff --git a/app/ui/client/components/header/headerRoom.js b/app/ui/client/components/header/headerRoom.js index bc5b1eeb35ad..c8de54f5738a 100644 --- a/app/ui/client/components/header/headerRoom.js +++ b/app/ui/client/components/header/headerRoom.js @@ -191,7 +191,7 @@ Template.headerRoom.events({ event.stopPropagation(); event.preventDefault(); const room = ChatRoom.findOne(this._id); - if (hasAllPermission('edit-room', this._id)) { + if (hasAllPermission('edit-room', this._id) || (room && room.t)) { call('saveRoomSettings', this._id, 'encrypted', !(room && room.encrypted)).then(() => { toastr.success( t('Encrypted_setting_changed_successfully'), diff --git a/app/ui/client/views/app/room.js b/app/ui/client/views/app/room.js index c3a68e865544..4718a244a0ef 100644 --- a/app/ui/client/views/app/room.js +++ b/app/ui/client/views/app/room.js @@ -1234,6 +1234,8 @@ Template.room.onCreated(function() { this.sendToBottom(); } }; + + this.sendToBottomIfNecessaryDebounced = () => {}; }); // Update message to re-render DOM Template.room.onDestroyed(function() { diff --git a/app/user-data-download/server/cronProcessDownloads.js b/app/user-data-download/server/cronProcessDownloads.js index e7219f1b10f8..d40ecc8b1882 100644 --- a/app/user-data-download/server/cronProcessDownloads.js +++ b/app/user-data-download/server/cronProcessDownloads.js @@ -13,6 +13,7 @@ import { settings } from '../../settings/server'; import { Subscriptions, Rooms, Users, Uploads, Messages, UserDataFiles, ExportOperations, Avatars } from '../../models/server'; import { FileUpload } from '../../file-upload/server'; import * as Mailer from '../../mailer'; +import { readSecondaryPreferred } from '../../../server/database/readSecondaryPreferred'; const fsStat = util.promisify(fs.stat); const fsOpen = util.promisify(fs.open); @@ -223,6 +224,7 @@ export async function exportRoomMessages(rid, exportType, skip, limit, assetsPat sort: { ts: 1 }, skip, limit, + readPreference: readSecondaryPreferred(Messages.model.rawDatabase()), }); const total = await cursor.count(); diff --git a/app/utils/rocketchat.info b/app/utils/rocketchat.info index 3c28f4f904a6..0032e94ee9b3 100644 --- a/app/utils/rocketchat.info +++ b/app/utils/rocketchat.info @@ -1,3 +1,3 @@ { - "version": "3.6.3" + "version": "3.7.0" } diff --git a/app/utils/server/functions/getMongoInfo.js b/app/utils/server/functions/getMongoInfo.js index c0cbd9a17a62..7b34b5a3054b 100644 --- a/app/utils/server/functions/getMongoInfo.js +++ b/app/utils/server/functions/getMongoInfo.js @@ -1,9 +1,11 @@ import { MongoInternals } from 'meteor/mongo'; +import { getOplogHandle } from '../../../models/server/models/_oplogHandle'; + export function getOplogInfo() { const { mongo } = MongoInternals.defaultRemoteCollectionDriver(); - const oplogEnabled = Boolean(mongo._oplogHandle && mongo._oplogHandle.onOplogEntry); + const oplogEnabled = !!Promise.await(getOplogHandle()); return { oplogEnabled, mongo }; } diff --git a/app/videobridge/server/methods/jitsiSetTimeout.js b/app/videobridge/server/methods/jitsiSetTimeout.js index 2a18ee53811f..4d160ba4868f 100644 --- a/app/videobridge/server/methods/jitsiSetTimeout.js +++ b/app/videobridge/server/methods/jitsiSetTimeout.js @@ -45,12 +45,6 @@ Meteor.methods({ ], }); message.msg = TAPi18n.__('Started_a_video_call'); - message.mentions = [ - { - _id: 'here', - username: 'here', - }, - ]; callbacks.run('afterSaveMessage', message, { ...room, jitsiTimeout: currentTime + CONSTANTS.TIMEOUT }); } diff --git a/app/webdav/server/lib/webdavClientAdapter.js b/app/webdav/server/lib/webdavClientAdapter.ts similarity index 59% rename from app/webdav/server/lib/webdavClientAdapter.js rename to app/webdav/server/lib/webdavClientAdapter.ts index 49d01fa04736..038d0907c8fd 100644 --- a/app/webdav/server/lib/webdavClientAdapter.js +++ b/app/webdav/server/lib/webdavClientAdapter.ts @@ -1,7 +1,17 @@ import { createClient } from 'webdav'; +import type { WebDavClient, Stat } from '../../../../definition/webdav'; + +export type ServerCredentials = { + token?: string; + username?: string; + password?: string; +}; + export class WebdavClientAdapter { - constructor(serverConfig, cred) { + _client: WebDavClient; + + constructor(serverConfig: string, cred: ServerCredentials) { if (cred.token) { this._client = createClient( serverConfig, @@ -18,7 +28,7 @@ export class WebdavClientAdapter { } } - async stat(path) { + async stat(path: string): Promise { try { return await this._client.stat(path); } catch (error) { @@ -26,7 +36,7 @@ export class WebdavClientAdapter { } } - async createDirectory(path) { + async createDirectory(path: string): Promise { try { return await this._client.createDirectory(path); } catch (error) { @@ -34,7 +44,7 @@ export class WebdavClientAdapter { } } - async deleteFile(path) { + async deleteFile(path: string): Promise { try { return await this._client.deleteFile(path); } catch (error) { @@ -42,15 +52,15 @@ export class WebdavClientAdapter { } } - async getFileContents(filename) { + async getFileContents(filename: string): Promise { try { - return await this._client.getFileContents(filename); + return await this._client.getFileContents(filename) as Buffer; } catch (error) { throw new Error(error.response && error.response.statusText ? error.response.statusText : 'Error getting file contents webdav'); } } - async getDirectoryContents(path) { + async getDirectoryContents(path: string): Promise> { try { return await this._client.getDirectoryContents(path); } catch (error) { @@ -58,11 +68,19 @@ export class WebdavClientAdapter { } } - createReadStream(path, options) { + async putFileContents(path: string, data: Buffer, options: Record = {}): Promise { + try { + return await this._client.putFileContents(path, data, options); + } catch (error) { + throw new Error(error.response?.statusText ?? 'Error updating file contents.'); + } + } + + createReadStream(path: string, options?: Record): ReadableStream { return this._client.createReadStream(path, options); } - createWriteStream(path) { + createWriteStream(path: string): WritableStream { return this._client.createWriteStream(path); } } diff --git a/app/webdav/server/methods/getWebdavCredentials.js b/app/webdav/server/methods/getWebdavCredentials.js deleted file mode 100644 index 244dbd49a74c..000000000000 --- a/app/webdav/server/methods/getWebdavCredentials.js +++ /dev/null @@ -1,7 +0,0 @@ -export function getWebdavCredentials(account) { - const cred = account.token ? { token: account.token } : { - username: account.username, - password: account.password, - }; - return cred; -} diff --git a/app/webdav/server/methods/getWebdavCredentials.ts b/app/webdav/server/methods/getWebdavCredentials.ts new file mode 100644 index 000000000000..e9bc7ecbd30f --- /dev/null +++ b/app/webdav/server/methods/getWebdavCredentials.ts @@ -0,0 +1,9 @@ +import { ServerCredentials } from '../lib/webdavClientAdapter'; + +export function getWebdavCredentials(account: ServerCredentials): ServerCredentials { + const cred = account.token ? { token: account.token } : { + username: account.username, + password: account.password, + }; + return cred; +} diff --git a/app/webdav/server/methods/uploadFileToWebdav.js b/app/webdav/server/methods/uploadFileToWebdav.ts similarity index 83% rename from app/webdav/server/methods/uploadFileToWebdav.js rename to app/webdav/server/methods/uploadFileToWebdav.ts index aff6f7f7b636..a7f295205e5f 100644 --- a/app/webdav/server/methods/uploadFileToWebdav.js +++ b/app/webdav/server/methods/uploadFileToWebdav.ts @@ -1,10 +1,13 @@ import { Meteor } from 'meteor/meteor'; -import { settings } from '../../../settings'; +import { settings } from '../../../settings/server'; +import { Logger } from '../../../logger/server'; import { getWebdavCredentials } from './getWebdavCredentials'; -import { WebdavAccounts } from '../../../models'; +import { WebdavAccounts } from '../../../models/server'; import { WebdavClientAdapter } from '../lib/webdavClientAdapter'; +const logger = new Logger('WebDAV_Upload', {}); + Meteor.methods({ async uploadFileToWebdav(accountId, fileData, name) { if (!Meteor.userId()) { @@ -26,10 +29,14 @@ Meteor.methods({ try { const cred = getWebdavCredentials(account); const client = new WebdavClientAdapter(account.server_url, cred); + // eslint-disable-next-line @typescript-eslint/no-empty-function await client.createDirectory(uploadFolder).catch(() => {}); await client.putFileContents(`${ uploadFolder }/${ name }`, buffer, { overwrite: false }); return { success: true }; } catch (error) { + // @ts-ignore + logger.error(error); + if (error.response) { const { status } = error.response; if (status === 404) { diff --git a/client/account/AccountProfilePage.js b/client/account/AccountProfilePage.js index d6e897aad76d..86989d48051f 100644 --- a/client/account/AccountProfilePage.js +++ b/client/account/AccountProfilePage.js @@ -128,7 +128,7 @@ const AccountProfilePage = () => { nickname, } = values; - const { handleAvatar } = handlers; + const { handleAvatar, handlePassword, handleConfirmationPassword } = handlers; const updateAvatar = useUpdateAvatar(avatar, user._id); @@ -140,7 +140,7 @@ const AccountProfilePage = () => { await saveFn({ ...allowRealNameChange && { realname }, ...allowEmailChange && getUserEmailAddress(user) !== email && { email }, - ...allowPasswordChange && { password }, + ...allowPasswordChange && { newPassword: password }, ...canChangeUsername && { username }, ...allowUserStatusMessageChange && { statusText }, ...typedPassword && { typedPassword: SHA256(typedPassword) }, @@ -148,6 +148,8 @@ const AccountProfilePage = () => { nickname, bio: bio || '', }, customFields); + handlePassword(''); + handleConfirmationPassword(''); commit(); dispatchToastMessage({ type: 'success', message: t('Profile_saved_successfully') }); } catch (error) { @@ -191,6 +193,8 @@ const AccountProfilePage = () => { setModal, commit, nickname, + handlePassword, + handleConfirmationPassword, ]); const handleLogoutOtherLocations = useCallback(async () => { diff --git a/client/account/preferences/PreferencesMyDataSection.js b/client/account/preferences/PreferencesMyDataSection.js index 48230b3f1bd3..46a5b223384f 100644 --- a/client/account/preferences/PreferencesMyDataSection.js +++ b/client/account/preferences/PreferencesMyDataSection.js @@ -41,9 +41,10 @@ const PreferencesMyDataSection = ({ onChange, ...props }) => { try { const result = await requestDataDownload({ fullExport }); if (result.requested) { + const text = t('UserDataDownload_Requested_Text', { pending_operations: result.pendingOperationsBeforeMyRequest }); setModal(} onCancel={closeModal} />); return; @@ -64,9 +65,10 @@ const PreferencesMyDataSection = ({ onChange, ...props }) => { return; } + const text = t('UserDataDownload_RequestExisted_Text', { pending_operations: result.pendingOperationsBeforeMyRequest }); setModal(} onCancel={closeModal} />); diff --git a/client/admin/apps/AppSettings.js b/client/admin/apps/AppSettings.js index d2c873228b1b..9b273b084bfe 100644 --- a/client/admin/apps/AppSettings.js +++ b/client/admin/apps/AppSettings.js @@ -46,6 +46,7 @@ function AppSetting({ appSetting, onChange, value, ...props }) { type, i18nLabel, i18nDescription, + values, } = appSetting; const label = (i18nLabel && tApp(i18nLabel)) || (id || tApp(id)); @@ -58,6 +59,7 @@ function AppSetting({ appSetting, onChange, value, ...props }) { value={value} onChangeValue={onChange} _id={id} + values={values} {...props} />; } diff --git a/client/admin/permissions/EditRolePage.js b/client/admin/permissions/EditRolePage.js new file mode 100644 index 000000000000..5c98042b6a42 --- /dev/null +++ b/client/admin/permissions/EditRolePage.js @@ -0,0 +1,90 @@ +import React from 'react'; +import { Box, Field, FieldGroup, Button, Margins, Callout } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; + +import RoleForm from './RoleForm'; +import { useRoute } from '../../contexts/RouterContext'; +import { useForm } from '../../hooks/useForm'; +import { useTranslation } from '../../contexts/TranslationContext'; +import { useMethod } from '../../contexts/ServerContext'; +import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext'; +import { useRole } from './useRole'; + +const EditRolePageContainer = ({ _id }) => { + const t = useTranslation(); + const role = useRole(_id); + + if (!role) { + return {t('error-invalid-role')}; + } + + return ; +}; + +const EditRolePage = ({ data }) => { + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const usersInRoleRouter = useRoute('admin-permissions'); + const router = useRoute('admin-permissions'); + + const { values, handlers } = useForm({ + name: data.name, + description: data.description || '', + scope: data.scope || 'Users', + mandatory2fa: !!data.mandatory2fa, + }); + + const saveRole = useMethod('authorization:saveRole'); + const deleteRole = useMethod('authorization:deleteRole'); + + const handleManageUsers = useMutableCallback(() => { + usersInRoleRouter.push({ + context: 'users-in-role', + _id: data.name, + }); + }); + + const handleSave = useMutableCallback(async () => { + try { + await saveRole(values); + dispatchToastMessage({ type: 'success', message: t('Saved') }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + const handleDelete = useMutableCallback(async () => { + try { + await deleteRole(data.name); + dispatchToastMessage({ type: 'success', message: t('Role_removed') }); + router.push({}); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + return + + + + + + + + + {!data.protected && + + + + } + + + + + + + + ; +}; + +export default EditRolePageContainer; diff --git a/client/admin/permissions/NewRolePage.js b/client/admin/permissions/NewRolePage.js new file mode 100644 index 000000000000..7c17c0a1fbe2 --- /dev/null +++ b/client/admin/permissions/NewRolePage.js @@ -0,0 +1,46 @@ +import React from 'react'; +import { Box, FieldGroup, ButtonGroup, Button, Margins } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; + +import RoleForm from './RoleForm'; +import { useForm } from '../../hooks/useForm'; +import { useTranslation } from '../../contexts/TranslationContext'; +import { useMethod } from '../../contexts/ServerContext'; +import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext'; + + +const NewRolePage = () => { + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const { values, handlers } = useForm({ + name: '', + description: '', + scope: 'Users', + mandatory2fa: false, + }); + + const saveRole = useMethod('authorization:saveRole'); + + const handleSave = useMutableCallback(async () => { + try { + await saveRole(values); + dispatchToastMessage({ type: 'success', message: t('Saved') }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + return + + + + + + + + + ; +}; + +export default NewRolePage; diff --git a/client/admin/permissions/PermissionsContextBar.js b/client/admin/permissions/PermissionsContextBar.js new file mode 100644 index 000000000000..41225f64bd82 --- /dev/null +++ b/client/admin/permissions/PermissionsContextBar.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; + +import { useRouteParameter, useRoute } from '../../contexts/RouterContext'; +import { useTranslation } from '../../contexts/TranslationContext'; +import VerticalBar from '../../components/basic/VerticalBar'; +import NewRolePage from './NewRolePage'; +import EditRolePage from './EditRolePage'; + +const PermissionsContextBar = () => { + const t = useTranslation(); + const _id = useRouteParameter('_id'); + const context = useRouteParameter('context'); + + const router = useRoute('admin-permissions'); + + const handleVerticalBarCloseButton = useMutableCallback(() => { + router.push({}); + }); + + return (context && + + {context === 'new' && t('New_role')} + {context === 'edit' && t('Role_Editing')} + + + + {context === 'new' && } + {context === 'edit' && } + + ) || null; +}; + +export default PermissionsContextBar; diff --git a/client/admin/permissions/PermissionsRouter.js b/client/admin/permissions/PermissionsRouter.js new file mode 100644 index 000000000000..5947a9e9b417 --- /dev/null +++ b/client/admin/permissions/PermissionsRouter.js @@ -0,0 +1,16 @@ +import React from 'react'; + +import { useRouteParameter } from '../../contexts/RouterContext'; +import UsersInRole from './UsersInRole'; +import PermissionsTable from './PermissionsTable'; + +const PermissionsRouter = () => { + const context = useRouteParameter('context'); + if (context === 'users-in-role') { + return ; + } + + return ; +}; + +export default PermissionsRouter; diff --git a/client/admin/permissions/PermissionsTable.js b/client/admin/permissions/PermissionsTable.js new file mode 100644 index 000000000000..d6f41a688d88 --- /dev/null +++ b/client/admin/permissions/PermissionsTable.js @@ -0,0 +1,261 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { TextInput, Table, Margins, Box, Icon, CheckBox, Throbber, Tabs, Button } from '@rocket.chat/fuselage'; +import { useMutableCallback, useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { css } from '@rocket.chat/css-in-js'; + +import Page from '../../components/basic/Page'; +import PermissionsContextBar from './PermissionsContextBar'; +import { GenericTable } from '../../components/GenericTable'; +import { useReactiveValue } from '../../hooks/useReactiveValue'; +import { useTranslation } from '../../contexts/TranslationContext'; +import { useMethod } from '../../contexts/ServerContext'; +import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext'; +import { useRoute } from '../../contexts/RouterContext'; +import { ChatPermissions } from '../../../app/authorization/client/lib/ChatPermissions'; +import { CONSTANTS, AuthorizationUtils } from '../../../app/authorization/lib'; +import { Roles } from '../../../app/models/client'; + +const useChangeRole = ({ onGrant, onRemove, permissionId }) => { + const dispatchToastMessage = useToastMessageDispatch(); + return useMutableCallback(async (roleId, granted) => { + try { + if (granted) { + await onRemove(permissionId, roleId); + } else { + await onGrant(permissionId, roleId); + } + return !granted; + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + return granted; + }); +}; + + +const usePermissionsAndRoles = (type = 'permissions', filter = '', limit = 25, skip = 0) => { + const getPermissions = useCallback(() => { + const filterRegExp = new RegExp(filter, 'i'); + + return ChatPermissions.find( + { + level: type === 'permissions' ? { $ne: CONSTANTS.SETTINGS_LEVEL } : CONSTANTS.SETTINGS_LEVEL, + _id: filterRegExp, + }, + { + sort: { + _id: 1, + }, + skip, + limit, + }, + ); + }, [filter, limit, skip, type]); + + const getRoles = useMutableCallback(() => Roles.find().fetch(), []); + + const permissions = useReactiveValue(getPermissions); + const roles = useReactiveValue(getRoles); + + return [permissions.fetch(), permissions.count(false), roles]; +}; + +const RoleCell = React.memo(({ grantedRoles = [], _id, description, onChange, lineHovered, permissionId }) => { + const [granted, setGranted] = useState(() => !!grantedRoles.includes(_id)); + const [loading, setLoading] = useState(false); + + const isRestrictedForRole = AuthorizationUtils.isPermissionRestrictedForRole(permissionId, _id); + + const handleChange = useMutableCallback(async () => { + setLoading(true); + const result = await onChange(_id, granted); + setGranted(result); + setLoading(false); + }); + + const isDisabled = !!loading || !!isRestrictedForRole; + + return + + + {!loading && + {description || _id} + } + {loading && } + + ; +}); + +const getName = (t, permission) => { + if (permission.level === CONSTANTS.SETTINGS_LEVEL) { + let path = ''; + if (permission.group) { + path = `${ t(permission.group) } > `; + } + if (permission.section) { + path = `${ path }${ t(permission.section) } > `; + } + return `${ path }${ t(permission.settingId) }`; + } + + return t(permission._id); +}; + +const PermissionRow = React.memo(({ permission, t, roleList, onGrant, onRemove, ...props }) => { + const { + _id, + roles, + } = permission; + + const [hovered, setHovered] = useState(false); + + const onMouseEnter = useMutableCallback(() => setHovered(true)); + const onMouseLeave = useMutableCallback(() => setHovered(false)); + + const changeRole = useChangeRole({ onGrant, onRemove, permissionId: _id }); + return + {getName(t, permission)} + {roleList.map(({ _id, description }) => )} + ; +}); + +const RoleHeader = React.memo(({ router, _id, description, ...props }) => { + const onClick = useMutableCallback(() => { + router.push({ + context: 'edit', + _id, + }); + }); + + return + + + {description || _id} + + + + ; +}); + +const FilterComponent = ({ onChange }) => { + const t = useTranslation(); + const [filter, setFilter] = useState(''); + + const debouncedFilter = useDebouncedValue(filter, 500); + + useEffect(() => { + onChange(debouncedFilter); + }, [debouncedFilter, onChange]); + + const handleFilter = useMutableCallback(({ currentTarget: { value } }) => { + setFilter(value); + }); + + return ; +}; + +const PermissionsTable = () => { + const t = useTranslation(); + const [filter, setFilter] = useState(''); + const [type, setType] = useState('permissions'); + const [params, setParams] = useState({ limit: 25, skip: 0 }); + + const router = useRoute('admin-permissions'); + + const grantRole = useMethod('authorization:addPermissionToRole'); + const removeRole = useMethod('authorization:removeRoleFromPermission'); + + const permissionsData = usePermissionsAndRoles(type, filter, params.limit, params.skip); + + const [ + permissions, + total, + roleList, + ] = permissionsData; + + const handleParams = useMutableCallback(({ current, itemsPerPage }) => { + setParams({ skip: current, limit: itemsPerPage }); + }); + + const handlePermissionsTab = useMutableCallback(() => { + if (type === 'permissions') { return; } + setType('permissions'); + }); + + const handleSettingsTab = useMutableCallback(() => { + if (type === 'settings') { return; } + setType('settings'); + }); + + const handleAdd = useMutableCallback(() => { + router.push({ + context: 'new', + }); + }); + + return + + + + + + + + {t('Permissions')} + + + {t('Settings')} + + + + + + + + {t('Name')} + {roleList.map(({ _id, description }) => )} + } + total={total} + results={permissions} + params={params} + setParams={handleParams} + fixed={false} + > + {useCallback((permission) => , [grantRole, removeRole, roleList, t])} + + + + + + ; +}; + +export default PermissionsTable; diff --git a/client/admin/permissions/RoleForm.js b/client/admin/permissions/RoleForm.js new file mode 100644 index 000000000000..d91e99e5df97 --- /dev/null +++ b/client/admin/permissions/RoleForm.js @@ -0,0 +1,59 @@ +import React, { useMemo } from 'react'; +import { Box, Field, TextInput, Select, ToggleSwitch } from '@rocket.chat/fuselage'; + +import { useTranslation } from '../../contexts/TranslationContext'; + +const RoleForm = ({ values, handlers, className, editing = false, isProtected = false }) => { + const t = useTranslation(); + + const { + name, + description, + scope, + mandatory2fa, + } = values; + + const { + handleName, + handleDescription, + handleScope, + handleMandatory2fa, + } = handlers; + + const options = useMemo(() => [ + ['Users', t('Global')], + ['Subscriptions', t('Rooms')], + ], [t]); + + return <> + + {t('Role')} + + + + + + {t('Description')} + + + + {('Leave the description field blank if you dont want to show the role')} + + + {t('Scope')} + + + + + + + + + setReloadFrequency(val))} value={reloadFrequency}/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ; +}; + +export default RealTimeMonitoringPage; diff --git a/client/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js b/client/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js new file mode 100644 index 000000000000..8a9d96b96201 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js @@ -0,0 +1,72 @@ +import React, { useRef, useEffect } from 'react'; + +import Chart from './Chart'; +import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../../hooks/useEndpointDataExperimental'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import { drawDoughnutChart } from '../../../../app/livechat/client/lib/chartHandler'; +import { useUpdateChartData } from './useUpdateChartData'; + +const labels = ['Available', 'Away', 'Busy', 'Offline']; + +const initialData = { + available: 0, + away: 0, + busy: 0, + offline: 0, +}; + +const init = (canvas, context, t) => drawDoughnutChart( + canvas, + t('Agents'), + context, + labels, + Object.values(initialData), +); + +const AgentStatusChart = ({ params, reloadRef, ...props }) => { + const t = useTranslation(); + + const canvas = useRef(); + const context = useRef(); + + const updateChartData = useUpdateChartData({ + context, + canvas, + t, + init, + }); + + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/charts/agents-status', + params, + ); + + reloadRef.current.agentStatusChart = reload; + + const { + offline = 0, + available = 0, + away = 0, + busy = 0, + } = data ?? initialData; + + useEffect(() => { + const initChart = async () => { + context.current = await init(canvas.current, context.current, t); + }; + initChart(); + }, [t]); + + useEffect(() => { + if (state === ENDPOINT_STATES.DONE) { + updateChartData('Offline', [offline]); + updateChartData('Available', [available]); + updateChartData('Away', [away]); + updateChartData('Busy', [busy]); + } + }, [available, away, busy, offline, state, t, updateChartData]); + + return ; +}; + +export default AgentStatusChart; diff --git a/client/omnichannel/realTimeMonitoring/charts/Chart.js b/client/omnichannel/realTimeMonitoring/charts/Chart.js new file mode 100644 index 000000000000..19d66f066fc3 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/Chart.js @@ -0,0 +1,17 @@ +import React, { forwardRef } from 'react'; +import { Box } from '@rocket.chat/fuselage'; + +const style = { + minHeight: '250px', +}; +const Chart = forwardRef(function Chart(props, ref) { + return + + ; +}); + +export default Chart; diff --git a/client/omnichannel/realTimeMonitoring/charts/ChatDurationChart.js b/client/omnichannel/realTimeMonitoring/charts/ChatDurationChart.js new file mode 100644 index 000000000000..38cfa0598744 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/ChatDurationChart.js @@ -0,0 +1,71 @@ +import React, { useRef, useEffect } from 'react'; + +import Chart from './Chart'; +import { useUpdateChartData } from './useUpdateChartData'; +import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../../hooks/useEndpointDataExperimental'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import { drawLineChart } from '../../../../app/livechat/client/lib/chartHandler'; +import { getMomentChartLabelsAndData } from './getMomentChartLabelsAndData'; +import { getMomentCurrentLabel } from './getMomentCurrentLabel'; + +const [labels, initialData] = getMomentChartLabelsAndData(); + +const init = (canvas, context, t) => drawLineChart( + canvas, + context, + [t('Avg_chat_duration'), t('Longest_chat_duration')], + labels, + [initialData, initialData], + { legends: true, anim: true, smallTicks: true }, +); + +const ChatDurationChart = ({ params, reloadRef, ...props }) => { + const t = useTranslation(); + + const canvas = useRef(); + const context = useRef(); + + const updateChartData = useUpdateChartData({ + context, + canvas, + t, + init, + }); + + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/charts/timings', + params, + ); + + reloadRef.current.chatDurationChart = reload; + + const { + chatDuration: { + avg, + longest, + }, + } = data ?? { + chatDuration: { + avg: 0, + longest: 0, + }, + }; + + useEffect(() => { + const initChart = async () => { + context.current = await init(canvas.current, context.current, t); + }; + initChart(); + }, [t]); + + useEffect(() => { + if (state === ENDPOINT_STATES.DONE) { + const label = getMomentCurrentLabel(); + updateChartData(label, [avg, longest]); + } + }, [avg, longest, state, t, updateChartData]); + + return ; +}; + +export default ChatDurationChart; diff --git a/client/omnichannel/realTimeMonitoring/charts/ChatsChart.js b/client/omnichannel/realTimeMonitoring/charts/ChatsChart.js new file mode 100644 index 000000000000..032a9439ba64 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/ChatsChart.js @@ -0,0 +1,73 @@ +import React, { useRef, useEffect } from 'react'; + +import Chart from './Chart'; +import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../../hooks/useEndpointDataExperimental'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import { drawDoughnutChart } from '../../../../app/livechat/client/lib/chartHandler'; +import { useUpdateChartData } from './useUpdateChartData'; + +const labels = [ + 'Open', + 'Queued', + 'Closed', +]; + +const initialData = { + open: 0, + queued: 0, + closed: 0, +}; + +const init = (canvas, context, t) => drawDoughnutChart( + canvas, + t('Chats'), + context, + labels, + Object.values(initialData), +); + +const ChatsChart = ({ params, reloadRef, ...props }) => { + const t = useTranslation(); + + const canvas = useRef(); + const context = useRef(); + + const updateChartData = useUpdateChartData({ + context, + canvas, + t, + init, + }); + + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/charts/chats', + params, + ); + + reloadRef.current.chatsChart = reload; + + const { + open, + queued, + closed, + } = data ?? initialData; + + useEffect(() => { + const initChart = async () => { + context.current = await init(canvas.current, context.current, t); + }; + initChart(); + }, [t]); + + useEffect(() => { + if (state === ENDPOINT_STATES.DONE) { + updateChartData(t('Open'), [open]); + updateChartData(t('Closed'), [closed]); + updateChartData(t('Queued'), [queued]); + } + }, [closed, open, queued, state, t, updateChartData]); + + return ; +}; + +export default ChatsChart; diff --git a/client/omnichannel/realTimeMonitoring/charts/ChatsPerAgentChart.js b/client/omnichannel/realTimeMonitoring/charts/ChatsPerAgentChart.js new file mode 100644 index 000000000000..3b18b98e19ee --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/ChatsPerAgentChart.js @@ -0,0 +1,64 @@ +import React, { useRef, useEffect } from 'react'; + +import Chart from './Chart'; +import { useUpdateChartData } from './useUpdateChartData'; +import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../../hooks/useEndpointDataExperimental'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import { drawLineChart } from '../../../../app/livechat/client/lib/chartHandler'; + +const initialData = { + agents: {}, +}; + +const init = (canvas, context, t) => drawLineChart( + canvas, + context, + [t('Open'), t('Closed')], + [], + [[], []], + { legends: true, anim: true, smallTicks: true }, +); + +const ChatsPerAgentChart = ({ params, reloadRef, ...props }) => { + const t = useTranslation(); + + const canvas = useRef(); + const context = useRef(); + + const updateChartData = useUpdateChartData({ + context, + canvas, + t, + init, + }); + + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/charts/chats-per-agent', + params, + ); + + reloadRef.current.chatsPerAgentChart = reload; + + const { + agents = {}, + } = data ?? initialData; + + useEffect(() => { + const initChart = async () => { + context.current = await init(canvas.current, context.current, t); + }; + initChart(); + }, [t]); + + useEffect(() => { + if (state === ENDPOINT_STATES.DONE) { + Object.entries(agents).forEach(([name, value]) => { + updateChartData(name, [value.open, value.closed]); + }); + } + }, [agents, state, t, updateChartData]); + + return ; +}; + +export default ChatsPerAgentChart; diff --git a/client/omnichannel/realTimeMonitoring/charts/ChatsPerDepartmentChart.js b/client/omnichannel/realTimeMonitoring/charts/ChatsPerDepartmentChart.js new file mode 100644 index 000000000000..493c7d9288a7 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/ChatsPerDepartmentChart.js @@ -0,0 +1,64 @@ +import React, { useRef, useEffect } from 'react'; + +import Chart from './Chart'; +import { useUpdateChartData } from './useUpdateChartData'; +import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../../hooks/useEndpointDataExperimental'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import { drawLineChart } from '../../../../app/livechat/client/lib/chartHandler'; + +const initialData = { + departments: {}, +}; + +const init = (canvas, context, t) => drawLineChart( + canvas, + context, + [t('Open'), t('Closed')], + [], + [[], []], + { legends: true, anim: true, smallTicks: true }, +); + +const ChatsPerDepartmentChart = ({ params, reloadRef, ...props }) => { + const t = useTranslation(); + + const canvas = useRef(); + const context = useRef(); + + const updateChartData = useUpdateChartData({ + context, + canvas, + t, + init, + }); + + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/charts/chats-per-department', + params, + ); + + reloadRef.current.chatsPerDepartmentChart = reload; + + const { + departments = {}, + } = data ?? initialData; + + useEffect(() => { + const initChart = async () => { + context.current = await init(canvas.current, context.current, t); + }; + initChart(); + }, [t]); + + useEffect(() => { + if (state === ENDPOINT_STATES.DONE) { + Object.entries(departments).forEach(([name, value]) => { + updateChartData(name, [value.open, value.closed]); + }); + } + }, [departments, state, t, updateChartData]); + + return ; +}; + +export default ChatsPerDepartmentChart; diff --git a/client/omnichannel/realTimeMonitoring/charts/ResponseTimesChart.js b/client/omnichannel/realTimeMonitoring/charts/ResponseTimesChart.js new file mode 100644 index 000000000000..5f34715280c2 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/ResponseTimesChart.js @@ -0,0 +1,79 @@ +import React, { useRef, useEffect } from 'react'; + +import Chart from './Chart'; +import { useUpdateChartData } from './useUpdateChartData'; +import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../../hooks/useEndpointDataExperimental'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import { drawLineChart } from '../../../../app/livechat/client/lib/chartHandler'; +import { getMomentChartLabelsAndData } from './getMomentChartLabelsAndData'; +import { getMomentCurrentLabel } from './getMomentCurrentLabel'; + +const [labels, initialData] = getMomentChartLabelsAndData(); + +const init = (canvas, context, t) => drawLineChart( + canvas, + context, + [t('Avg_reaction_time'), t('Longest_reaction_time'), t('Avg_response_time'), t('Longest_response_time')], + labels, + [initialData, initialData, initialData, initialData], + { legends: true, anim: true, smallTicks: true }, +); + +const ResponseTimesChart = ({ params, reloadRef, ...props }) => { + const t = useTranslation(); + + const canvas = useRef(); + const context = useRef(); + + const updateChartData = useUpdateChartData({ + context, + canvas, + t, + init, + }); + + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/charts/timings', + params, + ); + + reloadRef.current.responseTimesChart = reload; + + const { + reaction: { + avg: reactionAvg, + longest: reactionLongest, + }, + response: { + avg: responseAvg, + longest: responseLongest, + }, + } = data ?? { + reaction: { + avg: 0, + longest: 0, + }, + response: { + avg: 0, + longest: 0, + }, + }; + + useEffect(() => { + const initChart = async () => { + context.current = await init(canvas.current, context.current, t); + }; + initChart(); + }, [t]); + + useEffect(() => { + if (state === ENDPOINT_STATES.DONE) { + const label = getMomentCurrentLabel(); + updateChartData(label, [reactionAvg, reactionLongest, responseAvg, responseLongest]); + } + }, [reactionAvg, reactionLongest, responseAvg, responseLongest, state, t, updateChartData]); + + return ; +}; + +export default ResponseTimesChart; diff --git a/client/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.js b/client/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.js new file mode 100644 index 000000000000..64b1ace72cb5 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.js @@ -0,0 +1,14 @@ +import moment from 'moment'; + +export const getMomentChartLabelsAndData = () => { + const timingLabels = []; + const initData = []; + const today = moment().startOf('day'); + for (let m = today; m.diff(moment(), 'hours') < 0; m.add(1, 'hours')) { + const hour = m.format('H'); + timingLabels.push(`${ moment(hour, ['H']).format('hA') }-${ moment((parseInt(hour) + 1) % 24, ['H']).format('hA') }`); + initData.push(0); + } + + return [timingLabels, initData]; +}; diff --git a/client/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.js b/client/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.js new file mode 100644 index 000000000000..964a21b42985 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.js @@ -0,0 +1,8 @@ + +import moment from 'moment'; + +export const getMomentCurrentLabel = () => { + const hour = moment(new Date()).format('H'); + + return `${ moment(hour, ['H']).format('hA') }-${ moment((parseInt(hour) + 1) % 24, ['H']).format('hA') }`; +}; diff --git a/client/omnichannel/realTimeMonitoring/charts/useUpdateChartData.js b/client/omnichannel/realTimeMonitoring/charts/useUpdateChartData.js new file mode 100644 index 000000000000..7deaf9d1f651 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/useUpdateChartData.js @@ -0,0 +1,10 @@ +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; + +import { updateChart } from '../../../../app/livechat/client/lib/chartHandler'; + +export const useUpdateChartData = ({ context, canvas, init, t }) => useMutableCallback(async (label, data) => { + if (!context.current) { + context.current = await init(canvas.current, context.current, t); + } + await updateChart(context.current, label, data); +}); diff --git a/client/omnichannel/realTimeMonitoring/counter/CounterContainer.js b/client/omnichannel/realTimeMonitoring/counter/CounterContainer.js new file mode 100644 index 000000000000..e260e80585a0 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/counter/CounterContainer.js @@ -0,0 +1,29 @@ +import React, { useEffect, useState } from 'react'; +import { Skeleton } from '@rocket.chat/fuselage'; + +import { ENDPOINT_STATES } from '../../../hooks/useEndpointDataExperimental'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import CounterRow from './CounterRow'; +import CounterItem from './CounterItem'; + +const CounterContainer = ({ data, state, initialData, ...props }) => { + const t = useTranslation(); + + const [displayData, setDisplayData] = useState(initialData); + + const { + totalizers, + } = data || { totalizers: initialData }; + + useEffect(() => { + if (state === ENDPOINT_STATES.DONE) { + setDisplayData(totalizers); + } + }, [state, t, totalizers]); + + return + {displayData.map(({ title, value }, i) => } count={value}/>)} + ; +}; + +export default CounterContainer; diff --git a/client/omnichannel/realTimeMonitoring/counter/CounterItem.js b/client/omnichannel/realTimeMonitoring/counter/CounterItem.js new file mode 100644 index 000000000000..8cfc7016d926 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/counter/CounterItem.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { Box } from '@rocket.chat/fuselage'; + +const CounterItem = ({ title = '', count = '-', ...props }) => + + {title} + + + {count} + +; + +export default CounterItem; diff --git a/client/omnichannel/realTimeMonitoring/counter/CounterRow.js b/client/omnichannel/realTimeMonitoring/counter/CounterRow.js new file mode 100644 index 000000000000..526372e1580e --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/counter/CounterRow.js @@ -0,0 +1,23 @@ +import React, { Fragment } from 'react'; +import { Box, Divider } from '@rocket.chat/fuselage'; +import flattenChildren from 'react-keyed-flatten-children'; + +const CounterRow = ({ children, ...props }) => + {children && flattenChildren(children).reduce((acc, child, i) => { + acc = children.length - 1 !== i + ? [...acc, {child}, ] + : [...acc, child]; + return acc; + }, [])} +; + +export default CounterRow; diff --git a/client/omnichannel/realTimeMonitoring/counter/CounterRow.stories.js b/client/omnichannel/realTimeMonitoring/counter/CounterRow.stories.js new file mode 100644 index 000000000000..ca1bd8d550e9 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/counter/CounterRow.stories.js @@ -0,0 +1,16 @@ +import React from 'react'; + +import CounterRow from './CounterRow'; +import CounterItem from './CounterItem'; + +export default { + title: 'omnichannel/RealtimeMonitoring/Counter', + component: CounterRow, +}; + +export const Default = () => + + + + +; diff --git a/client/omnichannel/realTimeMonitoring/overviews/AgentsOverview.js b/client/omnichannel/realTimeMonitoring/overviews/AgentsOverview.js new file mode 100644 index 000000000000..29dc5457d219 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/overviews/AgentsOverview.js @@ -0,0 +1,28 @@ +import React from 'react'; + +import { useEndpointDataExperimental } from '../../../hooks/useEndpointDataExperimental'; +import CounterContainer from '../counter/CounterContainer'; + +const overviewInitalValue = { + title: '', + value: '-', +}; + +const initialData = [ + overviewInitalValue, + overviewInitalValue, + overviewInitalValue, +]; + +const AgentsOverview = ({ params, reloadRef, ...props }) => { + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/agents-productivity-totalizers', + params, + ); + + reloadRef.current.agentsOverview = reload; + + return ; +}; + +export default AgentsOverview; diff --git a/client/omnichannel/realTimeMonitoring/overviews/ChatsOverview.js b/client/omnichannel/realTimeMonitoring/overviews/ChatsOverview.js new file mode 100644 index 000000000000..08f6cec7a375 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/overviews/ChatsOverview.js @@ -0,0 +1,23 @@ +import React from 'react'; + +import { useEndpointDataExperimental } from '../../../hooks/useEndpointDataExperimental'; +import CounterContainer from '../counter/CounterContainer'; + +const initialData = [ + { title: '', value: 0 }, + { title: '', value: '0%' }, + { title: '', value: '00:00:00' }, +]; + +const ChatsOverview = ({ params, reloadRef, ...props }) => { + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/chats-totalizers', + params, + ); + + reloadRef.current.chatsOverview = reload; + + return ; +}; + +export default ChatsOverview; diff --git a/client/omnichannel/realTimeMonitoring/overviews/ConversationOverview.js b/client/omnichannel/realTimeMonitoring/overviews/ConversationOverview.js new file mode 100644 index 000000000000..6fe604dd9fee --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/overviews/ConversationOverview.js @@ -0,0 +1,29 @@ +import React from 'react'; + +import { useEndpointDataExperimental } from '../../../hooks/useEndpointDataExperimental'; +import CounterContainer from '../counter/CounterContainer'; + +const overviewInitalValue = { + title: '', + value: 0, +}; + +const initialData = [ + overviewInitalValue, + overviewInitalValue, + overviewInitalValue, + overviewInitalValue, +]; + +const ConversationOverview = ({ params, reloadRef, ...props }) => { + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/conversation-totalizers', + params, + ); + + reloadRef.current.conversationOverview = reload; + + return ; +}; + +export default ConversationOverview; diff --git a/client/omnichannel/realTimeMonitoring/overviews/ProductivityOverview.js b/client/omnichannel/realTimeMonitoring/overviews/ProductivityOverview.js new file mode 100644 index 000000000000..a86cadf5c9a8 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/overviews/ProductivityOverview.js @@ -0,0 +1,27 @@ +import React from 'react'; + +import { useEndpointDataExperimental } from '../../../hooks/useEndpointDataExperimental'; +import CounterContainer from '../counter/CounterContainer'; + +const defaultValue = { title: '', value: '00:00:00' }; + + +const initialData = [ + defaultValue, + defaultValue, + defaultValue, + defaultValue, +]; + +const ProductivityOverview = ({ params, reloadRef, ...props }) => { + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/productivity-totalizers', + params, + ); + + reloadRef.current.productivityOverview = reload; + + return ; +}; + +export default ProductivityOverview; diff --git a/client/omnichannel/routes.js b/client/omnichannel/routes.js index d1f7374ba612..6ac0d104249b 100644 --- a/client/omnichannel/routes.js +++ b/client/omnichannel/routes.js @@ -78,3 +78,13 @@ registerOmnichannelRoute('/current', { name: 'omnichannel-current-chats', lazyRouteComponent: () => import('./currentChats/CurrentChatsRoute'), }); + +registerOmnichannelRoute('/realtime-monitoring', { + name: 'omnichannel-realTime', + lazyRouteComponent: () => import('./realTimeMonitoring/RealTimeMonitoringPage'), +}); + +registerOmnichannelRoute('/analytics', { + name: 'omnichannel-analytics', + lazyRouteComponent: () => import('./analytics/AnalyticsPage'), +}); diff --git a/client/omnichannel/sidebarItems.js b/client/omnichannel/sidebarItems.js index 467e14a63133..210197041334 100644 --- a/client/omnichannel/sidebarItems.js +++ b/client/omnichannel/sidebarItems.js @@ -11,11 +11,11 @@ export const { i18nLabel: 'Current_Chats', permissionGranted: () => hasPermission('view-livechat-current-chats'), }, { - href: 'omnichannel/analytics', + href: 'omnichannel-analytics', i18nLabel: 'Analytics', permissionGranted: () => hasPermission('view-livechat-analytics'), }, { - href: 'omnichannel/real-time-monitoring', + href: 'omnichannel-realTime', i18nLabel: 'Real_Time_Monitoring', permissionGranted: () => hasPermission('view-livechat-real-time-monitoring'), }, { diff --git a/client/omnichannel/triggers/TriggersTable.js b/client/omnichannel/triggers/TriggersTable.js index c95dea6f08fa..b10534e1c69e 100644 --- a/client/omnichannel/triggers/TriggersTable.js +++ b/client/omnichannel/triggers/TriggersTable.js @@ -3,7 +3,7 @@ import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import React, { useState, memo, useMemo } from 'react'; import GenericTable from '../../components/GenericTable'; -import DeleteWarningModal from '../DeleteWarningModal'; +import DeleteWarningModal from '../../components/DeleteWarningModal'; import { useRoute } from '../../contexts/RouterContext'; import { useSetModal } from '../../contexts/ModalContext'; import { useMethod } from '../../contexts/ServerContext'; diff --git a/client/views/setupWizard/steps/SettingsBasedStep.js b/client/views/setupWizard/steps/SettingsBasedStep.js index a38e2d604ac0..323c4a7dcab3 100644 --- a/client/views/setupWizard/steps/SettingsBasedStep.js +++ b/client/views/setupWizard/steps/SettingsBasedStep.js @@ -9,7 +9,7 @@ import { TextInput, } from '@rocket.chat/fuselage'; import { useAutoFocus } from '@rocket.chat/fuselage-hooks'; -import React, { useEffect, useReducer, useState, useCallback } from 'react'; +import React, { useEffect, useReducer, useState, useCallback, useMemo } from 'react'; import { useSettingsDispatch } from '../../../contexts/SettingsContext'; import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext'; @@ -86,6 +86,9 @@ function SettingsBasedStep({ step, title, active }) { } }; + const hasEmptyRequiredFields = useMemo(() => !!fields.find((field) => field.requiredOnWizard && String(field.value).trim() === ''), [fields]); + + if (fields.length === 0) { return @@ -110,9 +113,9 @@ function SettingsBasedStep({ step, title, active }) { - {fields.map(({ _id, type, i18nLabel, value, values }, i) => + {fields.map(({ _id, type, i18nLabel, value, values, requiredOnWizard }, i) => - {t(i18nLabel)} + {t(i18nLabel)} {type === 'string' && 2 && handleBackClick} /> ; diff --git a/definition/webdav.ts b/definition/webdav.ts new file mode 100644 index 000000000000..afc380b8330e --- /dev/null +++ b/definition/webdav.ts @@ -0,0 +1,32 @@ +export type Stat = { + filename: string; + basename: string; + lastmod: string|null; + size: number; + type: string; + mime: string; + etag: string|null; + props: Record; +} + +export type WebDavClient = { + copyFile(remotePath: string, targetRemotePath: string, options?: Record): Promise; + createDirectory(dirPath: string, options?: Record): Promise; + createReadStream(remoteFileName: string, options?: Record): ReadableStream; + createWriteStream(remoteFileName: string, options?: Record, callback?: Function): WritableStream; + customRequest(remotePath: string, requestOptions: Record, options?: Record): Promise; + deleteFile(remotePath: string, options?: Record): Promise; + exists(remotePath: string, options?: Record): Promise; + getDirectoryContents(remotePath: string, options?: Record): Promise>; + getFileContents(remoteFileName: string, options?: Record): Promise; + getFileDownloadLink(remoteFileName: string, options?: Record): string; + getFileUploadLink(remoteFileName: string, options?: Record): string; + getQuota(options?: Record): Promise; + moveFile(remotePath: string, targetRemotePath: string, options?: Record): Promise; + putFileContents(remoteFileName: string, data: string|Buffer, options?: Record): Promise; + stat(remotePath: string, options?: Record): Promise; +} + +declare module 'webdav' { + export function createClient(remoteURL: string, opts?: Record): WebDavClient; +} diff --git a/ee/app/auditing/client/routes.js b/ee/app/auditing/client/routes.js index 334de697eb2b..3087462f6aeb 100644 --- a/ee/app/auditing/client/routes.js +++ b/ee/app/auditing/client/routes.js @@ -1,16 +1,21 @@ import { FlowRouter } from 'meteor/kadira:flow-router'; import { BlazeLayout } from 'meteor/kadira:blaze-layout'; +import { createTemplateForComponent } from '../../../../client/reactAdapters'; + +createTemplateForComponent('auditPage', () => import('../../../client/audit/AuditPage')); +createTemplateForComponent('auditLogPage', () => import('../../../client/audit/AuditLogPage')); + FlowRouter.route('/audit', { name: 'audit-home', action() { - BlazeLayout.render('main', { center: 'audit' }); + BlazeLayout.render('main', { center: 'auditPage' }); }, }); FlowRouter.route('/audit-log', { name: 'audit-log', action() { - BlazeLayout.render('main', { center: 'auditLog' }); + BlazeLayout.render('main', { center: 'auditLogPage' }); }, }); diff --git a/ee/app/auditing/client/templates/audit/audit.html b/ee/app/auditing/client/templates/audit/audit.html index b5247322ce60..4e147a15858b 100644 --- a/ee/app/auditing/client/templates/audit/audit.html +++ b/ee/app/auditing/client/templates/audit/audit.html @@ -1,127 +1,17 @@ - - - - diff --git a/ee/app/auditing/client/templates/audit/audit.js b/ee/app/auditing/client/templates/audit/audit.js index 8235c03cd28d..8ff72329cc87 100644 --- a/ee/app/auditing/client/templates/audit/audit.js +++ b/ee/app/auditing/client/templates/audit/audit.js @@ -1,12 +1,10 @@ -import { Blaze } from 'meteor/blaze'; import { Template } from 'meteor/templating'; import { ReactiveVar } from 'meteor/reactive-var'; import { FlowRouter } from 'meteor/kadira:flow-router'; -import { AutoComplete } from '../../../../../../app/meteor-autocomplete/client'; import { hasAllPermission } from '../../../../../../app/authorization/client'; import { messageContext } from '../../../../../../app/ui-utils/client/lib/messageContext'; -import { call, convertDate, scrollTo } from '../../utils.js'; +import { call } from '../../utils.js'; import './audit.html'; @@ -27,107 +25,8 @@ const loadMessages = async function({ rid, users, startDate, endDate = new Date( } }; -Template.audit.events({ - 'submit form'(e) { - e.preventDefault(); - }, - 'change input[type=date]'(e) { - e.currentTarget.parentElement.parentElement.parentElement.classList.remove('rc-input--error'); - }, - 'change [name=type]'(e, t) { - t.type.set(e.currentTarget.value); - }, - async 'click .js-submit'(e, t) { - const form = e.currentTarget.parentElement; - - const type = t.type.get(); - const result = { type }; - - e.currentTarget.blur(); - - if (type === 'd') { - if (!t.users) { - return form.querySelector('#autocomplete-users').classList.add('rc-input--error'); - } - form.querySelector('#autocomplete-users').classList.remove('rc-input--error'); - result.users = t.users.map((user) => user.username); - } else if (type === 'l') { - if (!t.visitor && !t.agent) { - form.querySelector('#autocomplete-agent').classList.add('rc-input--error'); - form.querySelector('#autocomplete-visitor').classList.add('rc-input--error'); - return; - } - - form.querySelector('#autocomplete-agent').classList.remove('rc-input--error'); - form.querySelector('#autocomplete-visitor').classList.remove('rc-input--error'); - - result.visitor = t.visitor?._id; - result.agent = t.agent?._id; - } else { - if (!t.room || !t.room._id) { - return form.querySelector('#autocomplete-room').classList.add('rc-input--error'); - } - form.querySelector('#autocomplete-room').classList.remove('rc-input--error'); - result.rid = t.room._id; - } - - if (!form.startDate.value) { - return form.startDate.parentElement.parentElement.parentElement.classList.add('rc-input--error'); - } - form.startDate.parentElement.parentElement.parentElement.classList.remove('rc-input--error'); - result.startDate = form.startDate.type === 'date' ? convertDate(form.startDate.value) : form.startDate.value; - - - result.msg = form.msg.value; - - if (!form.endDate.value) { - return form.endDate.parentElement.parentElement.parentElement.classList.add('rc-input--error'); - } - form.endDate.parentElement.parentElement.parentElement.classList.remove('rc-input--error'); - result.endDate = form.endDate.type === 'date' ? convertDate(form.endDate.value) : form.endDate.value; - result.endDate = new Date(result.endDate.getTime() + 86400000); - - await t.loadMessages(result); - - setTimeout(() => { - const offset = $(document.querySelector('.rc-audit-container ul')).offset(); - if (!offset) { - return; - } - scrollTo(document.querySelector('.rc-audit-container'), offset.top - 150, 300); - }, 150); - }, -}); Template.audit.helpers({ - onChange() { - const that = Template.instance(); - return function(value, key) { - that[key] = value; - }; - }, - prepareRoom: () => function(room) { - room.username = room.name; - return room; - }, - modifierUser: () => function(text, filter) { - const f = filter.get(); - return `@${ f.length === 0 ? text : text.replace(new RegExp(f), function(part) { - return `${ part }`; - }) }`; - }, - nTypeOthers() { - return ['d', 'l'].includes(Template.instance().type.get()); - }, - nTypeDM() { - return Template.instance().type.get() !== 'd'; - }, - nTypeOmni() { - return Template.instance().type.get() !== 'l'; - }, - type() { - return Template.instance().type.get(); - }, isLoading() { return Template.instance().loading.get(); }, @@ -137,14 +36,10 @@ Template.audit.helpers({ hasResults() { return Template.instance().hasResults.get(); }, - agentConditions() { - return { role: 'livechat-agent' }; - }, }); Template.audit.onCreated(async function() { this.messagesContext = new ReactiveVar({}); - this.type = new ReactiveVar(); this.loading = new ReactiveVar(false); this.hasResults = new ReactiveVar(false); @@ -159,243 +54,14 @@ Template.audit.onCreated(async function() { }); this.loadMessages = loadMessages.bind(this); -}); - -const acEvents = (key/* , variable, name*/) => ({ - 'click .rc-popup-list__item'(e, t) { - t[key].onItemClick(this, e); - }, - 'keydown input'(e, t) { - if ([8, 46].includes(e.keyCode) && e.target.value === '') { - const users = t.selected; - const usersArr = users.get(); - usersArr.pop(); - return users.set(usersArr); - } - t[key].onKeyDown(e); - }, - 'keyup input'(e, t) { - t[key].onKeyUp(e); - }, - 'focus input'(e, t) { - t[key].onFocus(e); - }, - 'blur input'(e, t) { - t[key].onBlur(e); - }, -}); - -Template.auditAutocompleteDirectMessage.events({ - ...acEvents('ac', 'selected'), - 'input input'(e, t) { - t.filter.set(e.target.value); - }, - 'click .rc-tags__tag-icon'(e, t) { - t.selected.set(); - }, - 'click .rc-tags__tag'({ target }, t) { - const { onClickTag } = t; - return onClickTag & onClickTag(Blaze.getData(target)); - }, -}); - -Template.auditAutocomplete.events({ - ...acEvents('ac', 'selected'), - 'input input'(e, t) { - t.filter.set(e.target.value); - }, - 'click .rc-tags__tag-icon'(e, t) { - t.selected.set(); - }, - 'click .rc-tags__tag'({ target }, t) { - const { onClickTag } = t; - return onClickTag & onClickTag(Blaze.getData(target)); - }, -}); - -Template.auditAutocompleteDirectMessage.helpers({ - selected() { - const instance = Template.instance(); - const selected = instance.selected.get(); - return selected && (instance.data.prepare ? instance.data.prepare(selected) : selected); - }, - config() { - const { filter } = Template.instance(); - return { - template_item: 'popupList_item_channel', - // noMatchTemplate: Template.roomSearchEmpty, - filter: filter.get(), - noMatchTemplate: 'userSearchEmpty', - modifier: (text) => (Template.parentData(8).modifier || function(text, filter) { - const f = filter.get(); - return `#${ f.length === 0 ? text : text.replace(new RegExp(f), function(part) { - return `${ part }`; - }) }`; - })(text, filter), - }; - }, - autocomplete(key) { - const instance = Template.instance(); - const param = instance.ac[key]; - return typeof param === 'function' ? param.apply(instance.ac) : param; - }, - items() { - return Template.instance().ac.filteredList(); - }, -}); - -Template.auditAutocomplete.helpers({ - selected() { - const instance = Template.instance(); - const selected = instance.selected.get(); - return selected && (instance.data.prepare ? instance.data.prepare(selected) : selected); - }, - config() { - const { filter } = Template.instance(); - return { - template_item: 'popupList_item_channel', - // noMatchTemplate: Template.roomSearchEmpty, - filter: filter.get(), - noMatchTemplate: 'userSearchEmpty', - modifier: (text) => (Template.parentData(8).modifier || function(text, filter) { - const f = filter.get(); - return `#${ f.length === 0 ? text : text.replace(new RegExp(f), function(part) { - return `${ part }`; - }) }`; - })(text, filter), - }; - }, - autocomplete(key) { - const instance = Template.instance(); - const param = instance.ac[key]; - return typeof param === 'function' ? param.apply(instance.ac) : param; - }, - items() { - return Template.instance().ac.filteredList(); - }, -}); - -Template.auditAutocomplete.onRendered(async function() { - const { selected } = this; - - this.ac.element = this.firstNode.querySelector('input'); - this.ac.$element = $(this.ac.element); - - this.ac.$element.on('autocompleteselect', function(e, { item }) { - selected.set(item); - }); -}); - -Template.auditAutocomplete.helpers({ - selected() { - const instance = Template.instance(); - const selected = instance.selected.get(); - return selected && (instance.data.prepare ? instance.data.prepare(selected) : selected); - }, - config() { - const { filter } = Template.instance(); - const { templateItem } = Template.instance().data; - return { - template_item: templateItem || 'popupList_item_channel', - // noMatchTemplate: Template.roomSearchEmpty, - filter: filter.get(), - noMatchTemplate: 'userSearchEmpty', - modifier: (text) => (Template.parentData(8).modifier || function(text, filter) { - const f = filter.get(); - return `#${ f.length === 0 ? text : text.replace(new RegExp(f), function(part) { - return `${ part }`; - }) }`; - })(text, filter), - }; - }, - autocomplete(key) { - const instance = Template.instance(); - const param = instance.ac[key]; - return typeof param === 'function' ? param.apply(instance.ac) : param; - }, - items() { - return Template.instance().ac.filteredList(); - }, -}); -const autocompleteConfig = ({ - collection, - endpoint, - field, - term = 'term', -}) => ({ - selector: { - item: '.rc-popup-list__item', - container: '.rc-popup-list__list', - }, - - limit: 10, - inputDelay: 300, - rules: [{ - // @TODO maybe change this 'collection' and/or template - collection, - endpoint, - field, - matchAll: true, - selector(match) { - return { - [term]: match, - }; - }, - sort: field, - }], -}); - -Template.auditAutocomplete.onCreated(function() { - this.filter = new ReactiveVar(''); - this.selected = new ReactiveVar(''); - - this.onClickTag = () => { - this.selected.set(''); - }; - - this.autorun(() => { - const value = this.selected.get(); - this.data.onChange(value, this.data.key); - }); - this.ac = new AutoComplete(autocompleteConfig({ - field: this.data.field, - collection: this.data.collection || 'CachedChannelList', - endpoint: this.data.endpoint || 'rooms.autocomplete.channelAndPrivate', - term: this.data.term || 'term', - })); - this.ac.tmplInst = this; -}); - - -Template.auditAutocompleteDirectMessage.onCreated(function() { - this.filter = new ReactiveVar(''); - this.selected = new ReactiveVar([]); - - this.onClickTag = ({ username }) => { - this.selected.set(this.selected.get().filter((user) => user.username !== username)); - }; - - this.autorun(() => { - const value = this.selected.get(); - this.data.onChange(value, this.data.key); - }); - this.ac = new AutoComplete(autocompleteConfig({ - field: this.data.field, - collection: this.data.collection || 'CachedChannelList', - endpoint: this.data.endpoint || 'rooms.autocomplete.channelAndPrivate', - term: this.data.term || 'term', - })); - this.ac.tmplInst = this; -}); - -Template.auditAutocompleteDirectMessage.onRendered(async function() { - const { selected } = this; - - this.ac.element = this.firstNode.querySelector('input'); - this.ac.$element = $(this.ac.element); - - this.ac.$element.on('autocompleteselect', function(e, { item }) { - selected.set([...selected.get(), item]); - }); + const { + visitor, + agent, + users, + rid, + } = this.data; + if (rid || users.length || agent || visitor) { + await this.loadMessages(this.data); + } }); diff --git a/ee/app/auditing/client/templates/auditLog/auditLog.html b/ee/app/auditing/client/templates/auditLog/auditLog.html deleted file mode 100644 index 22082570c5cc..000000000000 --- a/ee/app/auditing/client/templates/auditLog/auditLog.html +++ /dev/null @@ -1,74 +0,0 @@ - - - diff --git a/ee/app/auditing/client/templates/auditLog/auditLog.js b/ee/app/auditing/client/templates/auditLog/auditLog.js deleted file mode 100644 index 902dfd4228c2..000000000000 --- a/ee/app/auditing/client/templates/auditLog/auditLog.js +++ /dev/null @@ -1,73 +0,0 @@ -import moment from 'moment'; -import { ReactiveVar } from 'meteor/reactive-var'; -import { Template } from 'meteor/templating'; -import { FlowRouter } from 'meteor/kadira:flow-router'; - -import { hasAllPermission } from '../../../../../../app/authorization'; -import { call, convertDate } from '../../utils.js'; - -import './auditLog.html'; - -const loadLog = async function({ startDate, endDate = new Date() }) { - this.logs = this.logs || new ReactiveVar([]); - this.loading = this.loading || new ReactiveVar(false); - if (this.loading.get() === true) { - return; - } - this.loading.set(true); - try { - const logs = await call('auditGetAuditions', { startDate, endDate }); - this.logs.set(logs); - } catch (e) { - this.logs.set([]); - } finally { - this.loading.set(false); - } -}; - -Template.auditLog.events({ - 'click button'(e, t) { - const form = e.currentTarget.parentElement; - t.loadLog({ - startDate: convertDate(form.startDate.value), - endDate: new Date(convertDate(form.endDate.value).getTime() + 86400000), - }); - }, -}); - -Template.auditLog.helpers({ - logs() { - return Template.instance().logs.get(); - }, -}); - -Template.auditLog.onRendered(function() { - this.loadLog = loadLog.bind(this); -}); - -Template.auditLog.onCreated(function() { - this.logs = new ReactiveVar([]); - - if (!hasAllPermission('can-audit-log')) { - return FlowRouter.go('/home'); - } -}); - -Template.auditLogItem.helpers({ - msg() { - return this.fields.msg; - }, - username() { - return this.u.username; - }, - ts() { - return moment(this.ts).format('lll'); - }, - fields() { - const { fields } = this; - - const from = fields.users ? `@${ fields.users[0] } : @${ fields.users[1] }` : `#${ fields.room }`; - - return `${ from }

    ${ moment(fields.startDate).format('DD/MM/YYYY') } to ${ moment(fields.endDate).format('DD/MM/YYYY') }

    `; - }, -}); diff --git a/ee/app/auditing/client/templates/index.js b/ee/app/auditing/client/templates/index.js index b6bc546b034e..8e4cbfbcf7e6 100644 --- a/ee/app/auditing/client/templates/index.js +++ b/ee/app/auditing/client/templates/index.js @@ -1,2 +1 @@ import './audit/audit.js'; -import './auditLog/auditLog.js'; diff --git a/ee/client/audit/AuditLogPage.js b/ee/client/audit/AuditLogPage.js new file mode 100644 index 000000000000..0e2e3ea60119 --- /dev/null +++ b/ee/client/audit/AuditLogPage.js @@ -0,0 +1,44 @@ +import React, { useMemo, useState } from 'react'; +import { Field } from '@rocket.chat/fuselage'; + +import Page from '../../../client/components/basic/Page'; +import DateRangePicker from './DateRangePicker'; +import AuditLogTable from './AuditLogTable'; +import { useTranslation } from '../../../client/contexts/TranslationContext'; +import { useMethodData } from '../../../client/contexts/ServerContext'; + +const AuditLogPage = () => { + const t = useTranslation(); + + const [dateRange, setDateRange] = useState({ + start: '', + end: '', + }); + + const { + start, + end, + } = dateRange; + + const params = useMemo(() => [{ + startDate: new Date(start), + endDate: new Date(end), + }], [end, start]); + + const [data] = useMethodData('auditGetAuditions', params); + + return + + + + {t('Date')} + + + + + + + ; +}; + +export default AuditLogPage; diff --git a/ee/client/audit/AuditLogTable.js b/ee/client/audit/AuditLogTable.js new file mode 100644 index 000000000000..e1b06459b537 --- /dev/null +++ b/ee/client/audit/AuditLogTable.js @@ -0,0 +1,92 @@ +import React, { useMemo } from 'react'; +import { Box, Table } from '@rocket.chat/fuselage'; + +import UserAvatar from '../../../client/components/basic/avatar/UserAvatar'; +import { GenericTable } from '../../../client/components/GenericTable'; +import { useTranslation } from '../../../client/contexts/TranslationContext'; +import { useFormatDateAndTime } from '../../../client/hooks/useFormatDateAndTime'; +import { useFormatDate } from '../../../client/hooks/useFormatDate'; + + +const FilterDisplay = ({ users, room, startDate, endDate, t }) => + + {users ? `@${ users[0] } : @${ users[1] }` : `#${ room }`} + + + {startDate} {t('to')} {endDate} + +; + +const UserRow = React.memo(({ u, results, ts, _id, formatDateAndTime, formatDate, fields, mediaQuery }) => { + const t = useTranslation(); + + const { + username, + name, + avatarETag, + } = u; + + const { + msg, + users, + room, + startDate, + endDate, + } = fields; + + const when = useMemo(() => formatDateAndTime(ts), [formatDateAndTime, ts]); + + return + + + + + + {name || username} + {name && {`@${ username }`} } + + + + + + { msg } + + {when} + {results} + + + + ; +}); + +export function AuditLogTable({ data }) { + const t = useTranslation(); + + const formatDateAndTime = useFormatDateAndTime(); + const formatDate = useFormatDate(); + + return + + {t('Username')} + + + {t('Looked_for')} + + + {t('When')} + + + {t('Results')} + + + {t('Filters_applied')} + + } + results={data} + > + {(props) => } + ; +} + +export default AuditLogTable; diff --git a/ee/client/audit/AuditPage.js b/ee/client/audit/AuditPage.js new file mode 100644 index 000000000000..660c7debda7b --- /dev/null +++ b/ee/client/audit/AuditPage.js @@ -0,0 +1,191 @@ +import React, { useRef, useState } from 'react'; +import { Box, Field, TextInput, ButtonGroup, Button, Margins, Tabs } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; + +import Page from '../../../client/components/basic/Page'; +import DateRangePicker from './DateRangePicker'; +import RoomAutoComplete from './RoomAutoComplete'; +import UserAutoCompleteMultiple from './UserAutoCompleteMultiple'; +import VisitorAutoComplete from './VisitorAutoComplete'; +import Result from './Result'; +import { AutoCompleteAgent } from '../../../client/components/basic/AutoCompleteAgent'; +import { useTranslation } from '../../../client/contexts/TranslationContext'; +import { useForm } from '../../../client/hooks/useForm'; + +const initialValues = { + msg: '', + type: '', + dateRange: { + start: '', + end: '', + }, + visitor: '', + agent: '', + rid: '', + users: [], +}; + +const AuditPage = () => { + const t = useTranslation(); + + const { values, handlers } = useForm(initialValues); + const setData = useRef(() => {}); + + const [errors, setErrors] = useState({}); + + const { + msg, + type, + dateRange: { + start: startDate, + end: endDate, + }, + visitor, + agent, + users, + rid, + } = values; + + const { + handleMsg, + handleType, + handleVisitor, + handleAgent, + handleUsers, + handleRid, + handleDateRange, + } = handlers; + + const useHandleType = (type) => useMutableCallback(() => { + handleType(type); + }); + + const onChangeUsers = useMutableCallback((value, action) => { + if (!action) { + if (users.includes(value)) { + return; + } + return handleUsers([...users, value]); + } + handleUsers(users.filter((current) => current !== value)); + }); + + const apply = useMutableCallback(() => { + if (!rid && type === '') { + return setErrors({ + rid: t('The_field_is_required', t('room_name')), + }); + } + + if (users.length < 2 && type === 'd') { + return setErrors({ + users: t('Select_at_least_two_users'), + }); + } + + if (type === 'l') { + const errors = {}; + + if (visitor === '') { + errors.visitor = t('The_field_is_required', t('Visitor')); + } + + if (agent === '') { + errors.visitor = t('The_field_is_required', t('Agent')); + } + + if (errors.visitor || errors.agent) { + return setErrors(errors); + } + } + + setErrors({}); + setData.current({ + msg, + type, + startDate: new Date(startDate), + endDate: new Date(endDate), + visitor, + agent, + users, + rid, + }); + }); + + return + + + {t('Others')} + {t('Direct_Messages')} + {t('Omnichannel')} + + + + + + + {t('Message')} + + + + + + {t('Date')} + + + + + + + + {type === '' && + {t('room_name')} + + + + {errors.rid && + {errors.rid} + } + } + {type === 'd' && + {t('Users')} + + + + {errors.users && + {errors.users} + } + } + {type === 'l' && + + + {t('Visitor')} + + + + {errors.visitor && + {errors.visitor} + } + + + {t('Agent')} + + + + {errors.agent && + {errors.agent} + } + + + } + + + + + + + + ; +}; + +export default AuditPage; diff --git a/ee/client/audit/AuditPage.stories.js b/ee/client/audit/AuditPage.stories.js new file mode 100644 index 000000000000..d97a3e618a38 --- /dev/null +++ b/ee/client/audit/AuditPage.stories.js @@ -0,0 +1,11 @@ +import React from 'react'; + +import AuditPage from './AuditPage'; + +export default { + title: 'ee/Audit', + component: AuditPage, +}; + + +export const Default = () => ; diff --git a/ee/client/audit/DateRangePicker.js b/ee/client/audit/DateRangePicker.js new file mode 100644 index 000000000000..16b64373102e --- /dev/null +++ b/ee/client/audit/DateRangePicker.js @@ -0,0 +1,122 @@ +import React, { useState, useMemo, useEffect } from 'react'; +import { Box, InputBox, Menu, Margins } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; + +import { useTranslation } from '../../../client/contexts/TranslationContext'; + +const date = new Date(); + +const formatToDateInput = (date) => date.toISOString().slice(0, 10); + +const todayDate = formatToDateInput(date); + +const getMonthRange = (monthsToSubtractFromToday) => { + const date = new Date(); + return { + start: formatToDateInput(new Date( + date.getFullYear(), + date.getMonth() - monthsToSubtractFromToday, + 1)), + end: formatToDateInput(new Date( + date.getFullYear(), + date.getMonth() - monthsToSubtractFromToday + 1, + 0)), + }; +}; + +const getWeekRange = (daysToSubtractFromStart, daysToSubtractFromEnd) => { + const date = new Date(); + return { + start: formatToDateInput(new Date( + date.getFullYear(), + date.getMonth(), + date.getDate() - daysToSubtractFromStart)), + end: formatToDateInput(new Date( + date.getFullYear(), + date.getMonth(), + date.getDate() - daysToSubtractFromEnd)), + }; +}; + +const DateRangePicker = ({ onChange = () => {}, ...props }) => { + const t = useTranslation(); + const [range, setRange] = useState({ start: '', end: '' }); + + const { + start, + end, + } = range; + + const handleStart = useMutableCallback(({ currentTarget }) => { + const rangeObj = { + start: currentTarget.value, + end: range.end, + }; + setRange(rangeObj); + onChange(rangeObj); + }); + + const handleEnd = useMutableCallback(({ currentTarget }) => { + const rangeObj = { + end: currentTarget.value, + start: range.start, + }; + setRange(rangeObj); + onChange(rangeObj); + }); + + const handleRange = useMutableCallback((range) => { + setRange(range); + onChange(range); + }); + + useEffect(() => { + handleRange({ + start: todayDate, + end: todayDate, + }); + }, [handleRange]); + + const options = useMemo(() => ({ + today: { + icon: 'history', + label: t('Today'), + action: () => { handleRange(getWeekRange(0, 0)); }, + }, + yesterday: { + icon: 'history', + label: t('Yesterday'), + action: () => { handleRange(getWeekRange(1, 1)); }, + }, + thisWeek: { + icon: 'history', + label: t('This_week'), + action: () => { handleRange(getWeekRange(7, 0)); }, + }, + previousWeek: { + icon: 'history', + label: t('Previous_week'), + action: () => { handleRange(getWeekRange(14, 7)); }, + }, + thisMonth: { + icon: 'history', + label: t('This_month'), + action: () => { handleRange(getMonthRange(0)); }, + }, + lastMonth: { + icon: 'history', + label: t('Previous_month'), + action: () => { handleRange(getMonthRange(1)); }, + }, + }), [handleRange, t]); + + return + + + + + + ; +}; + +export default DateRangePicker; diff --git a/ee/client/audit/Result.js b/ee/client/audit/Result.js new file mode 100644 index 000000000000..86f3ea5b87a8 --- /dev/null +++ b/ee/client/audit/Result.js @@ -0,0 +1,48 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { Box } from '@rocket.chat/fuselage'; +import { useStableArray } from '@rocket.chat/fuselage-hooks'; +import { Template } from 'meteor/templating'; +import { Blaze } from 'meteor/blaze'; + + +import '../../app/auditing/client/templates/audit/audit.html'; + +const Result = React.memo(({ setDataRef }) => { + const ref = useRef(); + + const [data, setData] = useState({}); + + const { + msg, + type, + startDate, + endDate, + visitor, + agent, + users = [], + rid, + } = data; + + const stableUsers = useStableArray(users); + + setDataRef.current = setData; + + useEffect(() => { + const view = Blaze.renderWithData(Template.audit, { + msg, + type, + startDate, + endDate, + visitor, + agent, + users: stableUsers, + rid, + }, ref.current); + + return () => Blaze.remove(view); + }, [agent, endDate, msg, rid, startDate, type, stableUsers, visitor]); + + return ; +}); + +export default Result; diff --git a/ee/client/audit/RoomAutoComplete.js b/ee/client/audit/RoomAutoComplete.js new file mode 100644 index 000000000000..f65b9c673e81 --- /dev/null +++ b/ee/client/audit/RoomAutoComplete.js @@ -0,0 +1,32 @@ +import React, { useMemo, useState } from 'react'; +import { AutoComplete, Option, Options } from '@rocket.chat/fuselage'; + +import { useEndpointDataExperimental } from '../../../client/hooks/useEndpointDataExperimental'; +import RoomAvatar from '../../../client/components/basic/avatar/RoomAvatar'; + +const query = (term = '') => ({ selector: JSON.stringify({ term }) }); + +const Avatar = ({ value, type, avatarETag, ...props }) => ; + +const RoomAutoComplete = React.memo((props) => { + const [filter, setFilter] = useState(''); + const { data } = useEndpointDataExperimental('rooms.autocomplete.channelAndPrivate', useMemo(() => query(filter), [filter])); + const options = useMemo(() => (data && data.items.map(({ name, _id, avatarETag, t }) => ({ + value: _id, + label: { name, avatarETag, type: t }, + }))) || [], [data]); + + return <> {label.name}} + renderItem={({ value, label, ...props }) =>