diff --git a/.changeset/calm-weeks-mate.md b/.changeset/calm-weeks-mate.md new file mode 100644 index 0000000000000..b3879b37c6fce --- /dev/null +++ b/.changeset/calm-weeks-mate.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/apps-engine': minor +'@rocket.chat/meteor': minor +--- + +Adds file metadata to the Apps.Engine for messages with multiple files diff --git a/.changeset/chilled-lemons-admire.md b/.changeset/chilled-lemons-admire.md new file mode 100644 index 0000000000000..61e45bc65a4bf --- /dev/null +++ b/.changeset/chilled-lemons-admire.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/apps-engine': patch +'@rocket.chat/meteor': patch +--- + +Fixes an issue where apps logs were being lost in nested requests diff --git a/.changeset/cold-coats-cross.md b/.changeset/cold-coats-cross.md new file mode 100644 index 0000000000000..12dbfd710ce23 --- /dev/null +++ b/.changeset/cold-coats-cross.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes an issue with encrypted room's message previews on the sidebar not always being properly decrypted diff --git a/.changeset/cozy-melons-march.md b/.changeset/cozy-melons-march.md new file mode 100644 index 0000000000000..fad782960e8f7 --- /dev/null +++ b/.changeset/cozy-melons-march.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/model-typings': patch +'@rocket.chat/core-typings': patch +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Prevents over-assignment of omnichannel agents beyond their max chats limit in microservices deployments by serializing agent assignment with explicit user-level locking. diff --git a/.changeset/dark-ghosts-cut.md b/.changeset/dark-ghosts-cut.md new file mode 100644 index 0000000000000..cba06864fe6ee --- /dev/null +++ b/.changeset/dark-ghosts-cut.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/apps-engine': patch +--- + +Fixes an issue where app-defined API endpoints with dynamic paths could fail to receive requests when using path parameters like `:param`. diff --git a/.changeset/eighty-windows-join.md b/.changeset/eighty-windows-join.md new file mode 100644 index 0000000000000..6d9072eea1241 --- /dev/null +++ b/.changeset/eighty-windows-join.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes an issue where the Resend Verification Email could be abused to spam mail servers diff --git a/.changeset/fix-archived-room-messages.md b/.changeset/fix-archived-room-messages.md new file mode 100644 index 0000000000000..ad9ffc7b90372 --- /dev/null +++ b/.changeset/fix-archived-room-messages.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue where messages could be sent to archived rooms via the API diff --git a/.changeset/fix-dwg-file-preview.md b/.changeset/fix-dwg-file-preview.md new file mode 100644 index 0000000000000..495bb196bd5aa --- /dev/null +++ b/.changeset/fix-dwg-file-preview.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes preview generation for vendor-specific image formats like `.dwg` (AutoCAD) files. Files with MIME types such as `image/vnd.dwg` and `image/vnd.microsoft.icon` are now excluded from preview generation as they cannot be processed by the Sharp image library, preventing failed preview attempts. diff --git a/.changeset/fix-markdown-between-links.md b/.changeset/fix-markdown-between-links.md new file mode 100644 index 0000000000000..a626fad3632c3 --- /dev/null +++ b/.changeset/fix-markdown-between-links.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/message-parser": patch +--- + +Fixes markdown breaking when text in square brackets appears between hyperlinks. This resolves issues #31418 and #31766 where typing `[text]` between links would incorrectly parse the markdown structure. diff --git a/.changeset/fix-readonly-channel-video-calls.md b/.changeset/fix-readonly-channel-video-calls.md new file mode 100644 index 0000000000000..c463233bb66c0 --- /dev/null +++ b/.changeset/fix-readonly-channel-video-calls.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue where regular users could start video conference calls in read-only channels bypassing message restrictions diff --git a/.changeset/flat-tables-applaud.md b/.changeset/flat-tables-applaud.md new file mode 100644 index 0000000000000..457ad7aeaac72 --- /dev/null +++ b/.changeset/flat-tables-applaud.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/core-typings': patch +'@rocket.chat/meteor': patch +--- + +Fixes association of encrypted messages and encrypted files, so that if one of them is removed, the other gets removed as well. diff --git a/.changeset/forty-socks-roll.md b/.changeset/forty-socks-roll.md new file mode 100644 index 0000000000000..5e07fedb97292 --- /dev/null +++ b/.changeset/forty-socks-roll.md @@ -0,0 +1,10 @@ +--- +'@rocket.chat/core-services': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/models': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Adds a new endpoint to delete uploaded files individually diff --git a/.changeset/fuzzy-pumpkins-remember.md b/.changeset/fuzzy-pumpkins-remember.md new file mode 100644 index 0000000000000..000ac8e973ab5 --- /dev/null +++ b/.changeset/fuzzy-pumpkins-remember.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/core-services': patch +'@rocket.chat/ddp-client': patch +'@rocket.chat/meteor': patch +--- + +Fixes device management logout not redirecting to login page. diff --git a/.changeset/good-singers-kiss.md b/.changeset/good-singers-kiss.md new file mode 100644 index 0000000000000..74d797ee7dceb --- /dev/null +++ b/.changeset/good-singers-kiss.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes issue that caused Outgoing Webhook Retry Count to not be a number + diff --git a/.changeset/great-kings-cry.md b/.changeset/great-kings-cry.md new file mode 100644 index 0000000000000..21daef46ee29c --- /dev/null +++ b/.changeset/great-kings-cry.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue where the camera could stay on after closing the video recording modal. diff --git a/.changeset/green-dragons-boil.md b/.changeset/green-dragons-boil.md new file mode 100644 index 0000000000000..47e9914c8dc19 --- /dev/null +++ b/.changeset/green-dragons-boil.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': patch +'@rocket.chat/meteor': patch +--- + +Fixes an issue where web clients could remain with a stale slashcommand list during a rolling workspace update diff --git a/.changeset/grumpy-suns-remember.md b/.changeset/grumpy-suns-remember.md new file mode 100644 index 0000000000000..58cb976fbb104 --- /dev/null +++ b/.changeset/grumpy-suns-remember.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/http-router': patch +'@rocket.chat/meteor': patch +--- + +Fixes incoming webhook integrations not receiving parsed JSON from x-www-form-urlencoded payload field. diff --git a/.changeset/hot-bikes-sin.md b/.changeset/hot-bikes-sin.md new file mode 100644 index 0000000000000..1fac62a2a42eb --- /dev/null +++ b/.changeset/hot-bikes-sin.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue where managers table loading skeleton column mismatch with headers diff --git a/.changeset/little-mayflies-divide.md b/.changeset/little-mayflies-divide.md new file mode 100644 index 0000000000000..d5c3ee984ed47 --- /dev/null +++ b/.changeset/little-mayflies-divide.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes room header toolbar different spacing on Options menu diff --git a/.changeset/odd-colts-doubt.md b/.changeset/odd-colts-doubt.md new file mode 100644 index 0000000000000..86a9021a8992e --- /dev/null +++ b/.changeset/odd-colts-doubt.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': patch +'@rocket.chat/meteor': patch +--- + +Fixes the `sort` parameter validation on `/api/v1/audit.settings` endpoint to accept string format. diff --git a/.changeset/odd-gorillas-obey.md b/.changeset/odd-gorillas-obey.md new file mode 100644 index 0000000000000..de32602af5c44 --- /dev/null +++ b/.changeset/odd-gorillas-obey.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes issue when trying to create an unencrypted discussion when a parent channel is encrypted diff --git a/.changeset/polite-candles-punch.md b/.changeset/polite-candles-punch.md new file mode 100644 index 0000000000000..e3f83827ec4c4 --- /dev/null +++ b/.changeset/polite-candles-punch.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes an issue with the sidebar message preview (extended layout) showing `undefined` when the message has no previewable content diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000000000..de628643f07aa --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,116 @@ +{ + "mode": "pre", + "tag": "rc", + "initialVersions": { + "@rocket.chat/meteor": "8.2.0-develop", + "rocketchat-services": "2.0.41", + "@rocket.chat/uikit-playground": "0.7.6", + "@rocket.chat/account-service": "0.4.50", + "@rocket.chat/authorization-service": "0.5.3", + "@rocket.chat/ddp-streamer": "0.3.50", + "@rocket.chat/omnichannel-transcript": "0.4.50", + "@rocket.chat/presence-service": "0.4.50", + "@rocket.chat/queue-worker": "0.4.50", + "@rocket.chat/abac": "0.1.3", + "@rocket.chat/federation-matrix": "0.0.12", + "@rocket.chat/license": "1.1.10", + "@rocket.chat/media-calls": "0.2.3", + "@rocket.chat/network-broker": "0.2.29", + "@rocket.chat/omni-core-ee": "0.0.15", + "@rocket.chat/omnichannel-services": "0.3.47", + "@rocket.chat/pdf-worker": "0.3.29", + "@rocket.chat/presence": "0.2.50", + "@rocket.chat/ui-theming": "0.4.4", + "@rocket.chat/account-utils": "0.0.2", + "@rocket.chat/agenda": "0.1.0", + "@rocket.chat/api-client": "0.2.50", + "@rocket.chat/apps": "0.6.3", + "@rocket.chat/apps-engine": "1.59.1", + "@rocket.chat/base64": "1.0.13", + "@rocket.chat/cas-validate": "0.0.3", + "@rocket.chat/core-services": "0.12.3", + "@rocket.chat/core-typings": "8.2.0-develop", + "@rocket.chat/cron": "0.1.50", + "@rocket.chat/ddp-client": "1.0.3", + "@rocket.chat/desktop-api": "1.1.0", + "@rocket.chat/eslint-config": "0.7.0", + "@rocket.chat/favicon": "0.0.4", + "@rocket.chat/fuselage-ui-kit": "27.0.1", + "@rocket.chat/gazzodown": "27.0.1", + "@rocket.chat/http-router": "7.9.17", + "@rocket.chat/i18n": "2.0.1", + "@rocket.chat/instance-status": "0.1.50", + "@rocket.chat/jest-presets": "0.0.1", + "@rocket.chat/jwt": "0.2.0", + "@rocket.chat/livechat": "2.0.3", + "@rocket.chat/log-format": "0.0.2", + "@rocket.chat/logger": "1.0.0", + "@rocket.chat/media-signaling": "0.1.1", + "@rocket.chat/message-parser": "0.31.33", + "@rocket.chat/message-types": "0.1.0", + "@rocket.chat/mock-providers": "0.4.10", + "@rocket.chat/model-typings": "2.0.3", + "@rocket.chat/models": "2.0.3", + "@rocket.chat/mongo-adapter": "0.0.2", + "@rocket.chat/poplib": "0.0.2", + "@rocket.chat/omni-core": "0.0.15", + "@rocket.chat/password-policies": "0.1.0", + "@rocket.chat/patch-injection": "0.0.1", + "@rocket.chat/peggy-loader": "0.31.27", + "@rocket.chat/random": "1.2.2", + "@rocket.chat/release-action": "2.2.3", + "@rocket.chat/release-changelog": "0.1.0", + "@rocket.chat/rest-typings": "8.2.0-develop", + "@rocket.chat/server-cloud-communication": "0.0.2", + "@rocket.chat/server-fetch": "0.0.3", + "@rocket.chat/sha256": "1.0.12", + "@rocket.chat/storybook-config": "0.0.2", + "@rocket.chat/tools": "0.2.4", + "@rocket.chat/tracing": "0.0.1", + "@rocket.chat/tsconfig": "0.0.0", + "@rocket.chat/ui-avatar": "23.0.1", + "@rocket.chat/ui-client": "27.0.1", + "@rocket.chat/ui-composer": "0.5.3", + "@rocket.chat/ui-contexts": "27.0.1", + "@rocket.chat/ui-kit": "0.39.0", + "@rocket.chat/ui-video-conf": "27.0.1", + "@rocket.chat/ui-voip": "17.0.1", + "@rocket.chat/web-ui-registration": "27.0.1" + }, + "changesets": [ + "calm-weeks-mate", + "chilled-lemons-admire", + "cold-coats-cross", + "cozy-melons-march", + "dark-ghosts-cut", + "eighty-windows-join", + "fix-archived-room-messages", + "fix-dwg-file-preview", + "fix-markdown-between-links", + "fix-readonly-channel-video-calls", + "flat-tables-applaud", + "forty-socks-roll", + "fuzzy-pumpkins-remember", + "good-singers-kiss", + "great-kings-cry", + "green-dragons-boil", + "grumpy-suns-remember", + "hot-bikes-sin", + "little-mayflies-divide", + "odd-colts-doubt", + "odd-gorillas-obey", + "polite-candles-punch", + "proud-laws-melt", + "quick-schools-hear", + "rich-pets-sparkle", + "silver-clocks-help", + "smart-emus-chew", + "smooth-dodos-add", + "tender-swans-cheat", + "thick-ties-hunt", + "twelve-meals-sit", + "wet-beers-end", + "wild-moles-drop", + "young-humans-stare" + ] +} diff --git a/.changeset/proud-laws-melt.md b/.changeset/proud-laws-melt.md new file mode 100644 index 0000000000000..c15fdee5b3c4c --- /dev/null +++ b/.changeset/proud-laws-melt.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes dismissed banner popups reappearing after server restart. diff --git a/.changeset/quick-schools-hear.md b/.changeset/quick-schools-hear.md new file mode 100644 index 0000000000000..91e2069b81a19 --- /dev/null +++ b/.changeset/quick-schools-hear.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes room message export to correctly handle messages with multiple files. diff --git a/.changeset/rich-pets-sparkle.md b/.changeset/rich-pets-sparkle.md new file mode 100644 index 0000000000000..5ac5fbbc65ccf --- /dev/null +++ b/.changeset/rich-pets-sparkle.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/ui-voip": patch +--- + +Fixes select not closing when dragging the new call widget diff --git a/.changeset/silver-clocks-help.md b/.changeset/silver-clocks-help.md new file mode 100644 index 0000000000000..98b16342eed74 --- /dev/null +++ b/.changeset/silver-clocks-help.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/model-typings': patch +'@rocket.chat/rest-typings': patch +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Fix a validation issue in the `livechat/custom-fields.save` endpoint diff --git a/.changeset/smart-emus-chew.md b/.changeset/smart-emus-chew.md new file mode 100644 index 0000000000000..4c3f6bacf0599 --- /dev/null +++ b/.changeset/smart-emus-chew.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Creates a new setting with an extra layer of validation to restrict the usage of federation to only users with a validated email address that matches the configured federation domain. diff --git a/.changeset/smooth-dodos-add.md b/.changeset/smooth-dodos-add.md new file mode 100644 index 0000000000000..2da77a1c0b108 --- /dev/null +++ b/.changeset/smooth-dodos-add.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/core-services': patch +'@rocket.chat/meteor': patch +--- + +Fixes delete message permission check in read-only rooms to validate the deleting user's unmuted status instead of the message sender's diff --git a/.changeset/tender-swans-cheat.md b/.changeset/tender-swans-cheat.md new file mode 100644 index 0000000000000..61bcf0f1513ed --- /dev/null +++ b/.changeset/tender-swans-cheat.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/server-fetch': minor +'@rocket.chat/meteor': minor +--- + +Adds configurable SSRF validation for HTTP calls made from server diff --git a/.changeset/thick-ties-hunt.md b/.changeset/thick-ties-hunt.md new file mode 100644 index 0000000000000..d749bfdf11daa --- /dev/null +++ b/.changeset/thick-ties-hunt.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/models': patch +'@rocket.chat/model-typings': patch +'@rocket.chat/meteor': patch +--- + +Fixes endpoints `omnichannel/contacts.update` and `omnichannel/contacts.conflicts` where the contact manager field could not be cleared. diff --git a/.changeset/twelve-meals-sit.md b/.changeset/twelve-meals-sit.md new file mode 100644 index 0000000000000..89ddf2fcb79c2 --- /dev/null +++ b/.changeset/twelve-meals-sit.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/federation-matrix": patch +--- + +Adjusts the minimum supported MongoDB version from 8.2 (Rapid Release with short support lifecycle) to 8, ensuring stable and long-term compatibility diff --git a/.changeset/wet-beers-end.md b/.changeset/wet-beers-end.md new file mode 100644 index 0000000000000..b63ca6038c1fa --- /dev/null +++ b/.changeset/wet-beers-end.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Standardizes the display of username with `@` before diff --git a/.changeset/wild-moles-drop.md b/.changeset/wild-moles-drop.md new file mode 100644 index 0000000000000..9ddad9dfa6a1e --- /dev/null +++ b/.changeset/wild-moles-drop.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/message-parser": patch +--- + +fixes an issues where markdown link parser to was not handling parentheses in URLs diff --git a/.changeset/young-humans-stare.md b/.changeset/young-humans-stare.md new file mode 100644 index 0000000000000..f9d113d947d64 --- /dev/null +++ b/.changeset/young-humans-stare.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Adds automatic cleanup of statistics collection with 1-year retention via TTL index. diff --git a/.github/actions/docker-image-size-tracker/action.yml b/.github/actions/docker-image-size-tracker/action.yml index ea132c8a99c49..5edd90d3bab21 100644 --- a/.github/actions/docker-image-size-tracker/action.yml +++ b/.github/actions/docker-image-size-tracker/action.yml @@ -26,7 +26,15 @@ inputs: platform: description: 'Platform architecture to compare (amd64 or arm64)' required: false - default: 'arm64' + default: 'amd64' + size-thresholds: + description: 'Optional JSON: per-image trigger thresholds. Only comment when an image increase exceeds its threshold. Example: {"rocketchat":{"mb":50,"percent":5},"omnichannel":{"mb":10,"percent":2}}' + required: false + default: '' + fail-thresholds: + description: 'Optional JSON: per-image fail thresholds. Task fails when an image increase exceeds its threshold (mb and/or percent). Example: {"rocketchat":{"mb":100,"percent":15}}' + required: false + default: '' outputs: total-size: @@ -38,6 +46,12 @@ outputs: size-diff-percent: description: 'Size difference percentage' value: ${{ steps.compare.outputs.size-diff-percent }} + comment-triggered: + description: 'Whether to post PR comment (only when size is bigger and thresholds met)' + value: ${{ steps.compare.outputs.comment-triggered }} + failed: + description: 'True if image size exceeded fail-thresholds' + value: ${{ steps.compare.outputs.failed }} runs: using: 'composite' @@ -52,8 +66,10 @@ runs: - name: Measure image sizes from artifacts id: measure shell: bash + env: + PLATFORM: ${{ inputs.platform }} + TAG: ${{ inputs.tag }} run: | - PLATFORM="${{ inputs.platform }}" echo "Reading image sizes from build artifacts for platform: $PLATFORM" declare -A sizes @@ -94,7 +110,7 @@ runs: # Save to JSON echo "{" > current-sizes.json echo " \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"," >> current-sizes.json - echo " \"tag\": \"${{ inputs.tag }}\"," >> current-sizes.json + echo " \"tag\": \"$TAG\"," >> current-sizes.json echo " \"total\": $total," >> current-sizes.json echo " \"services\": {" >> current-sizes.json @@ -121,12 +137,12 @@ runs: id: baseline shell: bash continue-on-error: true + env: + REGISTRY: ${{ inputs.registry }} + ORG: ${{ inputs.repository }} + TAG: ${{ inputs.baseline-tag }} + PLATFORM: ${{ inputs.platform }} run: | - REGISTRY="${{ inputs.registry }}" - ORG="${{ inputs.repository }}" - TAG="${{ inputs.baseline-tag }}" - PLATFORM="${{ inputs.platform }}" - echo "Measuring baseline: $REGISTRY/$ORG/*:$TAG (platform: $PLATFORM)" declare -A sizes @@ -251,6 +267,8 @@ runs: - name: Save current measurement to history if: github.ref == 'refs/heads/develop' shell: bash + env: + CI_PAT: ${{ inputs.ci-pat }} run: | timestamp=$(date -u +%Y%m%d-%H%M%S) commit_sha="${{ github.sha }}" @@ -263,7 +281,7 @@ runs: git commit -m "Add measurement for ${timestamp} (${commit_sha:0:7})" git config --global user.email "ci@rocket.chat" git config --global user.name "rocketchat-ci[bot]" - git config --global url.https://${{ inputs.ci-pat }}@github.com/.insteadOf https://github.com/ + git config --global url.https://$CI_PAT@github.com/.insteadOf https://github.com/ git push origin image-size-history cd - @@ -272,6 +290,11 @@ runs: - name: Compare and generate report id: compare shell: bash + env: + SIZE_THRESHOLDS: ${{ inputs.size-thresholds }} + FAIL_THRESHOLDS: ${{ inputs.fail-thresholds }} + TAG: ${{ inputs.tag }} + BASELINE_TAG: ${{ inputs.baseline-tag }} run: | current_total=$(jq -r '.total' current-sizes.json) @@ -279,6 +302,7 @@ runs: echo "No baseline available" echo "size-diff=0" >> $GITHUB_OUTPUT echo "size-diff-percent=0" >> $GITHUB_OUTPUT + echo "comment-triggered=false" >> $GITHUB_OUTPUT cat > report.md << 'EOF' # 📦 Docker Image Size Report @@ -302,6 +326,17 @@ runs: echo "size-diff=$diff" >> $GITHUB_OUTPUT echo "size-diff-percent=$percent" >> $GITHUB_OUTPUT + # Only comment when size is bigger than baseline; optionally require per-image thresholds + THRESHOLDS="$SIZE_THRESHOLDS" + FAIL_THRESHOLDS="$FAIL_THRESHOLDS" + comment_triggered=false + fail_triggered=false + if [[ $diff -gt 0 ]]; then + if [[ -z "$THRESHOLDS" ]] || [[ "$THRESHOLDS" == "{}" ]]; then + comment_triggered=true + fi + fi + color="gray" if (( $(awk "BEGIN {print ($percent > 0.01)}") )); then color="red" @@ -346,6 +381,38 @@ runs: service_percent=0 fi + # Check per-image thresholds for comment trigger (only when size increased) + if [[ $diff -gt 0 ]] && [[ -n "$THRESHOLDS" ]] && [[ "$THRESHOLDS" != "{}" ]]; then + threshold_mb=$(echo "$THRESHOLDS" | jq -r ".\"$service\".mb // empty") + threshold_pct=$(echo "$THRESHOLDS" | jq -r ".\"$service\".percent // empty") + if [[ -n "$threshold_mb" ]] || [[ -n "$threshold_pct" ]]; then + exceeded=false + if [[ -n "$threshold_mb" ]] && [[ $service_diff -ge $(awk "BEGIN {printf \"%.0f\", $threshold_mb * 1048576}") ]]; then + exceeded=true + fi + if [[ -n "$threshold_pct" ]] && [[ $service_percent != "0.00" ]] && (( $(awk "BEGIN {print ($service_percent >= $threshold_pct)}") )); then + exceeded=true + fi + [[ "$exceeded" == "true" ]] && comment_triggered=true + fi + fi + + # Check per-image fail thresholds (task fails when exceeded) + if [[ $diff -gt 0 ]] && [[ -n "$FAIL_THRESHOLDS" ]] && [[ "$FAIL_THRESHOLDS" != "{}" ]]; then + fail_mb=$(echo "$FAIL_THRESHOLDS" | jq -r ".\"$service\".mb // empty") + fail_pct=$(echo "$FAIL_THRESHOLDS" | jq -r ".\"$service\".percent // empty") + if [[ -n "$fail_mb" ]] || [[ -n "$fail_pct" ]]; then + exceeded=false + if [[ -n "$fail_mb" ]] && [[ $service_diff -ge $(awk "BEGIN {printf \"%.0f\", $fail_mb * 1048576}") ]]; then + exceeded=true + fi + if [[ -n "$fail_pct" ]] && [[ $service_percent != "0.00" ]] && (( $(awk "BEGIN {print ($service_percent >= $fail_pct)}") )); then + exceeded=true + fi + [[ "$exceeded" == "true" ]] && fail_triggered=true + fi + fi + color="gray" if (( $(awk "BEGIN {print ($service_percent > 0.01)}") )); then color="red" @@ -474,62 +541,31 @@ runs:
ℹ️ About this report - This report compares Docker image sizes from this build against the \`${{ inputs.baseline-tag }}\` baseline. + This report compares Docker image sizes from this build against the \`$BASELINE_TAG\` baseline. - - **Tag:** \`${{ inputs.tag }}\` - - **Baseline:** \`${{ inputs.baseline-tag }}\` + - **Tag:** \`$TAG\` + - **Baseline:** \`$BASELINE_TAG\` - **Timestamp:** $(date -u +"%Y-%m-%d %H:%M:%S UTC") - **Historical data points:** $history_count
EOF - - name: Comment on PR - if: github.event_name == 'pull_request' - uses: actions/github-script@v7 - with: - github-token: ${{ inputs.github-token }} - script: | - const fs = require('fs'); - - if (!fs.existsSync('report.md')) { - console.log('No report found, skipping comment'); - return; - } - - const report = fs.readFileSync('report.md', 'utf8'); - - // Find existing comment - const comments = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number - }); - - const botComment = comments.data.find(comment => - comment.user.type === 'Bot' && - comment.body.includes('📦 Docker Image Size Report') - ); - - const commentBody = report + '\n\n---\n*Updated: ' + new Date().toUTCString() + '*'; - - if (botComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: commentBody - }); - console.log('Updated existing comment'); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: commentBody - }); - console.log('Created new comment'); - } + echo "comment-triggered=$comment_triggered" >> $GITHUB_OUTPUT + echo "failed=$fail_triggered" >> $GITHUB_OUTPUT + + if [[ "$fail_triggered" == "true" ]]; then + echo "::error::Docker image size exceeded fail-thresholds (mb/percent). Check the report for details." + exit 1 + fi + + - name: Add report to Job Summary + if: always() + shell: bash + run: | + if [[ -f report.md ]]; then + cat report.md >> $GITHUB_STEP_SUMMARY + fi - name: Cleanup worktree if: always() diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index 0e41352406b56..0e15ee6c9fb5a 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -20,7 +20,7 @@ on: transporter: type: string mongodb-version: - default: "['8.2']" + default: "['8.0']" required: false type: string release: @@ -88,6 +88,7 @@ jobs: steps: - name: Collect Workflow Telemetry + if: inputs.type == 'perf' uses: catchpoint/workflow-telemetry-action@v2 with: theme: dark diff --git a/.github/workflows/ci-test-unit.yml b/.github/workflows/ci-test-unit.yml index 5d35f7ef31bef..7d249f60ff48e 100644 --- a/.github/workflows/ci-test-unit.yml +++ b/.github/workflows/ci-test-unit.yml @@ -28,12 +28,6 @@ jobs: name: Unit Tests steps: - - name: Collect Workflow Telemetry - uses: catchpoint/workflow-telemetry-action@v2 - with: - theme: dark - job_summary: true - comment_on_pr: false - uses: actions/checkout@v6 - name: Setup NodeJS diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b1e812e67ea3..389be8c766bdf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,7 +158,7 @@ jobs: fi; curl -H "Content-Type: application/json" -H "X-Update-Token: $UPDATE_TOKEN" -d \ - "{\"nodeVersion\": \"${{ needs.release-versions.outputs.node-version }}\", \"denoVersion\": \"${{ needs.release-versions.outputs.deno-version }}\",\"compatibleMongoVersions\": [\"8.2\"], \"commit\": \"$GITHUB_SHA\", \"tag\": \"$RC_VERSION\", \"branch\": \"$GIT_BRANCH\", \"artifactName\": \"$ARTIFACT_NAME\", \"releaseType\": \"draft\", \"draftAs\": \"$RC_RELEASE\"}" \ + "{\"nodeVersion\": \"${{ needs.release-versions.outputs.node-version }}\", \"denoVersion\": \"${{ needs.release-versions.outputs.deno-version }}\",\"compatibleMongoVersions\": [\"8.0\"], \"commit\": \"$GITHUB_SHA\", \"tag\": \"$RC_VERSION\", \"branch\": \"$GIT_BRANCH\", \"artifactName\": \"$ARTIFACT_NAME\", \"releaseType\": \"draft\", \"draftAs\": \"$RC_RELEASE\"}" \ https://releases.rocket.chat/update packages-build: @@ -250,12 +250,6 @@ jobs: - type: ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'production' || '' }} steps: - - name: Collect Workflow Telemetry - uses: catchpoint/workflow-telemetry-action@v2 - with: - theme: dark - job_summary: true - comment_on_pr: false - uses: actions/checkout@v6 @@ -456,7 +450,7 @@ jobs: - name: Track Docker image sizes uses: ./.github/actions/docker-image-size-tracker - if: github.actor != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository + if: github.actor != 'dependabot[bot]' && (github.ref == 'refs/heads/develop' || github.event.pull_request.head.repo.full_name == github.repository) with: github-token: ${{ secrets.GITHUB_TOKEN }} ci-pat: ${{ secrets.CI_PAT }} @@ -464,6 +458,7 @@ jobs: repository: ${{ needs.release-versions.outputs.lowercase-repo }} tag: ${{ needs.release-versions.outputs.gh-docker-tag }} baseline-tag: develop + size-thresholds: '{"rocketchat":{"mb":11}}' checks: needs: [release-versions, packages-build] @@ -548,8 +543,8 @@ jobs: release: ee transporter: 'nats://nats:4222' enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} - mongodb-version: "['8.2']" - coverage: '8.2' + mongodb-version: "['8.0']" + coverage: '8.0' node-version: ${{ needs.release-versions.outputs.node-version }} deno-version: ${{ needs.release-versions.outputs.deno-version }} lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} @@ -570,8 +565,8 @@ jobs: enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} shard: '[1, 2, 3, 4, 5]' total-shard: 5 - mongodb-version: "['8.2']" - coverage: '8.2' + mongodb-version: "['8.0']" + coverage: '8.0' node-version: ${{ needs.release-versions.outputs.node-version }} deno-version: ${{ needs.release-versions.outputs.deno-version }} lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} @@ -981,7 +976,7 @@ jobs: fi; curl -H "Content-Type: application/json" -H "X-Update-Token: $UPDATE_TOKEN" -d \ - "{\"nodeVersion\": \"${{ needs.release-versions.outputs.node-version }}\", \"denoVersion\": \"${{ needs.release-versions.outputs.deno-version }}\", \"compatibleMongoVersions\": [\"8.2\"], \"commit\": \"$GITHUB_SHA\", \"tag\": \"$RC_VERSION\", \"branch\": \"$GIT_BRANCH\", \"artifactName\": \"$ARTIFACT_NAME\", \"releaseType\": \"$RC_RELEASE\"}" \ + "{\"nodeVersion\": \"${{ needs.release-versions.outputs.node-version }}\", \"denoVersion\": \"${{ needs.release-versions.outputs.deno-version }}\", \"compatibleMongoVersions\": [\"8.0\"], \"commit\": \"$GITHUB_SHA\", \"tag\": \"$RC_VERSION\", \"branch\": \"$GIT_BRANCH\", \"artifactName\": \"$ARTIFACT_NAME\", \"releaseType\": \"$RC_RELEASE\"}" \ https://releases.rocket.chat/update # Makes build fail if the release isn't there diff --git a/.worktrees/replies-refactor b/.worktrees/replies-refactor new file mode 160000 index 0000000000000..e5b749fabc585 --- /dev/null +++ b/.worktrees/replies-refactor @@ -0,0 +1 @@ +Subproject commit e5b749fabc58569dd3d3d059ca1745d9606b661d diff --git a/apps/meteor/.eslintrc.json b/apps/meteor/.eslintrc.json index 84af970d6b52f..47376a4e7fddf 100644 --- a/apps/meteor/.eslintrc.json +++ b/apps/meteor/.eslintrc.json @@ -1,5 +1,10 @@ { - "extends": ["@rocket.chat/eslint-config", "@rocket.chat/eslint-config/react", "plugin:you-dont-need-lodash-underscore/compatible", "plugin:storybook/recommended"], + "extends": [ + "@rocket.chat/eslint-config", + "@rocket.chat/eslint-config/react", + "plugin:you-dont-need-lodash-underscore/compatible", + "plugin:storybook/recommended" + ], "globals": { "__meteor_bootstrap__": false, "__meteor_runtime_config__": false, @@ -94,15 +99,6 @@ } } ], - "@typescript-eslint/no-misused-promises": [ - "error", - { - "checksVoidReturn": { - "arguments": false - } - } - ], - "@typescript-eslint/no-floating-promises": "error", "no-unreachable-loop": "error" }, "parserOptions": { diff --git a/apps/meteor/.meteor/packages b/apps/meteor/.meteor/packages index 4242b98f6bed3..0b6c97b193712 100644 --- a/apps/meteor/.meteor/packages +++ b/apps/meteor/.meteor/packages @@ -5,12 +5,10 @@ rocketchat:mongo-config rocketchat:livechat -rocketchat:streamer rocketchat:version accounts-base@3.1.2 accounts-facebook@1.3.4 -accounts-github@1.5.1 accounts-google@1.4.1 accounts-meteor-developer@1.5.1 accounts-oauth@1.4.6 diff --git a/apps/meteor/.meteor/versions b/apps/meteor/.meteor/versions index 04fcb4d920bdd..91adcc4d4e5f2 100644 --- a/apps/meteor/.meteor/versions +++ b/apps/meteor/.meteor/versions @@ -1,6 +1,5 @@ accounts-base@3.1.2 accounts-facebook@1.3.4 -accounts-github@1.5.1 accounts-google@1.4.1 accounts-meteor-developer@1.5.1 accounts-oauth@1.4.6 @@ -35,7 +34,6 @@ facebook-oauth@1.11.6 facts-base@1.0.2 fetch@0.1.6 geojson-utils@1.0.12 -github-oauth@1.4.2 google-oauth@1.4.5 hot-code-push@1.0.5 http@3.0.0 @@ -72,7 +70,6 @@ reload@1.3.2 retry@1.1.1 rocketchat:livechat@0.0.1 rocketchat:mongo-config@0.0.1 -rocketchat:streamer@1.1.0 rocketchat:version@1.0.0 routepolicy@1.1.2 service-configuration@1.3.5 diff --git a/apps/meteor/.mocharc.js b/apps/meteor/.mocharc.js index 010d7f720f677..2ec06b5257b65 100644 --- a/apps/meteor/.mocharc.js +++ b/apps/meteor/.mocharc.js @@ -10,7 +10,7 @@ module.exports = { ...base, // see https://github.com/mochajs/mocha/issues/3916 exit: true, spec: [ - 'lib/callbacks.spec.ts', + 'server/lib/callbacks.spec.ts', 'server/lib/ldap/*.spec.ts', 'server/lib/ldap/**/*.spec.ts', 'server/lib/dataExport/**/*.spec.ts', diff --git a/apps/meteor/.scripts/run-ha.ts b/apps/meteor/.scripts/run-ha.ts index 91d37225f4bec..a1a3775000690 100644 --- a/apps/meteor/.scripts/run-ha.ts +++ b/apps/meteor/.scripts/run-ha.ts @@ -84,10 +84,10 @@ async function main(mode: any): Promise { switch (mode) { case ModeParam.MAIN: - runMain(config); + void runMain(config); break; case ModeParam.INSTANCE: - runInstance(config); + void runInstance(config); break; } } @@ -95,4 +95,4 @@ async function main(mode: any): Promise { // First two parameters are the executable and the path to this script const [, , mode] = process.argv; -main(mode); +void main(mode); diff --git a/apps/meteor/.storybook/decorators.tsx b/apps/meteor/.storybook/decorators.tsx index 296698e629537..822f524e5b7ad 100644 --- a/apps/meteor/.storybook/decorators.tsx +++ b/apps/meteor/.storybook/decorators.tsx @@ -12,7 +12,6 @@ export const rocketChatDecorator: Decorator = (fn, { parameters }) => { const linkElement = document.getElementById('theme-styles') || document.createElement('link'); if (linkElement.id !== 'theme-styles') { require('../app/theme/client/main.css'); - require('../app/theme/client/vendor/fontello/css/fontello.css'); require('../app/theme/client/rocketchat.font.css'); linkElement.setAttribute('id', 'theme-styles'); linkElement.setAttribute('rel', 'stylesheet'); diff --git a/apps/meteor/.stylelintignore b/apps/meteor/.stylelintignore index 33637d3dd3e71..242e9a4701c3a 100644 --- a/apps/meteor/.stylelintignore +++ b/apps/meteor/.stylelintignore @@ -1,4 +1,3 @@ -app/theme/client/vendor/fontello/css/fontello.css app/meteor-autocomplete/client/autocomplete.css app/emoji-emojione/client/*.css storybook-static diff --git a/apps/meteor/CHANGELOG.md b/apps/meteor/CHANGELOG.md index b7eaf3baf130e..b676e63c693fc 100644 --- a/apps/meteor/CHANGELOG.md +++ b/apps/meteor/CHANGELOG.md @@ -1,5 +1,110 @@ # @rocket.chat/meteor +## 8.2.0-rc.0 + +### Minor Changes + +- ([#38099](https://github.com/RocketChat/Rocket.Chat/pull/38099)) Adds file metadata to the Apps.Engine for messages with multiple files + +- ([#38173](https://github.com/RocketChat/Rocket.Chat/pull/38173)) Adds a new endpoint to delete uploaded files individually + +- ([#38356](https://github.com/RocketChat/Rocket.Chat/pull/38356)) Creates a new setting with an extra layer of validation to restrict the usage of federation to only users with a validated email address that matches the configured federation domain. + +- ([#38044](https://github.com/RocketChat/Rocket.Chat/pull/38044)) Adds configurable SSRF validation for HTTP calls made from server + +- ([#38532](https://github.com/RocketChat/Rocket.Chat/pull/38532)) Standardizes the display of username with `@` before + +### Patch Changes + +- ([#38374](https://github.com/RocketChat/Rocket.Chat/pull/38374)) Fixes an issue where apps logs were being lost in nested requests + +- ([#38283](https://github.com/RocketChat/Rocket.Chat/pull/38283)) Fixes an issue with encrypted room's message previews on the sidebar not always being properly decrypted + +- ([#37776](https://github.com/RocketChat/Rocket.Chat/pull/37776)) Prevents over-assignment of omnichannel agents beyond their max chats limit in microservices deployments by serializing agent assignment with explicit user-level locking. + +- ([#35971](https://github.com/RocketChat/Rocket.Chat/pull/35971) by [@JASIM0021](https://github.com/JASIM0021)) Fixes an issue where the Resend Verification Email could be abused to spam mail servers + +- ([#38653](https://github.com/RocketChat/Rocket.Chat/pull/38653) by [@copilot-swe-agent](https://github.com/copilot-swe-agent)) Fixes an issue where messages could be sent to archived rooms via the API + +- ([#38794](https://github.com/RocketChat/Rocket.Chat/pull/38794) by [@copilot-swe-agent](https://github.com/copilot-swe-agent)) Fixes preview generation for vendor-specific image formats like `.dwg` (AutoCAD) files. Files with MIME types such as `image/vnd.dwg` and `image/vnd.microsoft.icon` are now excluded from preview generation as they cannot be processed by the Sharp image library, preventing failed preview attempts. + +- ([#38796](https://github.com/RocketChat/Rocket.Chat/pull/38796) by [@copilot-swe-agent](https://github.com/copilot-swe-agent)) Fixes an issue where regular users could start video conference calls in read-only channels bypassing message restrictions + +- ([#38379](https://github.com/RocketChat/Rocket.Chat/pull/38379)) Fixes association of encrypted messages and encrypted files, so that if one of them is removed, the other gets removed as well. + +- ([#38616](https://github.com/RocketChat/Rocket.Chat/pull/38616)) Fixes device management logout not redirecting to login page. + +- ([#37356](https://github.com/RocketChat/Rocket.Chat/pull/37356) by [@MrKalyanKing](https://github.com/MrKalyanKing)) Fixes issue that caused Outgoing Webhook Retry Count to not be a number + +- ([#38491](https://github.com/RocketChat/Rocket.Chat/pull/38491)) Fixes an issue where the camera could stay on after closing the video recording modal. + +- ([#38267](https://github.com/RocketChat/Rocket.Chat/pull/38267)) Fixes an issue where web clients could remain with a stale slashcommand list during a rolling workspace update + +- ([#38319](https://github.com/RocketChat/Rocket.Chat/pull/38319)) Fixes incoming webhook integrations not receiving parsed JSON from x-www-form-urlencoded payload field. + +- ([#38579](https://github.com/RocketChat/Rocket.Chat/pull/38579) by [@ScriptShah](https://github.com/ScriptShah)) Fixes an issue where managers table loading skeleton column mismatch with headers + +- ([#38318](https://github.com/RocketChat/Rocket.Chat/pull/38318)) Fixes room header toolbar different spacing on Options menu + +- ([#38366](https://github.com/RocketChat/Rocket.Chat/pull/38366)) Fixes the `sort` parameter validation on `/api/v1/audit.settings` endpoint to accept string format. + +- ([#38279](https://github.com/RocketChat/Rocket.Chat/pull/38279)) Fixes issue when trying to create an unencrypted discussion when a parent channel is encrypted + +- ([#38262](https://github.com/RocketChat/Rocket.Chat/pull/38262)) Fixes an issue with the sidebar message preview (extended layout) showing `undefined` when the message has no previewable content + +- ([#38282](https://github.com/RocketChat/Rocket.Chat/pull/38282)) Fixes dismissed banner popups reappearing after server restart. + +- ([#38292](https://github.com/RocketChat/Rocket.Chat/pull/38292)) Fixes room message export to correctly handle messages with multiple files. + +- ([#38376](https://github.com/RocketChat/Rocket.Chat/pull/38376)) Fix a validation issue in the `livechat/custom-fields.save` endpoint + +- ([#38415](https://github.com/RocketChat/Rocket.Chat/pull/38415)) Fixes delete message permission check in read-only rooms to validate the deleting user's unmuted status instead of the message sender's + +- ([#38265](https://github.com/RocketChat/Rocket.Chat/pull/38265)) Fixes endpoints `omnichannel/contacts.update` and `omnichannel/contacts.conflicts` where the contact manager field could not be cleared. + +- ([#38596](https://github.com/RocketChat/Rocket.Chat/pull/38596)) Adjusts the minimum supported MongoDB version from 8.2 (Rapid Release with short support lifecycle) to 8, ensuring stable and long-term compatibility + +- ([#38568](https://github.com/RocketChat/Rocket.Chat/pull/38568)) Adds automatic cleanup of statistics collection with 1-year retention via TTL index. + +-
Updated dependencies [bbc14893f10baa6d548274485d1a2470efccfd55, 11821455ea6a8c1cac2a43c433254864b8b2c5f8, d3758a7d57ab602745369ef9d2ccdbf9271cf305, 398fca05554d860a1202c7afd78912f1254257f5, 098f0a7467332f10a7bea5d435ae2ca3b5431fc9, fbc4935dec220495201cf905017170d3cd1e275c, e57f15845e4df048dd2f08f11aa08215780a2c34, 11e1c51f0867a35c69ce9b6eeca25dbbe2c71872, 88da141f3c2af6f91980c7ca8b8777161f99a068, 1c474580b768358b49c93002b1277e7065df02fe, 75d089ca40248af963d7cd2a8034c3c6de6b971e, a75e1f168050bd49880e0d3e1b02e36a4f53b6f8, 3b003e6b69c11b280d55bcc8db2f3e4ae7a4a573, 87faec13b3c0efc3e85627f9b70c4561b7231416, d6ef0db96a60e3ad27b980af6df2e80fad1467be, 508b4a17d76dc1cd7d3a55bdba826216f51432e2, 379c2b22f54911ebf17c0872c9ca8e2baaac3609, 562d5ce6ad8afc67bef61e91939f8c21c4501610, 123aebec2caa74b17d2b5dcbd2a2db2e687cf3ac]: + + - @rocket.chat/apps-engine@1.60.0-rc.0 + - @rocket.chat/model-typings@2.1.0-rc.0 + - @rocket.chat/core-typings@8.2.0-rc.0 + - @rocket.chat/models@2.1.0-rc.0 + - @rocket.chat/message-parser@0.31.34-rc.0 + - @rocket.chat/core-services@0.13.0-rc.0 + - @rocket.chat/i18n@2.1.0-rc.0 + - @rocket.chat/rest-typings@8.2.0-rc.0 + - @rocket.chat/http-router@7.9.18-rc.0 + - @rocket.chat/ui-voip@18.0.0-rc.0 + - @rocket.chat/server-fetch@0.1.0-rc.0 + - @rocket.chat/federation-matrix@0.0.13-rc.0 + - @rocket.chat/presence@0.2.51-rc.0 + - @rocket.chat/apps@0.6.4-rc.0 + - @rocket.chat/fuselage-ui-kit@28.0.0-rc.0 + - @rocket.chat/omnichannel-services@0.3.48-rc.0 + - @rocket.chat/abac@0.1.4-rc.0 + - @rocket.chat/license@1.1.11-rc.0 + - @rocket.chat/media-calls@0.2.4-rc.0 + - @rocket.chat/pdf-worker@0.3.30-rc.0 + - @rocket.chat/api-client@0.2.51-rc.0 + - @rocket.chat/cron@0.1.51-rc.0 + - @rocket.chat/gazzodown@28.0.0-rc.0 + - @rocket.chat/message-types@0.1.0 + - @rocket.chat/ui-avatar@24.0.0-rc.0 + - @rocket.chat/ui-client@28.0.0-rc.0 + - @rocket.chat/ui-contexts@28.0.0-rc.0 + - @rocket.chat/web-ui-registration@28.0.0-rc.0 + - @rocket.chat/omni-core-ee@0.0.16-rc.0 + - @rocket.chat/instance-status@0.1.51-rc.0 + - @rocket.chat/omni-core@0.0.16-rc.0 + - @rocket.chat/network-broker@0.2.30-rc.0 + - @rocket.chat/server-cloud-communication@0.0.2 + - @rocket.chat/ui-theming@0.4.4 + - @rocket.chat/ui-video-conf@28.0.0-rc.0 +
+ ## 8.1.1 ### Patch Changes diff --git a/apps/meteor/app/2fa/server/twoFactorRequired.ts b/apps/meteor/app/2fa/server/twoFactorRequired.ts index a3f77add66af3..c89c21ff47b31 100644 --- a/apps/meteor/app/2fa/server/twoFactorRequired.ts +++ b/apps/meteor/app/2fa/server/twoFactorRequired.ts @@ -3,11 +3,24 @@ import { Meteor } from 'meteor/meteor'; import type { ITwoFactorOptions } from './code/index'; import { checkCodeForUser } from './code/index'; -export function twoFactorRequired any>( - fn: TFunction, +export type AuthenticatedContext = { + userId: string; + token: string; + connection: { + id: string; + clientAddress: string; + httpHeaders: Record; + }; + twoFactorChecked?: boolean; +}; + +export const twoFactorRequired = Promise>( + fn: ThisParameterType extends AuthenticatedContext + ? TFunction + : (this: AuthenticatedContext, ...args: Parameters) => ReturnType, options?: ITwoFactorOptions, -): (this: Meteor.MethodThisType, ...args: Parameters) => Promise> { - return async function (this: Meteor.MethodThisType, ...args: Parameters): Promise> { +) => + async function (this, ...args) { if (!this.userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'twoFactorRequired' }); } @@ -35,5 +48,4 @@ export function twoFactorRequired, ...args: Parameters) => ReturnType; diff --git a/apps/meteor/app/api/server/ApiClass.ts b/apps/meteor/app/api/server/ApiClass.ts index 3da37938ed43c..cf7cf77ac8c1f 100644 --- a/apps/meteor/app/api/server/ApiClass.ts +++ b/apps/meteor/app/api/server/ApiClass.ts @@ -496,18 +496,16 @@ export class APIClass { + }): Promise { if (options && (!('twoFactorRequired' in options) || !options.twoFactorRequired)) { - return; + return false; } const code = request.headers.get('x-2fa-code') ? String(request.headers.get('x-2fa-code')) : undefined; const method = request.headers.get('x-2fa-method') ? String(request.headers.get('x-2fa-method')) : undefined; @@ -520,7 +518,7 @@ export class APIClass(method: MinimalRoute['method'], subpath: TSubPathPattern, options: TOptions): void { const path = `/${this.apiPath}/${subpath}`.replaceAll('//', '/') as TPathPattern; this.typedRoutes = this.typedRoutes || {}; - this.typedRoutes[path] = this.typedRoutes[subpath] || {}; + this.typedRoutes[path] = this.typedRoutes[path] || {}; const { query, authRequired, response, body, tags, ...rest } = options; this.typedRoutes[path][method.toLowerCase()] = { ...(response && { @@ -902,30 +900,28 @@ export class APIClass, options: _options, connection: connection as unknown as IMethodConnection, - })); + })) + ) { + this.twoFactorChecked = true; + } this.parseJsonQuery = () => api.parseJsonQuery(this); - result = (await DDP._CurrentInvocation.withValue(invocation as any, async () => originalAction.apply(this))) || api.success(); + if (options.applyMeteorContext) { + const invocation = APIClass.createMeteorInvocation(connection, this.userId, this.token); + result = await invocation + .applyInvocation(() => originalAction.apply(this)) + .finally(() => invocation[Symbol.asyncDispose]()); + } else { + result = await originalAction.apply(this); + } } catch (e: any) { result = ((e: any) => { switch (e.error) { @@ -1209,4 +1205,38 @@ export class APIClass void; + clientAddress: string; + httpHeaders: Record; + }, + userId?: string, + token?: string, + ) { + const invocation = new DDPCommon.MethodInvocation({ + connection, + isSimulation: false, + userId, + }); + + Accounts._accountData[connection.id] = { + connection, + }; + if (token) { + Accounts._setAccountData(connection.id, 'loginToken', token); + } + + return { + invocation, + applyInvocation: Promise>(action: F): ReturnType => { + return DDP._CurrentInvocation.withValue(invocation as any, async () => action()) as ReturnType; + }, + [Symbol.asyncDispose]() { + return Promise.resolve(); + }, + }; + } } diff --git a/apps/meteor/app/api/server/definition.ts b/apps/meteor/app/api/server/definition.ts index f8deb68d55261..3268b2d96aabd 100644 --- a/apps/meteor/app/api/server/definition.ts +++ b/apps/meteor/app/api/server/definition.ts @@ -150,6 +150,7 @@ export type SharedOptions = ( version: DeprecationLoggerNextPlannedVersion; alternatives?: PathPattern[]; }; + applyMeteorContext?: boolean; }; export type GenericRouteExecutionContext = ActionThis; @@ -191,6 +192,8 @@ export type ActionThis; /** @@ -292,6 +295,7 @@ export type TypedOptions = { } & SharedOptions<'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'>; export type TypedThis = { + readonly logger: Logger; userId: TOptions['authRequired'] extends true ? string : string | undefined; user: TOptions['authRequired'] extends true ? IUser : IUser | null; token: TOptions['authRequired'] extends true ? string : string | undefined; diff --git a/apps/meteor/app/api/server/index.ts b/apps/meteor/app/api/server/index.ts index 77114be0b196e..59986d6e2da87 100644 --- a/apps/meteor/app/api/server/index.ts +++ b/apps/meteor/app/api/server/index.ts @@ -44,6 +44,7 @@ import './v1/email-inbox'; import './v1/mailer'; import './v1/teams'; import './v1/moderation'; +import './v1/uploads'; // This has to come last so all endpoints are registered before generating the OpenAPI documentation import './default/openApi'; diff --git a/apps/meteor/app/api/server/lib/eraseTeam.spec.ts b/apps/meteor/app/api/server/lib/eraseTeam.spec.ts index 8e5da6b4b59c8..de28fe2c0efa1 100644 --- a/apps/meteor/app/api/server/lib/eraseTeam.spec.ts +++ b/apps/meteor/app/api/server/lib/eraseTeam.spec.ts @@ -126,7 +126,7 @@ describe('eraseTeam (TypeScript) module', () => { await subject.eraseTeam(user, team, []); - sinon.assert.calledWith(eraseRoomStub, team.roomId, 'u1'); + sinon.assert.calledWith(eraseRoomStub, team.roomId, user); }); }); diff --git a/apps/meteor/app/api/server/lib/eraseTeam.ts b/apps/meteor/app/api/server/lib/eraseTeam.ts index 5fd47f782539a..e8c9a3a6b294c 100644 --- a/apps/meteor/app/api/server/lib/eraseTeam.ts +++ b/apps/meteor/app/api/server/lib/eraseTeam.ts @@ -1,19 +1,19 @@ import { AppEvents, Apps } from '@rocket.chat/apps'; import { MeteorError, Team } from '@rocket.chat/core-services'; -import type { AtLeast, IRoom, ITeam, IUser } from '@rocket.chat/core-typings'; +import type { IRoom, ITeam, IUser, AtLeast } from '@rocket.chat/core-typings'; import { Rooms } from '@rocket.chat/models'; import { eraseRoom } from '../../../../server/lib/eraseRoom'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { deleteRoom } from '../../../lib/server/functions/deleteRoom'; -type eraseRoomFnType = (rid: string, user: AtLeast) => Promise; +type EraseRoomFnType = >(rid: string, user: T) => Promise; -export const eraseTeamShared = async ( - user: AtLeast, +export const eraseTeamShared = async >( + user: T, team: ITeam, roomsToRemove: IRoom['_id'][] = [], - eraseRoomFn: eraseRoomFnType, + eraseRoomFn: EraseRoomFnType, ) => { const rooms: string[] = roomsToRemove.length ? (await Team.getMatchingTeamRooms(team._id, roomsToRemove)).filter((roomId) => roomId !== team.roomId) @@ -41,9 +41,9 @@ export const eraseTeamShared = async ( await Team.deleteById(team._id); }; -export const eraseTeam = async (user: AtLeast, team: ITeam, roomsToRemove: IRoom['_id'][]) => { +export const eraseTeam = async (user: IUser, team: ITeam, roomsToRemove: IRoom['_id'][]) => { await eraseTeamShared(user, team, roomsToRemove, async (rid, user) => { - return eraseRoom(rid, user._id); + return eraseRoom(rid, user); }); }; @@ -54,7 +54,7 @@ export const eraseTeam = async (user: AtLeast => { const deletedRooms = new Set(); - await eraseTeamShared({ _id: 'rocket.cat', username: 'rocket.cat', name: 'Rocket.Cat' }, team, roomsToRemove, async (rid) => { + await eraseTeamShared({ _id: 'rocket.cat', username: 'rocket.cat', name: 'Rocket.Cat' } as IUser, team, roomsToRemove, async (rid) => { const isDeleted = await eraseRoomLooseValidation(rid); if (isDeleted) { deletedRooms.add(rid); @@ -83,8 +83,8 @@ export async function eraseRoomLooseValidation(rid: string): Promise { try { await deleteRoom(rid); - } catch (e) { - SystemLogger.error(e); + } catch (err) { + SystemLogger.error({ err }); return false; } diff --git a/apps/meteor/app/api/server/lib/rooms.ts b/apps/meteor/app/api/server/lib/rooms.ts index 3f1353be8a6c2..14b43c1e83d2d 100644 --- a/apps/meteor/app/api/server/lib/rooms.ts +++ b/apps/meteor/app/api/server/lib/rooms.ts @@ -67,6 +67,7 @@ export async function findChannelAndPrivateAutocomplete({ uid, selector }: { uid name: 1, t: 1, avatarETag: 1, + encrypted: 1, }, limit: 10, sort: { diff --git a/apps/meteor/app/api/server/middlewares/logger.ts b/apps/meteor/app/api/server/middlewares/logger.ts index 1188556d0a263..6c56de6cfceb0 100644 --- a/apps/meteor/app/api/server/middlewares/logger.ts +++ b/apps/meteor/app/api/server/middlewares/logger.ts @@ -8,29 +8,24 @@ export const loggerMiddleware = async (c, next) => { const startTime = Date.now(); - let payload = {}; - - // We don't want to consume the request body stream for multipart requests - if (!c.req.header('content-type')?.includes('multipart/form-data')) { - try { - payload = await c.req.raw.clone().json(); - // eslint-disable-next-line no-empty - } catch {} - } else { - payload = '[multipart/form-data]'; - } - - const log = logger.logger.child({ - method: c.req.method, - url: c.req.url, - userId: c.req.header('x-user-id'), - userAgent: c.req.header('user-agent'), - length: c.req.header('content-length'), - host: c.req.header('host'), - referer: c.req.header('referer'), - remoteIP: c.get('remoteAddress'), - ...(['POST', 'PUT', 'PATCH', 'DELETE'].includes(c.req.method) && getRestPayload(payload)), - }); + const log = logger.logger.child( + { + method: c.req.method, + url: c.req.url, + userId: c.req.header('x-user-id'), + userAgent: c.req.header('user-agent'), + length: c.req.header('content-length'), + host: c.req.header('host'), + referer: c.req.header('referer'), + remoteIP: c.get('remoteAddress'), + ...(['POST', 'PUT', 'PATCH', 'DELETE'].includes(c.req.method) && (await getRestPayload(c.req))), + }, + { + redact: [ + 'payload.password', // Potentially logged by v1/login + ], + }, + ); await next(); diff --git a/apps/meteor/app/api/server/router.ts b/apps/meteor/app/api/server/router.ts index 0fba18a206093..41ca09d4f1a32 100644 --- a/apps/meteor/app/api/server/router.ts +++ b/apps/meteor/app/api/server/router.ts @@ -10,15 +10,17 @@ type HonoContext = Context<{ Bindings: { incoming: IncomingMessage }; Variables: { 'remoteAddress': string; - 'bodyParams-override'?: Record; + 'bodyParams': Record; + 'bodyParams-override': Record | undefined; + 'queryParams': Record; }; }>; export type APIActionContext = { requestIp: string; urlParams: Record; - queryParams: Record; - bodyParams: Record; + queryParams: Record; + bodyParams: Record; request: Request; path: string; response: any; @@ -37,19 +39,14 @@ export class RocketChatAPIRouter< protected override convertActionToHandler(action: APIActionHandler): (c: HonoContext) => Promise> { return async (c: HonoContext): Promise> => { const { req, res } = c; - const queryParams = this.parseQueryParams(req); - const bodyParams = await this.parseBodyParams<{ bodyParamsOverride: Record }>({ - request: req, - extra: { bodyParamsOverride: c.var['bodyParams-override'] || {} }, - }); const request = req.raw.clone(); const context: APIActionContext = { requestIp: c.get('remoteAddress'), urlParams: req.param(), - queryParams, - bodyParams, + queryParams: c.get('queryParams'), + bodyParams: c.get('bodyParams-override') || c.get('bodyParams'), request, path: req.path, response: res, diff --git a/apps/meteor/app/api/server/v1/channels.ts b/apps/meteor/app/api/server/v1/channels.ts index 0f654e8822d4a..0aa24f5097b04 100644 --- a/apps/meteor/app/api/server/v1/channels.ts +++ b/apps/meteor/app/api/server/v1/channels.ts @@ -493,7 +493,7 @@ API.v1.addRoute( checkedArchived: false, }); - await eraseRoom(room._id, this.userId); + await eraseRoom(room._id, this.user); return API.v1.success(); }, diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index f289960f4f411..f5a9250fe29b6 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -804,7 +804,7 @@ API.v1.addRoute( throw new Meteor.Error('The required "mid" body param is missing.'); } - await followMessage(this.userId, { mid }); + await followMessage(this.user, { mid }); return API.v1.success(); }, @@ -822,7 +822,7 @@ API.v1.addRoute( throw new Meteor.Error('The required "mid" body param is missing.'); } - await unfollowMessage(this.userId, { mid }); + await unfollowMessage(this.user, { mid }); return API.v1.success(); }, diff --git a/apps/meteor/app/api/server/v1/custom-sounds.ts b/apps/meteor/app/api/server/v1/custom-sounds.ts index 7a5c3f8e856fc..d41362641f75b 100644 --- a/apps/meteor/app/api/server/v1/custom-sounds.ts +++ b/apps/meteor/app/api/server/v1/custom-sounds.ts @@ -1,7 +1,12 @@ import type { ICustomSound } from '@rocket.chat/core-typings'; import { CustomSounds } from '@rocket.chat/models'; import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings'; -import { ajv } from '@rocket.chat/rest-typings'; +import { + ajv, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { ExtractRoutesFromAPI } from '../ApiClass'; @@ -44,6 +49,9 @@ const customSoundsEndpoints = API.v1.get( 'custom-sounds.list', { response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, 200: ajv.compile< PaginatedResult<{ sounds: ICustomSound[]; diff --git a/apps/meteor/app/api/server/v1/emoji-custom.ts b/apps/meteor/app/api/server/v1/emoji-custom.ts index fa84d3cc6b995..15764b7f74e7d 100644 --- a/apps/meteor/app/api/server/v1/emoji-custom.ts +++ b/apps/meteor/app/api/server/v1/emoji-custom.ts @@ -135,8 +135,8 @@ API.v1.addRoute( }); await uploadEmojiCustomWithBuffer(this.userId, fileBuffer, mimetype, emojiData); - } catch (e) { - SystemLogger.error(e); + } catch (err) { + SystemLogger.error({ err }); return API.v1.failure(); } diff --git a/apps/meteor/app/api/server/v1/groups.ts b/apps/meteor/app/api/server/v1/groups.ts index a87fd5cc7cd4f..3fbe9c967a8e9 100644 --- a/apps/meteor/app/api/server/v1/groups.ts +++ b/apps/meteor/app/api/server/v1/groups.ts @@ -380,7 +380,7 @@ API.v1.addRoute( checkedArchived: false, }); - await eraseRoom(findResult.rid, this.userId); + await eraseRoom(findResult.rid, this.user); return API.v1.success(); }, diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts index 2e13de8023259..9972de8ce10cb 100644 --- a/apps/meteor/app/api/server/v1/im.ts +++ b/apps/meteor/app/api/server/v1/im.ts @@ -44,7 +44,7 @@ const findDirectMessageRoom = async ( throw new Meteor.Error('error-room-param-not-provided', 'Query param "roomId" or "username" is required'); } - const user = await Users.findOneById(uid, { projection: { username: 1 } }); + const user = await Users.findOneById(uid); if (!user) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'findDirectMessageRoom', @@ -155,7 +155,7 @@ const dmDeleteAction = (_path: Path): TypedAction { if (settingId === 'uniqueID') { - return auditSettingOperation(Settings.resetValueById, 'uniqueID', process.env.DEPLOYMENT_ID || uuidv4()); + return auditSettingOperation(Settings.resetValueById, 'uniqueID', process.env.DEPLOYMENT_ID || crypto.randomUUID()); } if (settingId === 'Cloud_Workspace_Access_Token_Expires_At') { diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index b89be1304b7c5..686f76a7476c6 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -148,7 +148,7 @@ API.v1.addRoute( }); } - await eraseRoom(room, this.userId); + await eraseRoom(room, this.user); return API.v1.success(); }, @@ -271,7 +271,7 @@ API.v1.addRoute( delete this.bodyParams.description; await applyAirGappedRestrictionsValidation(() => - sendFileMessage(this.userId, { roomId: this.urlParams.rid, file, msgData: this.bodyParams }, { parseAttachmentsForE2EE: false }), + sendFileMessage(this.userId, { roomId: this.urlParams.rid, file, msgData: this.bodyParams }), ); await Uploads.confirmTemporaryFile(this.urlParams.fileId, this.userId); diff --git a/apps/meteor/app/api/server/v1/teams.ts b/apps/meteor/app/api/server/v1/teams.ts index e106a4e4ae707..8c741e7dd9b13 100644 --- a/apps/meteor/app/api/server/v1/teams.ts +++ b/apps/meteor/app/api/server/v1/teams.ts @@ -133,7 +133,7 @@ API.v1.addRoute( if (rooms.length) { for await (const room of rooms) { - await eraseRoom(room, this.userId); + await eraseRoom(room, this.user); } } diff --git a/apps/meteor/app/api/server/v1/uploads.ts b/apps/meteor/app/api/server/v1/uploads.ts new file mode 100644 index 0000000000000..c31beb06dfc29 --- /dev/null +++ b/apps/meteor/app/api/server/v1/uploads.ts @@ -0,0 +1,100 @@ +import { Upload } from '@rocket.chat/core-services'; +import type { IUpload } from '@rocket.chat/core-typings'; +import { Messages, Uploads, Users } from '@rocket.chat/models'; +import { + ajv, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateNotFoundErrorResponse, +} from '@rocket.chat/rest-typings'; + +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; + +type UploadsDeleteResult = { + /** + * The list of files that were successfully removed; May include additional files such as image thumbnails + * */ + deletedFiles: IUpload['_id'][]; +}; + +type UploadsDeleteParams = { + fileId: string; +}; + +const uploadsDeleteParamsSchema = { + type: 'object', + properties: { + fileId: { + type: 'string', + }, + }, + required: ['fileId'], + additionalProperties: false, +}; + +export const isUploadsDeleteParams = ajv.compile(uploadsDeleteParamsSchema); + +const uploadsDeleteEndpoint = API.v1.post( + 'uploads.delete', + { + authRequired: true, + body: isUploadsDeleteParams, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + }, + deletedFiles: { + description: 'The list of files that were successfully removed. May include additional files such as image thumbnails', + type: 'array', + items: { + type: 'string', + }, + }, + }, + required: ['deletedFiles'], + additionalProperties: false, + }), + }, + }, + async function action() { + const { fileId } = this.bodyParams; + + const file = await Uploads.findOneById(fileId); + if (!file?.userId || !file.rid) { + return API.v1.notFound(); + } + + const msg = await Messages.getMessageByFileId(fileId); + + const user = await Users.findOneById(this.userId); + // Safeguard, can't really happen + if (!user) { + return API.v1.forbidden('forbidden'); + } + + if (!(await Upload.canDeleteFile(user, file, msg))) { + return API.v1.forbidden('forbidden'); + } + + const { deletedFiles } = await Upload.deleteFile(user, fileId, msg); + return API.v1.success({ + deletedFiles, + }); + }, +); + +type UploadsEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends UploadsEndpoints {} +} diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index c4b57449bc560..88b6b67e3b442 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -31,7 +31,6 @@ import { regeneratePersonalAccessTokenOfUser } from '../../../../imports/persona import { removePersonalAccessTokenOfUser } from '../../../../imports/personal-access-tokens/server/api/methods/removeToken'; import { UserChangedAuditStore } from '../../../../server/lib/auditServerEvents/userChanged'; import { i18n } from '../../../../server/lib/i18n'; -import { removeOtherTokens } from '../../../../server/lib/removeOtherTokens'; import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKey'; import { registerUser } from '../../../../server/methods/registerUser'; import { requestDataDownload } from '../../../../server/methods/requestDataDownload'; @@ -154,7 +153,14 @@ API.v1.addRoute( API.v1.addRoute( 'users.updateOwnBasicInfo', - { authRequired: true, validateParams: isUsersUpdateOwnBasicInfoParamsPOST }, + { + authRequired: true, + validateParams: isUsersUpdateOwnBasicInfoParamsPOST, + rateLimiterOptions: { + numRequestsAllowed: 1, + intervalTimeInMS: 60000, + }, + }, { async post() { const userData = { @@ -181,13 +187,7 @@ API.v1.addRoute( twoFactorMethod: 'password', }; - await executeSaveUserProfile.call( - this as unknown as Meteor.MethodThisType, - this.user, - userData, - this.bodyParams.customFields, - twoFactorOptions, - ); + await executeSaveUserProfile.call(this, this.user, userData, this.bodyParams.customFields, twoFactorOptions); return API.v1.success({ user: await getUserInfo((await Users.findOneById(this.userId, { projection: API.v1.defaultFieldsToExclude })) as IUser, false), @@ -1073,6 +1073,10 @@ API.v1.addRoute( { authRequired: true, validateParams: isUsersSendConfirmationEmailParamsPOST, + rateLimiterOptions: { + numRequestsAllowed: 1, + intervalTimeInMS: 60000, + }, }, { async post() { @@ -1233,7 +1237,7 @@ API.v1.addRoute( { authRequired: true }, { async post() { - return API.v1.success(await removeOtherTokens(this.userId, this.connection.id)); + return API.v1.success(await Users.removeNonLoginTokensExcept(this.userId, this.token)); }, }, ); @@ -1375,7 +1379,13 @@ API.v1.addRoute( API.v1.addRoute( 'users.setStatus', - { authRequired: true }, + { + authRequired: true, + rateLimiterOptions: { + numRequestsAllowed: 5, + intervalTimeInMS: 60000, + }, + }, { async post() { check( @@ -1398,9 +1408,7 @@ API.v1.addRoute( }); } - const user = await (async (): Promise< - Pick | undefined | null - > => { + const user = await (async () => { if (isUserFromParams(this.bodyParams, this.userId, this.user)) { return Users.findOneById(this.userId); } @@ -1417,7 +1425,7 @@ API.v1.addRoute( let { statusText, status } = user; if (this.bodyParams.message || this.bodyParams.message === '') { - await setStatusText(user._id, this.bodyParams.message, { emit: false }); + await setStatusText(user, this.bodyParams.message, { emit: false }); statusText = this.bodyParams.message; } diff --git a/apps/meteor/app/api/server/v1/videoConference.ts b/apps/meteor/app/api/server/v1/videoConference.ts index cf0a3a58b53ad..5036eed09cc2d 100644 --- a/apps/meteor/app/api/server/v1/videoConference.ts +++ b/apps/meteor/app/api/server/v1/videoConference.ts @@ -11,6 +11,7 @@ import { import { availabilityErrors } from '../../../../lib/videoConference/constants'; import { videoConfProviders } from '../../../../server/lib/videoConfProviders'; import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; +import { canSendMessageAsync } from '../../../authorization/server/functions/canSendMessage'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; @@ -22,14 +23,17 @@ API.v1.addRoute( async post() { const { roomId, title, allowRinging: requestRinging } = this.bodyParams; const { userId } = this; - if (!userId || !(await canAccessRoomIdAsync(roomId, userId))) { - return API.v1.failure('invalid-params'); - } if (!(await hasPermissionAsync(userId, 'call-management', roomId))) { return API.v1.forbidden(); } + try { + await canSendMessageAsync(roomId, { uid: userId, username: this.user.username!, type: this.user.type! }); + } catch (error) { + return API.v1.forbidden(); + } + try { const providerName = videoConfProviders.getActiveProvider(); diff --git a/apps/meteor/app/apple/lib/handleIdentityToken.ts b/apps/meteor/app/apple/lib/handleIdentityToken.ts index 056777eb11362..f3ab1c8f9e66f 100644 --- a/apps/meteor/app/apple/lib/handleIdentityToken.ts +++ b/apps/meteor/app/apple/lib/handleIdentityToken.ts @@ -3,7 +3,11 @@ import { KJUR } from 'jsrsasign'; import NodeRSA from 'node-rsa'; async function isValidAppleJWT(identityToken: string, header: any): Promise { - const request = await fetch('https://appleid.apple.com/auth/keys', { method: 'GET' }); + const request = await fetch('https://appleid.apple.com/auth/keys', { + method: 'GET', + // SECURITY: Hardcoded URL, no SSRF protection needed + ignoreSsrfValidation: true, + }); const applePublicKeys = ((await request.json()) as { keys: { kid: string; e: string; n: string }[] }).keys; const { kid } = header; diff --git a/apps/meteor/app/apps/server/bridges/http.ts b/apps/meteor/app/apps/server/bridges/http.ts index 9d62769336a25..aab0d56d301f0 100644 --- a/apps/meteor/app/apps/server/bridges/http.ts +++ b/apps/meteor/app/apps/server/bridges/http.ts @@ -2,7 +2,9 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; import type { IHttpResponse } from '@rocket.chat/apps-engine/definition/accessors'; import type { IHttpBridgeRequestInfo } from '@rocket.chat/apps-engine/server/bridges'; import { HttpBridge } from '@rocket.chat/apps-engine/server/bridges/HttpBridge'; -import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { serverFetch as fetch, type ExtendedFetchOptions } from '@rocket.chat/server-fetch'; + +import { settings } from '../../../settings/server'; const isGetOrHead = (method: string): boolean => ['GET', 'HEAD'].includes(method.toUpperCase()); @@ -72,14 +74,20 @@ export class AppHttpBridge extends HttpBridge { this.orch.debugLog(`The App ${info.appId} is requesting from the outter webs:`, info); + const shouldIgnoreSsrf = request.ssrfValidation !== true; + const fetchOptions: ExtendedFetchOptions = { + method, + body: content, + headers, + timeout, + ...(shouldIgnoreSsrf + ? { ignoreSsrfValidation: true } + : { ignoreSsrfValidation: false, allowList: settings.get('SSRF_Allowlist') }), + }; + const response = await fetch( url.href, - { - method, - body: content, - headers, - timeout, - }, + fetchOptions, (request.hasOwnProperty('strictSSL') && !request.strictSSL) || (request.hasOwnProperty('rejectUnauthorized') && request.rejectUnauthorized), ); diff --git a/apps/meteor/app/apps/server/bridges/oauthApps.ts b/apps/meteor/app/apps/server/bridges/oauthApps.ts index ba8ed81246904..bfd72917a367c 100644 --- a/apps/meteor/app/apps/server/bridges/oauthApps.ts +++ b/apps/meteor/app/apps/server/bridges/oauthApps.ts @@ -1,10 +1,11 @@ +import { randomUUID } from 'crypto'; + import type { IAppServerOrchestrator } from '@rocket.chat/apps'; import type { IOAuthApp, IOAuthAppParams } from '@rocket.chat/apps-engine/definition/accessors/IOAuthApp'; import { OAuthAppsBridge } from '@rocket.chat/apps-engine/server/bridges/OAuthAppsBridge'; import type { IOAuthApps } from '@rocket.chat/core-typings'; import { OAuthApps, Users } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; -import { v4 as uuidv4 } from 'uuid'; export class AppOAuthAppsBridge extends OAuthAppsBridge { constructor(private readonly orch: IAppServerOrchestrator) { @@ -25,7 +26,7 @@ export class AppOAuthAppsBridge extends OAuthAppsBridge { return ( await OAuthApps.insertOne({ ...oAuthApp, - _id: uuidv4(), + _id: randomUUID(), appId, clientId: clientId ?? Random.id(), clientSecret: clientSecret ?? Random.secret(), diff --git a/apps/meteor/app/apps/server/bridges/scheduler.ts b/apps/meteor/app/apps/server/bridges/scheduler.ts index 6fdd3d69f9531..b08d49182c9bc 100644 --- a/apps/meteor/app/apps/server/bridges/scheduler.ts +++ b/apps/meteor/app/apps/server/bridges/scheduler.ts @@ -84,9 +84,7 @@ export class AppSchedulerBridge extends SchedulerBridge { ); break; default: - this.orch - .getRocketChatLogger() - .error(`Invalid startup setting type (${String((startupSetting as any).type)}) for the processor ${id}`); + this.orch.getRocketChatLogger().error({ msg: 'Unknown startup setting type', type: (startupSetting as any).type }); break; } }); @@ -105,8 +103,8 @@ export class AppSchedulerBridge extends SchedulerBridge { await this.startScheduler(); const job = await this.scheduler.schedule(when, id, this.decorateJobData(data, appId)); return job.attrs._id.toString(); - } catch (e) { - this.orch.getRocketChatLogger().error(e); + } catch (err) { + this.orch.getRocketChatLogger().error({ err }); } } @@ -140,8 +138,8 @@ export class AppSchedulerBridge extends SchedulerBridge { skipImmediate, }); return job.attrs._id.toString(); - } catch (e) { - this.orch.getRocketChatLogger().error(e); + } catch (err) { + this.orch.getRocketChatLogger().error({ err }); } } @@ -167,8 +165,8 @@ export class AppSchedulerBridge extends SchedulerBridge { try { await this.scheduler.cancel(cancelQuery); - } catch (e) { - this.orch.getRocketChatLogger().error(e); + } catch (err) { + this.orch.getRocketChatLogger().error({ err }); } } @@ -185,8 +183,8 @@ export class AppSchedulerBridge extends SchedulerBridge { const matcher = new RegExp(`_${appId}$`); try { await this.scheduler.cancel({ name: { $regex: matcher } }); - } catch (e) { - this.orch.getRocketChatLogger().error(e); + } catch (err) { + this.orch.getRocketChatLogger().error({ err }); } } diff --git a/apps/meteor/app/apps/server/converters/convertMessageFiles.ts b/apps/meteor/app/apps/server/converters/convertMessageFiles.ts new file mode 100644 index 0000000000000..d62ecd6c62ce2 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/convertMessageFiles.ts @@ -0,0 +1,23 @@ +import type { IMessage as AppsEngineMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IMessage } from '@rocket.chat/core-typings'; + +export async function convertMessageFiles( + files: IMessage['files'], + attachments: IMessage['attachments'], +): Promise { + return files?.map((file) => { + if (!file || file.typeGroup) { + return file; + } + + // Thumbnails from older messages did not have any identification but we can extrapolate this information from other data + if (files.length === 2 && attachments?.length === 1 && file === files[1]) { + return { + ...file, + typeGroup: 'thumb', + }; + } + + return file; + }); +} diff --git a/apps/meteor/app/apps/server/converters/messages.js b/apps/meteor/app/apps/server/converters/messages.js index a824df3228396..332ab585d32e7 100644 --- a/apps/meteor/app/apps/server/converters/messages.js +++ b/apps/meteor/app/apps/server/converters/messages.js @@ -4,6 +4,7 @@ import { Random } from '@rocket.chat/random'; import { removeEmpty } from '@rocket.chat/tools'; import { cachedFunction } from './cachedFunction'; +import { convertMessageFiles } from './convertMessageFiles'; import { transformMappedData } from './transformMappedData'; export class AppMessagesConverter { @@ -25,7 +26,7 @@ export class AppMessagesConverter { } const { attachments, ...message } = msgObj; - const getAttachments = async () => this._convertAttachmentsToApp(attachments); + const getAttachments = async () => this._convertAttachmentsToApp(attachments, msgObj.file); const map = { id: '_id', @@ -40,6 +41,7 @@ export class AppMessagesConverter { avatarUrl: 'avatar', alias: 'alias', file: 'file', + files: 'files', customFields: 'customFields', groupable: 'groupable', token: 'token', @@ -76,6 +78,8 @@ export class AppMessagesConverter { this.mem.set(cacheObj, cache); + const { attachments, file: mainFile } = msgObj; + const map = { id: '_id', threadId: 'tmid', @@ -94,6 +98,7 @@ export class AppMessagesConverter { token: 'token', blocks: 'blocks', type: 't', + files: async (message) => convertMessageFiles(message.files, attachments), room: async (message) => { const result = await cache.get('room')(message.rid); delete message.rid; @@ -110,7 +115,7 @@ export class AppMessagesConverter { return cache.get('user.convertById')(editedBy._id); }, attachments: async (message) => { - const result = await this._convertAttachmentsToApp(message.attachments); + const result = await this._convertAttachmentsToApp(message.attachments, mainFile); delete message.attachments; return result; }, @@ -271,7 +276,7 @@ export class AppMessagesConverter { ); } - async _convertAttachmentsToApp(attachments) { + async _convertAttachmentsToApp(attachments, mainFile) { if (typeof attachments === 'undefined' || !Array.isArray(attachments)) { return undefined; } @@ -321,6 +326,14 @@ export class AppMessagesConverter { delete attachment.ts; return result; }, + fileId: (attachment) => { + // If the attachment is missing the fileId, but there's only one file in the message, use that file's ID + if (!attachment.fileId && attachment.type === 'file' && mainFile?._id && attachments.length === 1) { + return mainFile._id; + } + + return attachment.fileId; + }, }; return Promise.all(attachments.map(async (attachment) => transformMappedData(attachment, map))); diff --git a/apps/meteor/app/apps/server/converters/threads.ts b/apps/meteor/app/apps/server/converters/threads.ts index d6284688b984a..376488f3ec3ca 100644 --- a/apps/meteor/app/apps/server/converters/threads.ts +++ b/apps/meteor/app/apps/server/converters/threads.ts @@ -1,11 +1,12 @@ import type { IAppRoomsConverter, IAppThreadsConverter, IAppUsersConverter, IAppsMessage, IAppsUser } from '@rocket.chat/apps'; import type { IMessage as AppsEngineMessage, IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages'; import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; -import { isEditedMessage } from '@rocket.chat/core-typings'; +import { isEditedMessage, isFileAttachment } from '@rocket.chat/core-typings'; import type { IUser, IMessage } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; import { cachedFunction } from './cachedFunction'; +import { convertMessageFiles } from './convertMessageFiles'; import { transformMappedData } from './transformMappedData'; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -69,6 +70,8 @@ export class AppThreadsConverter implements IAppThreadsConverter { convertUserById: ReturnType['convertById'], convertToApp: ReturnType['convertToApp'], ): Promise { + const { attachments, file: mainFile } = msgObj; + const map = { id: '_id', threadId: 'tmid', @@ -100,7 +103,7 @@ export class AppThreadsConverter implements IAppThreadsConverter { if (!message.attachments) { return undefined; } - const result = await this._convertAttachmentsToApp(message.attachments); + const result = await this._convertAttachmentsToApp(message.attachments, mainFile); delete message.attachments; return result; }, @@ -119,6 +122,7 @@ export class AppThreadsConverter implements IAppThreadsConverter { return user as IAppsUser; }, + files: async (message: IMessage) => convertMessageFiles(message.files, attachments), } as const; // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. @@ -130,7 +134,10 @@ export class AppThreadsConverter implements IAppThreadsConverter { return transformMappedData(msgData, map); } - async _convertAttachmentsToApp(attachments: NonNullable): Promise> { + async _convertAttachmentsToApp( + attachments: NonNullable, + mainFile: IMessage['file'], + ): Promise> { const map = { collapsed: 'collapsed', color: 'color', @@ -180,6 +187,18 @@ export class AppThreadsConverter implements IAppThreadsConverter { delete attachment.ts; return result; }, + fileId: (attachment: NonNullable[number]) => { + if ('fileId' in attachment && attachment.fileId) { + return attachment.fileId; + } + + // If the attachment is missing the fileId, but there's only one file in the message, use that file's ID + if (isFileAttachment(attachment) && mainFile?._id && attachments?.length === 1) { + return mainFile._id; + } + + return undefined; + }, } as const; return Promise.all(attachments.map(async (attachment) => transformMappedData(attachment, map))); diff --git a/apps/meteor/app/authentication/server/lib/logLoginAttempts.ts b/apps/meteor/app/authentication/server/lib/logLoginAttempts.ts index 0f97796aa4a75..3c50937599063 100644 --- a/apps/meteor/app/authentication/server/lib/logLoginAttempts.ts +++ b/apps/meteor/app/authentication/server/lib/logLoginAttempts.ts @@ -26,7 +26,12 @@ export const logFailedLoginAttempts = (login: ILoginAttempt): void => { if (!settings.get('Login_Logs_UserAgent')) { userAgent = '-'; } - SystemLogger.info( - `Failed login detected - Username[${user}] ClientAddress[${clientAddress}] ForwardedFor[${forwardedFor}] XRealIp[${realIp}] UserAgent[${userAgent}]`, - ); + SystemLogger.info({ + msg: 'Failed login detected', + user, + clientAddress, + forwardedFor, + realIp, + userAgent, + }); }; diff --git a/apps/meteor/app/authentication/server/lib/restrictLoginAttempts.ts b/apps/meteor/app/authentication/server/lib/restrictLoginAttempts.ts index 6f260c8a129c0..300a4fa973114 100644 --- a/apps/meteor/app/authentication/server/lib/restrictLoginAttempts.ts +++ b/apps/meteor/app/authentication/server/lib/restrictLoginAttempts.ts @@ -43,7 +43,7 @@ const notifyFailedLogin = async (ipOrUsername: string, blockedUntil: Date, faile ], }; - await sendMessage(rocketCat, message, room, false); + await sendMessage(rocketCat, message, room); }; export const isValidLoginAttemptByIp = async (ip: string): Promise => { diff --git a/apps/meteor/app/authentication/server/startup/index.js b/apps/meteor/app/authentication/server/startup/index.js index 13f757b37bc38..6c23092761b07 100644 --- a/apps/meteor/app/authentication/server/startup/index.js +++ b/apps/meteor/app/authentication/server/startup/index.js @@ -24,7 +24,6 @@ import { notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListen import * as Mailer from '../../../mailer/server/api'; import { settings } from '../../../settings/server'; import { getBaseUserFields } from '../../../utils/server/functions/getBaseUserFields'; -import { safeGetMeteorUser } from '../../../utils/server/functions/safeGetMeteorUser'; import { isValidAttemptByUser, isValidLoginAttemptByIp } from '../lib/restrictLoginAttempts'; Accounts.config({ @@ -380,7 +379,7 @@ Accounts.insertUserDoc = async function (options, user) { if (!options.skipAppsEngineEvent) { // `post` triggered events don't need to wait for the promise to resolve - Apps.self?.triggerEvent(AppEvents.IPostUserCreated, { user, performedBy: await safeGetMeteorUser() }).catch((e) => { + Apps.self?.triggerEvent(AppEvents.IPostUserCreated, { user, performedBy: options.performedBy }).catch((e) => { Apps.self?.getRocketChatLogger().error({ msg: 'Error while executing post user created event', err: e }); }); } diff --git a/apps/meteor/app/authorization/server/functions/canDeleteMessage.ts b/apps/meteor/app/authorization/server/functions/canDeleteMessage.ts index 1651affba0deb..75967624d79c8 100644 --- a/apps/meteor/app/authorization/server/functions/canDeleteMessage.ts +++ b/apps/meteor/app/authorization/server/functions/canDeleteMessage.ts @@ -11,8 +11,8 @@ const elapsedTime = (ts: Date): number => { }; export const canDeleteMessageAsync = async ( - uid: string, - { u, rid, ts }: { u: Pick; rid: string; ts: Date }, + deletingUser: Pick, + { u, rid, ts }: { u: Pick; rid: string; ts?: Date }, ): Promise => { const room = await Rooms.findOneById>(rid, { projection: { @@ -29,11 +29,11 @@ export const canDeleteMessageAsync = async ( return false; } - if (!(await canAccessRoomAsync(room, { _id: uid }))) { + if (!(await canAccessRoomAsync(room, { _id: deletingUser._id }))) { return false; } - const forceDelete = await hasPermissionAsync(uid, 'force-delete-message', rid); + const forceDelete = await hasPermissionAsync(deletingUser._id, 'force-delete-message', rid); if (forceDelete) { return true; @@ -48,13 +48,14 @@ export const canDeleteMessageAsync = async ( return false; } - const allowedToDeleteAny = await hasPermissionAsync(uid, 'delete-message', rid); + const allowedToDeleteAny = await hasPermissionAsync(deletingUser._id, 'delete-message', rid); - const allowed = allowedToDeleteAny || (uid === u._id && (await hasPermissionAsync(uid, 'delete-own-message', rid))); + const allowed = + allowedToDeleteAny || (deletingUser._id === u._id && (await hasPermissionAsync(deletingUser._id, 'delete-own-message', rid))); if (!allowed) { return false; } - const bypassBlockTimeLimit = await hasPermissionAsync(uid, 'bypass-time-limit-edit-and-delete', rid); + const bypassBlockTimeLimit = await hasPermissionAsync(deletingUser._id, 'bypass-time-limit-edit-and-delete', rid); if (!bypassBlockTimeLimit) { const blockDeleteInMinutes = await getValue('Message_AllowDeleting_BlockDeleteInMinutes'); @@ -65,9 +66,9 @@ export const canDeleteMessageAsync = async ( } } - if (room.ro === true && !(await hasPermissionAsync(uid, 'post-readonly', rid))) { + if (room.ro === true && !(await hasPermissionAsync(deletingUser._id, 'post-readonly', rid))) { // Unless the user was manually unmuted - if (u.username && !(room.unmuted || []).includes(u.username)) { + if (deletingUser.username && !(room.unmuted || []).includes(deletingUser.username)) { throw new Error("You can't delete messages because the room is readonly."); } } diff --git a/apps/meteor/app/authorization/server/functions/canSendMessage.ts b/apps/meteor/app/authorization/server/functions/canSendMessage.ts index 97767ee001b00..b9d6b740c2ddd 100644 --- a/apps/meteor/app/authorization/server/functions/canSendMessage.ts +++ b/apps/meteor/app/authorization/server/functions/canSendMessage.ts @@ -22,6 +22,10 @@ export async function validateRoomMessagePermissionsAsync( throw new Error('error-invalid-room'); } + if (room.archived) { + throw new Error('room_is_archived'); + } + if (type !== 'app' && !(await canAccessRoomAsync(room, { _id: uid }, extraData))) { throw new Error('error-not-allowed'); } diff --git a/apps/meteor/app/autotranslate/server/deeplTranslate.ts b/apps/meteor/app/autotranslate/server/deeplTranslate.ts index 8ed1a6876e39f..5976f7a3e48e3 100644 --- a/apps/meteor/app/autotranslate/server/deeplTranslate.ts +++ b/apps/meteor/app/autotranslate/server/deeplTranslate.ts @@ -101,7 +101,9 @@ class DeeplAutoTranslate extends AutoTranslate { } let result: (ISupportedLanguage & { supports_formality?: boolean })[] = []; + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. const request = await fetch(this.supportedLanguageEndpointUrl, { + ignoreSsrfValidation: true, params: { type: 'target' }, headers: { Authorization: `DeepL-Auth-Key ${this.apiKey}`, @@ -140,7 +142,9 @@ class DeeplAutoTranslate extends AutoTranslate { language = language.substr(0, 2); } try { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. const result = await fetch(this.apiEndPointUrl, { + ignoreSsrfValidation: true, params: { target_lang: language, text: msgs }, headers: { Authorization: `DeepL-Auth-Key ${this.apiKey}`, @@ -186,7 +190,9 @@ class DeeplAutoTranslate extends AutoTranslate { language = language.substr(0, 2); } try { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. const result = await fetch(this.apiEndPointUrl, { + ignoreSsrfValidation: true, params: { auth_key: this.apiKey, target_lang: language, diff --git a/apps/meteor/app/autotranslate/server/googleTranslate.ts b/apps/meteor/app/autotranslate/server/googleTranslate.ts index 23955a401c69c..4ffa557406154 100644 --- a/apps/meteor/app/autotranslate/server/googleTranslate.ts +++ b/apps/meteor/app/autotranslate/server/googleTranslate.ts @@ -88,7 +88,11 @@ class GoogleAutoTranslate extends AutoTranslate { }; try { - const request = await fetch(`https://translation.googleapis.com/language/translate/v2/languages`, { params }); + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch(`https://translation.googleapis.com/language/translate/v2/languages`, { + ignoreSsrfValidation: true, + params, + }); if (!request.ok && request.status === 400 && request.statusText === 'INVALID_ARGUMENT') { throw new Error('Failed to fetch supported languages'); } @@ -100,7 +104,11 @@ class GoogleAutoTranslate extends AutoTranslate { params.target = 'en'; target = 'en'; if (!this.supportedLanguages[target]) { - const request = await fetch(`https://translation.googleapis.com/language/translate/v2/languages`, { params }); + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch(`https://translation.googleapis.com/language/translate/v2/languages`, { + ignoreSsrfValidation: true, + params, + }); result = (await request.json()) as typeof result; } } @@ -132,7 +140,9 @@ class GoogleAutoTranslate extends AutoTranslate { } try { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. const result = await fetch(this.apiEndPointUrl, { + ignoreSsrfValidation: true, params: { key: this.apiKey, target: language, @@ -179,7 +189,9 @@ class GoogleAutoTranslate extends AutoTranslate { } try { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. const result = await fetch(this.apiEndPointUrl, { + ignoreSsrfValidation: true, params: { key: this.apiKey, target: language, diff --git a/apps/meteor/app/autotranslate/server/msTranslate.ts b/apps/meteor/app/autotranslate/server/msTranslate.ts index ad36ff0b8b771..ddb345d3c895a 100644 --- a/apps/meteor/app/autotranslate/server/msTranslate.ts +++ b/apps/meteor/app/autotranslate/server/msTranslate.ts @@ -87,7 +87,10 @@ class MsAutoTranslate extends AutoTranslate { if (this.supportedLanguages[target]) { return this.supportedLanguages[target]; } - const request = await fetch(this.apiGetLanguages); + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch(this.apiGetLanguages, { + ignoreSsrfValidation: true, + }); if (!request.ok) { throw new Error(request.statusText); } @@ -121,7 +124,9 @@ class MsAutoTranslate extends AutoTranslate { } return language; }); + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. const request = await fetch(this.apiEndPointUrl, { + ignoreSsrfValidation: true, method: 'POST', headers: { 'Ocp-Apim-Subscription-Key': this.apiKey, diff --git a/apps/meteor/app/cloud/server/functions/connectWorkspace.ts b/apps/meteor/app/cloud/server/functions/connectWorkspace.ts index 9d29fb7eb399e..4b8dc4d9c8a6f 100644 --- a/apps/meteor/app/cloud/server/functions/connectWorkspace.ts +++ b/apps/meteor/app/cloud/server/functions/connectWorkspace.ts @@ -24,6 +24,8 @@ const fetchRegistrationDataPayload = async ({ Authorization: `Bearer ${token}`, }, body, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, }); if (!response.ok) { diff --git a/apps/meteor/app/cloud/server/functions/finishOAuthAuthorization.ts b/apps/meteor/app/cloud/server/functions/finishOAuthAuthorization.ts index 61b3a77966e79..456802cc1eacc 100644 --- a/apps/meteor/app/cloud/server/functions/finishOAuthAuthorization.ts +++ b/apps/meteor/app/cloud/server/functions/finishOAuthAuthorization.ts @@ -33,6 +33,8 @@ export async function finishOAuthAuthorization(code: string, state: string) { code, redirect_uri: getRedirectUri(), }), + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, }); if (!response.ok) { diff --git a/apps/meteor/app/cloud/server/functions/getCheckoutUrl.ts b/apps/meteor/app/cloud/server/functions/getCheckoutUrl.ts index b5deea6fee931..d2d9ac4704a8b 100644 --- a/apps/meteor/app/cloud/server/functions/getCheckoutUrl.ts +++ b/apps/meteor/app/cloud/server/functions/getCheckoutUrl.ts @@ -33,6 +33,8 @@ export const getCheckoutUrl = async (): Promise<{ Authorization: `Bearer ${token}`, }, body, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, }); if (!response.ok) { diff --git a/apps/meteor/app/cloud/server/functions/getConfirmationPoll.ts b/apps/meteor/app/cloud/server/functions/getConfirmationPoll.ts index deda7327a6b96..b3d1769cf379e 100644 --- a/apps/meteor/app/cloud/server/functions/getConfirmationPoll.ts +++ b/apps/meteor/app/cloud/server/functions/getConfirmationPoll.ts @@ -7,7 +7,11 @@ import { settings } from '../../../settings/server'; export async function getConfirmationPoll(deviceCode: string): Promise { try { const cloudUrl = settings.get('Cloud_Url'); - const response = await fetch(`${cloudUrl}/api/v2/register/workspace/poll`, { params: { token: deviceCode } }); + const response = await fetch(`${cloudUrl}/api/v2/register/workspace/poll`, { + params: { token: deviceCode }, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + }); try { if (!response.ok) { diff --git a/apps/meteor/app/cloud/server/functions/getWorkspaceAccessTokenWithScope.ts b/apps/meteor/app/cloud/server/functions/getWorkspaceAccessTokenWithScope.ts index 1137b899967a9..76da8c06b5cfe 100644 --- a/apps/meteor/app/cloud/server/functions/getWorkspaceAccessTokenWithScope.ts +++ b/apps/meteor/app/cloud/server/functions/getWorkspaceAccessTokenWithScope.ts @@ -59,6 +59,8 @@ export async function getWorkspaceAccessTokenWithScope({ headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, method: 'POST', body, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, timeout: 5000, }); diff --git a/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts b/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts index dae5ffe7104c7..a46ab3b6ba88c 100644 --- a/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts +++ b/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts @@ -1,4 +1,4 @@ -import type { Cloud, Serialized } from '@rocket.chat/core-typings'; +import { Cloud } from '@rocket.chat/core-typings'; import { Settings } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import * as z from 'zod'; @@ -11,15 +11,7 @@ import { SystemLogger } from '../../../../server/lib/logger/system'; import { settings } from '../../../settings/server'; import { LICENSE_VERSION } from '../license'; -const workspaceLicensePayloadSchema = z.object({ - version: z.number(), - address: z.string(), - license: z.string(), - updatedAt: z.string().datetime(), - expireAt: z.string().datetime(), -}); - -const fetchCloudWorkspaceLicensePayload = async ({ token }: { token: string }): Promise> => { +const fetchCloudWorkspaceLicensePayload = async ({ token }: { token: string }): Promise => { const workspaceRegistrationClientUri = settings.get('Cloud_Workspace_Registration_Client_Uri'); const response = await fetch(`${workspaceRegistrationClientUri}/license`, { headers: { @@ -28,6 +20,8 @@ const fetchCloudWorkspaceLicensePayload = async ({ token }: { token: string }): params: { version: LICENSE_VERSION, }, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, }); if (!response.ok) { @@ -41,13 +35,15 @@ const fetchCloudWorkspaceLicensePayload = async ({ token }: { token: string }): const payload = await response.json(); - const assertWorkspaceLicensePayload = workspaceLicensePayloadSchema.safeParse(payload); + const result = Cloud.WorkspaceLicensePayloadSchema.safeParse(payload); - if (!assertWorkspaceLicensePayload.success) { - SystemLogger.error({ msg: 'workspaceLicensePayloadSchema failed type validation', errors: assertWorkspaceLicensePayload.error.issues }); + if (!result.success) { + throw new CloudWorkspaceLicenseError('failed type validation', { + cause: z.prettifyError(result.error), + }); } - return payload; + return result.data; }; export async function getWorkspaceLicense() { @@ -66,7 +62,7 @@ export async function getWorkspaceLicense() { const payload = await fetchCloudWorkspaceLicensePayload({ token }); - if (currentLicense.value && Date.parse(payload.updatedAt) <= currentLicense._updatedAt.getTime()) { + if (currentLicense.value && payload.updatedAt.getTime() <= currentLicense._updatedAt.getTime()) { return; } await callbacks.run('workspaceLicenseChanged', payload.license); diff --git a/apps/meteor/app/cloud/server/functions/registerPreIntentWorkspaceWizard.ts b/apps/meteor/app/cloud/server/functions/registerPreIntentWorkspaceWizard.ts index a102ed3590536..caaa52078e1f0 100644 --- a/apps/meteor/app/cloud/server/functions/registerPreIntentWorkspaceWizard.ts +++ b/apps/meteor/app/cloud/server/functions/registerPreIntentWorkspaceWizard.ts @@ -22,6 +22,8 @@ export async function registerPreIntentWorkspaceWizard(): Promise { method: 'POST', body: regInfo, timeout: 3 * 1000, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, }); if (!response.ok) { throw new Error((await response.json()).error); diff --git a/apps/meteor/app/cloud/server/functions/removeLicense.ts b/apps/meteor/app/cloud/server/functions/removeLicense.ts index 31cd23df14558..148b3302691ef 100644 --- a/apps/meteor/app/cloud/server/functions/removeLicense.ts +++ b/apps/meteor/app/cloud/server/functions/removeLicense.ts @@ -26,6 +26,8 @@ export async function removeLicense() { headers: { Authorization: `Bearer ${token}`, }, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, }); if (!response.ok) { diff --git a/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts b/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts index bdd6cedc018d1..a8493ac18a932 100644 --- a/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts +++ b/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts @@ -29,6 +29,8 @@ export async function startRegisterWorkspace(resend = false) { params: { resend, }, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, }); if (!response.ok) { throw new Error((await response.json()).error); diff --git a/apps/meteor/app/cloud/server/functions/startRegisterWorkspaceSetupWizard.ts b/apps/meteor/app/cloud/server/functions/startRegisterWorkspaceSetupWizard.ts index 36b858d932c89..d3135695822f0 100644 --- a/apps/meteor/app/cloud/server/functions/startRegisterWorkspaceSetupWizard.ts +++ b/apps/meteor/app/cloud/server/functions/startRegisterWorkspaceSetupWizard.ts @@ -17,6 +17,8 @@ export async function startRegisterWorkspaceSetupWizard(resend = false, email: s params: { resent: resend, }, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, }); if (!response.ok) { throw new Error((await response.json()).error); diff --git a/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts index 2643b673c5b24..4c788e4d8ea39 100644 --- a/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts +++ b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts @@ -118,6 +118,8 @@ const getSupportedVersionsFromCloud = async () => { fetch(releaseEndpoint, { headers, timeout: 5000, + // SECURITY: the URL is a default hardcoded value or an envvar set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, }), ); diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/announcementSync.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/announcementSync.ts index 68ee4cea83949..e105179576ef5 100644 --- a/apps/meteor/app/cloud/server/functions/syncWorkspace/announcementSync.ts +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/announcementSync.ts @@ -1,4 +1,4 @@ -import { type Cloud, type Serialized } from '@rocket.chat/core-typings'; +import { Cloud } from '@rocket.chat/core-typings'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import * as z from 'zod'; @@ -11,45 +11,13 @@ import { CloudWorkspaceAccessTokenEmptyError, getWorkspaceAccessToken } from '.. import { retrieveRegistrationStatus } from '../retrieveRegistrationStatus'; import { handleAnnouncementsOnWorkspaceSync, handleNpsOnWorkspaceSync } from './handleCommsSync'; -const workspaceCommPayloadSchema = z.object({ - workspaceId: z.string().optional(), - publicKey: z.string().optional(), - nps: z - .object({ - id: z.string(), - startAt: z.string().datetime(), - expireAt: z.string().datetime(), - }) - .optional(), - announcements: z.object({ - create: z.array( - z.object({ - _id: z.string(), - _updatedAt: z.string().datetime().optional(), - selector: z.object({ - roles: z.array(z.string()), - }), - platform: z.array(z.enum(['web', 'mobile'])), - expireAt: z.string().datetime(), - startAt: z.string().datetime(), - createdBy: z.enum(['cloud', 'system']), - createdAt: z.string().datetime(), - dictionary: z.record(z.string(), z.record(z.string(), z.string())).optional(), - view: z.unknown(), - surface: z.enum(['banner', 'modal']), - }), - ), - delete: z.array(z.string()).optional(), - }), -}); - const fetchCloudAnnouncementsSync = async ({ token, data, }: { token: string; data: Cloud.WorkspaceSyncRequestPayload; -}): Promise> => { +}): Promise => { const cloudUrl = settings.get('Cloud_Url'); const response = await fetch(`${cloudUrl}/api/v3/comms/workspace`, { method: 'POST', @@ -57,6 +25,8 @@ const fetchCloudAnnouncementsSync = async ({ Authorization: `Bearer ${token}`, }, body: data, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, }); if (!response.ok) { @@ -70,13 +40,15 @@ const fetchCloudAnnouncementsSync = async ({ const payload = await response.json(); - const assertWorkspaceCommPayload = workspaceCommPayloadSchema.safeParse(payload); + const result = Cloud.WorkspaceCommsResponsePayloadSchema.safeParse(payload); - if (!assertWorkspaceCommPayload.success) { - SystemLogger.error({ msg: 'workspaceCommPayloadSchema failed type validation', errors: assertWorkspaceCommPayload.error.issues }); + if (!result.success) { + throw new CloudWorkspaceConnectionError('failed type validation', { + cause: z.prettifyError(result.error), + }); } - return payload; + return result.data; }; export async function announcementSync() { diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/fetchWorkspaceSyncPayload.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/fetchWorkspaceSyncPayload.ts index dd3a602eb7d84..4cd7cc9c24d02 100644 --- a/apps/meteor/app/cloud/server/functions/syncWorkspace/fetchWorkspaceSyncPayload.ts +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/fetchWorkspaceSyncPayload.ts @@ -1,24 +1,17 @@ -import type { Cloud, Serialized } from '@rocket.chat/core-typings'; +import { Cloud } from '@rocket.chat/core-typings'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import * as z from 'zod'; import { CloudWorkspaceConnectionError } from '../../../../../lib/errors/CloudWorkspaceConnectionError'; -import { SystemLogger } from '../../../../../server/lib/logger/system'; import { settings } from '../../../../settings/server'; -const workspaceSyncPayloadSchema = z.object({ - workspaceId: z.string(), - publicKey: z.string().optional(), - license: z.string(), -}); - export async function fetchWorkspaceSyncPayload({ token, data, }: { token: string; data: Cloud.WorkspaceSyncRequestPayload; -}): Promise> { +}): Promise { const workspaceRegistrationClientUri = settings.get('Cloud_Workspace_Registration_Client_Uri'); const response = await fetch(`${workspaceRegistrationClientUri}/sync`, { method: 'POST', @@ -26,6 +19,8 @@ export async function fetchWorkspaceSyncPayload({ Authorization: `Bearer ${token}`, }, body: data, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, }); if (!response.ok) { @@ -35,11 +30,13 @@ export async function fetchWorkspaceSyncPayload({ const payload = await response.json(); - const assertWorkspaceSyncPayload = workspaceSyncPayloadSchema.safeParse(payload); + const result = Cloud.WorkspaceSyncResponseSchema.safeParse(payload); - if (!assertWorkspaceSyncPayload.success) { - SystemLogger.error({ msg: 'workspaceCommPayloadSchema failed type validation', errors: assertWorkspaceSyncPayload.error.issues }); + if (!result.success) { + throw new CloudWorkspaceConnectionError('failed type validation', { + cause: z.prettifyError(result.error), + }); } - return payload; + return result.data; } diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/handleCommsSync.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/handleCommsSync.ts index 133c3cc5b3f63..75ca93965c9fa 100644 --- a/apps/meteor/app/cloud/server/functions/syncWorkspace/handleCommsSync.ts +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/handleCommsSync.ts @@ -1,17 +1,15 @@ import { NPS, Banner } from '@rocket.chat/core-services'; -import type { Cloud, Serialized } from '@rocket.chat/core-typings'; +import type { Cloud, IBanner } from '@rocket.chat/core-typings'; import { getAndCreateNpsSurvey } from '../../../../../server/services/nps/getAndCreateNpsSurvey'; -export const handleNpsOnWorkspaceSync = async (nps: Exclude['nps'], undefined>) => { - const { id: npsId, expireAt } = nps; - - const startAt = new Date(nps.startAt); +export const handleNpsOnWorkspaceSync = async (nps: Cloud.NpsSurveyAnnouncement) => { + const { id: npsId, startAt, expireAt } = nps; await NPS.create({ npsId, startAt, - expireAt: new Date(expireAt), + expireAt, createdBy: { _id: 'rocket.cat', username: 'rocket.cat', @@ -25,44 +23,24 @@ export const handleNpsOnWorkspaceSync = async (nps: Exclude['banners'], undefined>) => { +export const handleBannerOnWorkspaceSync = async (banners: IBanner[]) => { for await (const banner of banners) { - const { createdAt, expireAt, startAt, inactivedAt, _updatedAt, ...rest } = banner; - - await Banner.create({ - ...rest, - createdAt: new Date(createdAt), - expireAt: new Date(expireAt), - startAt: new Date(startAt), - ...(inactivedAt && { inactivedAt: new Date(inactivedAt) }), - }); + await Banner.create(banner); } }; -const deserializeAnnouncement = (announcement: Serialized): Cloud.IAnnouncement => { - const { inactivedAt, _updatedAt, expireAt, startAt, createdAt } = announcement; - - return { - ...announcement, - _updatedAt: new Date(_updatedAt), - expireAt: new Date(expireAt), - startAt: new Date(startAt), - createdAt: new Date(createdAt), - inactivedAt: inactivedAt ? new Date(inactivedAt) : undefined, - }; -}; - -export const handleAnnouncementsOnWorkspaceSync = async ( - announcements: Exclude['announcements'], undefined>, -) => { +export const handleAnnouncementsOnWorkspaceSync = async (announcements: { + create: Cloud.Announcement[]; + delete?: Cloud.Announcement['_id'][]; +}) => { const { create, delete: deleteIds } = announcements; if (deleteIds) { - await Promise.all(deleteIds.map((bannerId) => Banner.disable(bannerId))); + await Promise.all(deleteIds.map((announcementId) => Banner.disable(announcementId))); } await Promise.all( - create.map(deserializeAnnouncement).map((announcement) => { + create.map((announcement) => { const { view, selector } = announcement; return Banner.create({ diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/legacySyncWorkspace.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/legacySyncWorkspace.ts index 5dd6920416d81..89843962d74e0 100644 --- a/apps/meteor/app/cloud/server/functions/syncWorkspace/legacySyncWorkspace.ts +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/legacySyncWorkspace.ts @@ -1,4 +1,4 @@ -import { type Cloud, type Serialized } from '@rocket.chat/core-typings'; +import { Cloud } from '@rocket.chat/core-typings'; import { Settings } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import * as z from 'zod'; @@ -14,72 +14,6 @@ import { getWorkspaceLicense } from '../getWorkspaceLicense'; import { retrieveRegistrationStatus } from '../retrieveRegistrationStatus'; import { handleBannerOnWorkspaceSync, handleNpsOnWorkspaceSync } from './handleCommsSync'; -const workspaceClientPayloadSchema = z.object({ - workspaceId: z.string(), - publicKey: z.string().optional(), - trial: z - .object({ - trialing: z.boolean(), - trialID: z.string(), - endDate: z.string().datetime(), - marketing: z.object({ - utmContent: z.string(), - utmMedium: z.string(), - utmSource: z.string(), - utmCampaign: z.string(), - }), - DowngradesToPlan: z.object({ - id: z.string(), - }), - trialRequested: z.boolean(), - }) - .optional(), - nps: z.object({ - id: z.string(), - startAt: z.string().datetime(), - expireAt: z.string().datetime(), - }), - banners: z.array( - z.object({ - _id: z.string(), - _updatedAt: z.string().datetime(), - platform: z.array(z.string()), - expireAt: z.string().datetime(), - startAt: z.string().datetime(), - roles: z.array(z.string()).optional(), - createdBy: z.object({ - _id: z.string(), - username: z.string().optional(), - }), - createdAt: z.string().datetime(), - view: z.any(), - active: z.boolean().optional(), - inactivedAt: z.string().datetime().optional(), - snapshot: z.string().optional(), - }), - ), - announcements: z.object({ - create: z.array( - z.object({ - _id: z.string(), - _updatedAt: z.string().datetime(), - selector: z.object({ - roles: z.array(z.string()), - }), - platform: z.array(z.enum(['web', 'mobile'])), - expireAt: z.string().datetime(), - startAt: z.string().datetime(), - createdBy: z.enum(['cloud', 'system']), - createdAt: z.string().datetime(), - dictionary: z.record(z.string(), z.record(z.string(), z.string())), - view: z.any(), - surface: z.enum(['banner', 'modal']), - }), - ), - delete: z.array(z.string()), - }), -}); - /** @deprecated */ const fetchWorkspaceClientPayload = async ({ token, @@ -87,7 +21,7 @@ const fetchWorkspaceClientPayload = async ({ }: { token: string; workspaceRegistrationData: WorkspaceRegistrationData; -}): Promise | undefined> => { +}): Promise => { const workspaceRegistrationClientUri = settings.get('Cloud_Workspace_Registration_Client_Uri'); const response = await fetch(`${workspaceRegistrationClientUri}/client`, { method: 'POST', @@ -96,6 +30,8 @@ const fetchWorkspaceClientPayload = async ({ }, body: workspaceRegistrationData, timeout: 5000, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, }); if (!response.ok) { @@ -113,17 +49,19 @@ const fetchWorkspaceClientPayload = async ({ return undefined; } - const assertWorkspaceClientPayload = workspaceClientPayloadSchema.safeParse(payload); + const result = Cloud.WorkspaceSyncPayloadSchema.safeParse(payload); - if (!assertWorkspaceClientPayload.success) { - throw new CloudWorkspaceConnectionError('Invalid response from Rocket.Chat Cloud'); + if (!result.success) { + throw new CloudWorkspaceConnectionError('Invalid response from Rocket.Chat Cloud', { + cause: z.prettifyError(result.error), + }); } - return payload; + return result.data; }; /** @deprecated */ -const consumeWorkspaceSyncPayload = async (result: Serialized) => { +const consumeWorkspaceSyncPayload = async (result: Cloud.WorkspaceSyncPayload) => { if (result.publicKey) { (await Settings.updateValueById('Cloud_Workspace_PublicKey', result.publicKey)).modifiedCount && void notifyOnSettingChangedById('Cloud_Workspace_PublicKey'); diff --git a/apps/meteor/app/cloud/server/functions/userLogout.ts b/apps/meteor/app/cloud/server/functions/userLogout.ts index 590a581ed4f01..df690041b7536 100644 --- a/apps/meteor/app/cloud/server/functions/userLogout.ts +++ b/apps/meteor/app/cloud/server/functions/userLogout.ts @@ -40,6 +40,8 @@ export async function userLogout(userId: string): Promise { token: refreshToken, token_type_hint: 'refresh_token', }, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, }); } catch (err) { SystemLogger.error({ diff --git a/apps/meteor/app/cloud/server/index.ts b/apps/meteor/app/cloud/server/index.ts index e7214295225b1..7bc7696d5b0dc 100644 --- a/apps/meteor/app/cloud/server/index.ts +++ b/apps/meteor/app/cloud/server/index.ts @@ -23,36 +23,36 @@ Meteor.startup(async () => { } console.log('Successfully registered with token provided by REG_TOKEN!'); - } catch (e: any) { - SystemLogger.error('An error occurred registering with token.', e.message); + } catch (err: any) { + SystemLogger.error({ msg: 'An error occurred registering with token.', err }); } } setImmediate(async () => { try { await syncWorkspace(); - } catch (e: any) { - if (e instanceof CloudWorkspaceAccessTokenEmptyError) { + } catch (err: any) { + if (err instanceof CloudWorkspaceAccessTokenEmptyError) { return; } - if (e.type && e.type === 'AbortError') { + if (err.type && err.type === 'AbortError') { return; } - SystemLogger.error('An error occurred syncing workspace.', e.message); + SystemLogger.error({ msg: 'An error occurred syncing workspace.', err }); } }); const minute = Math.floor(Math.random() * 60); await cronJobs.add(licenseCronName, `${minute} */12 * * *`, async () => { try { await syncWorkspace(); - } catch (e: any) { - if (e instanceof CloudWorkspaceAccessTokenEmptyError) { + } catch (err: any) { + if (err instanceof CloudWorkspaceAccessTokenEmptyError) { return; } - if (e.type && e.type === 'AbortError') { + if (err.type && err.type === 'AbortError') { return; } - SystemLogger.error('An error occurred syncing workspace.', e.message); + SystemLogger.error({ msg: 'An error occurred syncing workspace.', err }); } }); }); diff --git a/apps/meteor/app/cors/server/cors.ts b/apps/meteor/app/cors/server/cors.ts index ef70370770741..5e7909c525dfb 100644 --- a/apps/meteor/app/cors/server/cors.ts +++ b/apps/meteor/app/cors/server/cors.ts @@ -3,7 +3,6 @@ import type { UrlWithParsedQuery } from 'url'; import url from 'url'; import { Logger } from '@rocket.chat/logger'; -import { OAuthApps } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import type { StaticFiles } from 'meteor/webapp'; import { WebApp, WebAppInternals } from 'meteor/webapp'; @@ -45,13 +44,11 @@ WebApp.rawConnectHandlers.use(async (_req: http.IncomingMessage, res: http.Serve } if (settings.get('Enable_CSP')) { - const legacyZapierAvailable = Boolean(await OAuthApps.findOneById('zapier')); - // eslint-disable-next-line @typescript-eslint/naming-convention const cdn_prefixes = [ settings.get('CDN_PREFIX'), settings.get('CDN_PREFIX_ALL') ? null : settings.get('CDN_JSCSS_PREFIX'), - legacyZapierAvailable && 'https://cdn.zapier.com', + 'https://cdn.zapier.com', ] .filter(Boolean) .join(' '); @@ -68,7 +65,7 @@ WebApp.rawConnectHandlers.use(async (_req: http.IncomingMessage, res: http.Serve settings.get('Accounts_OAuth_Apple') && 'https://appleid.cdn-apple.com', settings.get('PiwikAnalytics_enabled') && settings.get('PiwikAnalytics_url'), settings.get('GoogleAnalytics_enabled') && 'https://www.google-analytics.com', - legacyZapierAvailable && 'https://zapier.com', + 'https://zapier.com', ...settings .get('Extra_CSP_Domains') .split(/[ \n\,]/gim) diff --git a/apps/meteor/app/crowd/server/crowd.ts b/apps/meteor/app/crowd/server/crowd.ts index ac1467dedbe00..54cefce69ad48 100644 --- a/apps/meteor/app/crowd/server/crowd.ts +++ b/apps/meteor/app/crowd/server/crowd.ts @@ -9,7 +9,7 @@ import { Meteor } from 'meteor/meteor'; import { logger } from './logger'; import { crowdIntervalValuesToCronMap } from '../../../server/settings/crowd'; import { deleteUser } from '../../lib/server/functions/deleteUser'; -import { _setRealName } from '../../lib/server/functions/setRealName'; +import { setRealName } from '../../lib/server/functions/setRealName'; import { setUserActiveStatus } from '../../lib/server/functions/setUserActiveStatus'; import { notifyOnUserChange, notifyOnUserChangeById, notifyOnUserChangeAsync } from '../../lib/server/lib/notifyListener'; import { settings } from '../../settings/server'; @@ -206,7 +206,7 @@ export class CROWD { } if (crowdUser.displayname) { - await _setRealName(id, crowdUser.displayname); + await setRealName(id, crowdUser.displayname); } await Users.updateOne( @@ -392,8 +392,8 @@ Accounts.registerLoginHandler('crowd', async function (this: typeof Accounts, lo return result; } catch (err: any) { - logger.debug({ err }); - logger.error('Crowd user not authenticated due to an error'); + logger.error({ msg: 'Crowd user not authenticated due to an error', err }); + throw new Meteor.Error('user-not-found', err.message); } }); diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.ts index cc65b524abcbc..89878e783a413 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.ts @@ -269,7 +269,7 @@ export const FileUpload = { try { await writeFile(tempFilePath, data); } catch (err: any) { - SystemLogger.error(err); + SystemLogger.error({ err }); } await this.getCollection().updateOne( diff --git a/apps/meteor/app/file-upload/server/lib/requests.ts b/apps/meteor/app/file-upload/server/lib/requests.ts index f88b2477777c8..b2ae48fbca362 100644 --- a/apps/meteor/app/file-upload/server/lib/requests.ts +++ b/apps/meteor/app/file-upload/server/lib/requests.ts @@ -44,8 +44,8 @@ WebApp.connectHandlers.use(FileUpload.getPath(), async (req, res, next) => { try { url = await store.getStore().getRedirectURL(file, false); expiryTimespan = await store.getStore().getUrlExpiryTimeSpan(); - } catch (e) { - SystemLogger.debug(e); + } catch (err) { + SystemLogger.debug({ err }); } return FileUpload.respondWithRedirectUrlInfo(url, file, req, res, expiryTimespan); } diff --git a/apps/meteor/app/file-upload/server/methods/isImagePreviewSupported.ts b/apps/meteor/app/file-upload/server/methods/isImagePreviewSupported.ts new file mode 100644 index 0000000000000..0ed3e416e3577 --- /dev/null +++ b/apps/meteor/app/file-upload/server/methods/isImagePreviewSupported.ts @@ -0,0 +1,14 @@ +export function isImagePreviewSupported(mimeType: string): boolean { + // Only attempt preview generation for image types that can be processed by Sharp + // This excludes vendor-specific formats like image/vnd.dwg that cannot be rendered + return ( + mimeType === 'image/bmp' || + mimeType === 'image/x-windows-bmp' || + mimeType === 'image/jpeg' || + mimeType === 'image/pjpeg' || + mimeType === 'image/png' || + mimeType === 'image/gif' || + mimeType === 'image/webp' || + mimeType === 'image/svg+xml' + ); +} diff --git a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts index fd503b31644ce..15fcba1875388 100644 --- a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts +++ b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts @@ -13,6 +13,7 @@ import { Rooms, Uploads, Users } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { isImagePreviewSupported } from './isImagePreviewSupported'; import { getFileExtension } from '../../../../lib/utils/getFileExtension'; import { omit } from '../../../../lib/utils/omit'; import { callbacks } from '../../../../server/lib/callbacks'; @@ -54,7 +55,7 @@ export const parseFileIntoMessageAttachments = async ( }, ]; - if (/^image\/.+/.test(file.type as string)) { + if (isImagePreviewSupported(file.type as string)) { const attachment: FileAttachmentProps = { title: file.name, type: 'file', @@ -64,6 +65,7 @@ export const parseFileIntoMessageAttachments = async ( image_url: fileUrl, image_type: file.type as string, image_size: file.size, + fileId: file._id, }; if (file.identify?.size) { @@ -101,8 +103,8 @@ export const parseFileIntoMessageAttachments = async ( typeGroup: thumbnail.typeGroup || '', }); } - } catch (e) { - SystemLogger.error(e); + } catch (err) { + SystemLogger.error({ err }); } attachments.push(attachment); } else if (/^audio\/.+/.test(file.type as string)) { @@ -115,6 +117,7 @@ export const parseFileIntoMessageAttachments = async ( audio_url: fileUrl, audio_type: file.type as string, audio_size: file.size, + fileId: file._id, }; attachments.push(attachment); } else if (/^video\/.+/.test(file.type as string)) { @@ -127,6 +130,7 @@ export const parseFileIntoMessageAttachments = async ( video_url: fileUrl, video_type: file.type as string, video_size: file.size as number, + fileId: file._id, }; attachments.push(attachment); } else { @@ -138,6 +142,7 @@ export const parseFileIntoMessageAttachments = async ( title_link: fileUrl, title_link_download: true, size: file.size as number, + fileId: file._id, }; attachments.push(attachment); } @@ -162,13 +167,6 @@ export const sendFileMessage = async ( file: Partial; msgData?: Record; }, - { - parseAttachmentsForE2EE, - }: { - parseAttachmentsForE2EE: boolean; - } = { - parseAttachmentsForE2EE: true, - }, ): Promise => { const user = await Users.findOneById(userId, { projection: { services: 0 } }); @@ -216,12 +214,10 @@ export const sendFileMessage = async ( groupable: msgData?.groupable ?? false, }; - if (parseAttachmentsForE2EE || msgData?.t !== 'e2e') { - const { files, attachments } = await parseFileIntoMessageAttachments(file, roomId, user); - data.file = files[0]; - data.files = files; - data.attachments = attachments; - } + const { files, attachments } = await parseFileIntoMessageAttachments(file, roomId, user); + data.file = files[0]; + data.files = files; + data.attachments = attachments; const msg = await executeSendMessage(userId, data); diff --git a/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts b/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts index 3cc3f04f9ccb3..9e00e4ea497f3 100644 --- a/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts +++ b/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts @@ -128,7 +128,7 @@ class AmazonS3Store extends UploadFS.Store { try { return s3.deleteObject(params).promise(); } catch (err: any) { - SystemLogger.error(err); + SystemLogger.error({ err }); } }; @@ -184,9 +184,9 @@ class AmazonS3Store extends UploadFS.Store { ContentType: file.type, Bucket: classOptions.connection.params.Bucket, }, - (error) => { - if (error) { - SystemLogger.error(error); + (err) => { + if (err) { + SystemLogger.error({ err }); } writeStream.emit('real_finish'); diff --git a/apps/meteor/app/file-upload/ufs/GoogleStorage/server.ts b/apps/meteor/app/file-upload/ufs/GoogleStorage/server.ts index 2034ea2135706..e2b71ac8052d7 100644 --- a/apps/meteor/app/file-upload/ufs/GoogleStorage/server.ts +++ b/apps/meteor/app/file-upload/ufs/GoogleStorage/server.ts @@ -103,7 +103,7 @@ class GoogleStorageStore extends UploadFS.Store { try { return bucket.file(this.getPath(file)).delete(); } catch (err: any) { - SystemLogger.error(err); + SystemLogger.error({ err }); } }; diff --git a/apps/meteor/app/file-upload/ufs/Webdav/server.ts b/apps/meteor/app/file-upload/ufs/Webdav/server.ts index 69ab18a4ebb08..e5a8a62b5d059 100644 --- a/apps/meteor/app/file-upload/ufs/Webdav/server.ts +++ b/apps/meteor/app/file-upload/ufs/Webdav/server.ts @@ -94,7 +94,7 @@ class WebdavStore extends UploadFS.Store { try { return client.deleteFile(this.getPath(file)); } catch (err: any) { - SystemLogger.error(err); + SystemLogger.error({ err }); } }; diff --git a/apps/meteor/app/github/server/index.ts b/apps/meteor/app/github/server/index.ts new file mode 100644 index 0000000000000..cf327e4971bb2 --- /dev/null +++ b/apps/meteor/app/github/server/index.ts @@ -0,0 +1 @@ +import './lib'; diff --git a/apps/meteor/app/github/server/lib.ts b/apps/meteor/app/github/server/lib.ts new file mode 100644 index 0000000000000..abdc87419956a --- /dev/null +++ b/apps/meteor/app/github/server/lib.ts @@ -0,0 +1,19 @@ +import type { OauthConfig } from '@rocket.chat/core-typings'; + +import { CustomOAuth } from '../../custom-oauth/server/custom_oauth_server'; + +const config: OauthConfig = { + serverURL: 'https://github.com', + identityPath: 'https://api.github.com/user', + tokenPath: 'https://github.com/login/oauth/access_token', + scope: 'user:email', + mergeUsers: false, + addAutopublishFields: { + forLoggedInUser: ['services.github'], + forOtherUsers: ['services.github.username'], + }, + accessTokenParam: 'access_token', + identityTokenSentVia: 'header', +}; + +export const Github = new CustomOAuth('github', config); diff --git a/apps/meteor/app/importer-pending-files/server/PendingFileImporter.ts b/apps/meteor/app/importer-pending-files/server/PendingFileImporter.ts index 818031d07916e..56aa0293aedb0 100644 --- a/apps/meteor/app/importer-pending-files/server/PendingFileImporter.ts +++ b/apps/meteor/app/importer-pending-files/server/PendingFileImporter.ts @@ -129,9 +129,9 @@ export class PendingFileImporter extends Importer { // Update progress more often on large files this.reportProgress(); }); - res.on('error', async (error) => { + res.on('error', async (err) => { await completeFile(details); - logError(error); + logError({ err }); }); res.on('end', async () => { @@ -145,14 +145,14 @@ export class PendingFileImporter extends Importer { await Messages.setImportFileRocketChatAttachment(_importFile.id, url, attachment); await completeFile(details); importedRoomIds.add(message.rid); - } catch (error) { + } catch (err) { await completeFile(details); - logError(error); + logError({ err }); } }); }); - } catch (error) { - this.logger.error(error); + } catch (err) { + this.logger.error({ err }); } } diff --git a/apps/meteor/app/importer-slack/server/SlackImporter.ts b/apps/meteor/app/importer-slack/server/SlackImporter.ts index 87098c8b35ec2..30c710df09b7a 100644 --- a/apps/meteor/app/importer-slack/server/SlackImporter.ts +++ b/apps/meteor/app/importer-slack/server/SlackImporter.ts @@ -290,8 +290,8 @@ export class SlackImporter extends Importer { ImporterWebsocket.progressUpdated({ rate }); oldRate = rate; } - } catch (e) { - this.logger.error(e); + } catch (err) { + this.logger.error({ msg: 'Error updating progress', err }); } }; @@ -332,8 +332,8 @@ export class SlackImporter extends Importer { increaseProgress(); continue; } - } catch (e) { - this.logger.error(e); + } catch (err) { + this.logger.error({ msg: 'Error adding missed type', err }); } } @@ -388,19 +388,19 @@ export class SlackImporter extends Importer { this.logger.warn({ msg: 'Entry is not a valid JSON file; unable to import', entryName: entry.entryName, err: error }); } } - } catch (e) { - this.logger.error(e); + } catch (err) { + this.logger.error({ msg: 'Error processing message entry', err }); } increaseProgress(); } if (Object.keys(missedTypes).length > 0) { - this.logger.info('Missed import types:', missedTypes); + this.logger.info({ msg: 'Missed import types', missedTypes }); } - } catch (e) { - this.logger.error(e); - throw e; + } catch (err) { + this.logger.error({ msg: 'Error preparing import using local file', err }); + throw err; } ImporterWebsocket.progressUpdated({ rate: 100 }); diff --git a/apps/meteor/app/importer/server/classes/ImporterWebsocket.ts b/apps/meteor/app/importer/server/classes/ImporterWebsocket.ts index a08e62f8435c4..ffcc8aef1ed96 100644 --- a/apps/meteor/app/importer/server/classes/ImporterWebsocket.ts +++ b/apps/meteor/app/importer/server/classes/ImporterWebsocket.ts @@ -1,6 +1,6 @@ import type { IImportProgress } from '@rocket.chat/core-typings'; -import type { IStreamer } from 'meteor/rocketchat:streamer'; +import type { IStreamer } from '../../../../server/modules/streamer/types'; import notifications from '../../../notifications/server/lib/Notifications'; class ImporterWebsocketDef { diff --git a/apps/meteor/app/importer/server/classes/converters/MessageConverter.ts b/apps/meteor/app/importer/server/classes/converters/MessageConverter.ts index 8fa9eaba04534..825090147be8a 100644 --- a/apps/meteor/app/importer/server/classes/converters/MessageConverter.ts +++ b/apps/meteor/app/importer/server/classes/converters/MessageConverter.ts @@ -41,9 +41,8 @@ export class MessageConverter extends RecordConverter { for await (const rid of this.rids) { try { await Rooms.resetLastMessageById(rid, null); - } catch (e) { - this._logger.warn({ msg: 'Failed to update last message of room', roomId: rid }); - this._logger.error(e); + } catch (err) { + this._logger.error({ msg: 'Failed to update last message of room', roomId: rid, err }); } } } @@ -70,9 +69,8 @@ export class MessageConverter extends RecordConverter { try { await insertMessage(creator, msgObj as unknown as IDBMessage, rid, true); - } catch (e) { - this._logger.warn({ msg: 'Failed to import message', timestamp: msgObj.ts, roomId: rid }); - this._logger.error(e); + } catch (err) { + this._logger.error({ msg: 'Failed to import message', timestamp: msgObj.ts, roomId: rid, err }); } } @@ -167,7 +165,7 @@ export class MessageConverter extends RecordConverter { } if (!data.username) { - this._logger.debug(importId); + this._logger.debug({ msg: 'Mentioned user has no username', importId }); throw new Error('importer-message-mentioned-username-not-found'); } diff --git a/apps/meteor/app/integrations/server/api/api.ts b/apps/meteor/app/integrations/server/api/api.ts index 8e1a37c8e76fa..7e72862f70588 100644 --- a/apps/meteor/app/integrations/server/api/api.ts +++ b/apps/meteor/app/integrations/server/api/api.ts @@ -401,16 +401,29 @@ const middleware = async (c: Context, next: Next): Promise => { return next(); } + /** + * Slack/GitHub-style webhooks send JSON wrapped in a `payload` field with + * Content-Type: application/x-www-form-urlencoded (e.g. `payload={"text":"hello"}`). + * We unwrap it here so integrations receive the parsed JSON directly. + * + * Note: These webhooks only send the `payload` field with no additional form + * parameters, so we simply replace bodyParams with the parsed JSON. + */ if (body.payload) { - // need to compose the full payload in this weird way because body-parser thought it was a form - c.set('bodyParams-override', JSON.parse(body.payload)); + if (typeof body.payload === 'string') { + try { + c.set('bodyParams-override', JSON.parse(body.payload)); + } catch { + // Keep original without unwrapping + } + } return next(); } + incomingLogger.debug({ msg: 'Body received as application/x-www-form-urlencoded without the "payload" key, parsed as string', content, }); - c.set('bodyParams-override', JSON.parse(content)); } catch (e: any) { c.body(JSON.stringify({ success: false, error: e.message }), 400); } diff --git a/apps/meteor/app/integrations/server/lib/ScriptEngine.ts b/apps/meteor/app/integrations/server/lib/ScriptEngine.ts index 906dbcd4024f3..7778d42f20179 100644 --- a/apps/meteor/app/integrations/server/lib/ScriptEngine.ts +++ b/apps/meteor/app/integrations/server/lib/ScriptEngine.ts @@ -296,7 +296,9 @@ export abstract class IntegrationScriptEngine { }); this.logger.debug({ - msg: `Script method "${method}" result of the Integration "${integration.name}" is:`, + msg: 'Script method result of the Integration', + method, + integration: integration.name, result, }); diff --git a/apps/meteor/app/integrations/server/lib/isolated-vm/isolated-vm.ts b/apps/meteor/app/integrations/server/lib/isolated-vm/isolated-vm.ts index 2c78b6d98a7ce..42044697014cb 100644 --- a/apps/meteor/app/integrations/server/lib/isolated-vm/isolated-vm.ts +++ b/apps/meteor/app/integrations/server/lib/isolated-vm/isolated-vm.ts @@ -56,7 +56,7 @@ export class IsolatedVMScriptEngine extends Integrat const script = integration.scriptCompiled; try { this.logger.info({ msg: 'Will evaluate the integration script', integration: pick(integration, 'name', '_id') }); - this.logger.debug(script); + this.logger.debug({ script }); const isolate = new ivm.Isolate({ memoryLimit: 8 }); diff --git a/apps/meteor/app/integrations/server/lib/triggerHandler.ts b/apps/meteor/app/integrations/server/lib/triggerHandler.ts index 0a29396ec2c32..192419d6c2136 100644 --- a/apps/meteor/app/integrations/server/lib/triggerHandler.ts +++ b/apps/meteor/app/integrations/server/lib/triggerHandler.ts @@ -158,9 +158,10 @@ class RocketChatIntegrationHandler { // If no room could be found, we won't be sending any messages but we'll warn in the logs if (!tmpRoom) { - outgoingLogger.warn( - `The Integration "${trigger.name}" doesn't have a room configured nor did it provide a room to send the message to.`, - ); + outgoingLogger.warn({ + msg: 'The Integration doesnt have a room configured nor did it provide a room to send the message to.', + integrationName: trigger.name, + }); return; } @@ -618,6 +619,8 @@ class RocketChatIntegrationHandler { headers: opts.headers, ...(opts.timeout && { timeout: opts.timeout }), ...(opts.data && { body: opts.data }), + // SECURITY: Integrations can only be configured by users with enough privileges. It's ok to disable this check here. + ignoreSsrfValidation: true, }, settings.get('Allow_Invalid_SelfSigned_Certs'), ) @@ -781,12 +784,12 @@ class RocketChatIntegrationHandler { } } }) - .catch(async (error) => { - outgoingLogger.error(error); + .catch(async (err) => { + outgoingLogger.error({ err }); await updateHistory({ historyId, step: 'after-http-call', - httpError: error, + httpError: err, httpResult: null, }); }); diff --git a/apps/meteor/app/lib/server/functions/checkUrlForSsrf.ts b/apps/meteor/app/lib/server/functions/checkUrlForSsrf.ts deleted file mode 100644 index 7ec3272ffe4c0..0000000000000 --- a/apps/meteor/app/lib/server/functions/checkUrlForSsrf.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { lookup } from 'dns'; - -// https://en.wikipedia.org/wiki/Reserved_IP_addresses + Alibaba Metadata IP -const ranges: string[] = [ - '0.0.0.0/8', - '10.0.0.0/8', - '100.64.0.0/10', - '127.0.0.0/8', - '169.254.0.0/16', - '172.16.0.0/12', - '192.0.0.0/24', - '192.0.2.0/24', - '192.88.99.0/24', - '192.168.0.0/16', - '198.18.0.0/15', - '198.51.100.0/24', - '203.0.113.0/24', - '224.0.0.0/4', - '240.0.0.0/4', - '255.255.255.255', - '100.100.100.200/32', -]; - -export const nslookup = async (hostname: string): Promise => { - return new Promise((resolve, reject) => { - lookup(hostname, (error, address) => { - if (error) { - reject(error); - } else { - resolve(address); - } - }); - }); -}; - -export const ipToLong = (ip: string): number => { - return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0; -}; - -export const isIpInRange = (ip: string, range: string): boolean => { - const [rangeIp, subnet] = range.split('/'); - const ipLong = ipToLong(ip); - const rangeIpLong = ipToLong(rangeIp); - const mask = ~(2 ** (32 - Number(subnet)) - 1); - return (ipLong & mask) === (rangeIpLong & mask); -}; - -export const isIpInAnyRange = (ip: string): boolean => ranges.some((range) => isIpInRange(ip, range)); - -export const isValidIPv4 = (ip: string): boolean => { - const octets = ip.split('.'); - if (octets.length !== 4) return false; - return octets.every((octet) => { - const num = Number(octet); - return num >= 0 && num <= 255 && octet === num.toString(); - }); -}; - -export const isValidDomain = (domain: string): boolean => { - const domainPattern = /^(?!-)(?!.*--)[A-Za-z0-9-]{1,63}(? => { - if (!(url.startsWith('http://') || url.startsWith('https://'))) { - return false; - } - - const [, address] = url.split('://'); - const ipOrDomain = address.includes('/') ? address.split('/')[0] : address; - - if (!(isValidIPv4(ipOrDomain) || isValidDomain(ipOrDomain))) { - return false; - } - - if (isValidIPv4(ipOrDomain) && isIpInAnyRange(ipOrDomain)) { - return false; - } - - if (isValidDomain(ipOrDomain) && /metadata.google.internal/.test(ipOrDomain.toLowerCase())) { - return false; - } - - if (isValidDomain(ipOrDomain)) { - try { - const ipAddress = await nslookup(ipOrDomain); - if (isIpInAnyRange(ipAddress)) { - return false; - } - } catch (error) { - console.log(error); - return false; - } - } - - return true; -}; diff --git a/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts b/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts index 7acc06f2ba3cd..2bea0914ee00f 100644 --- a/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts +++ b/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts @@ -3,6 +3,7 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { Messages, Rooms, Subscriptions, ReadReceipts, Users } from '@rocket.chat/models'; import { deleteRoom } from './deleteRoom'; +import { NOTIFICATION_ATTACHMENT_COLOR } from '../../../../lib/constants'; import { i18n } from '../../../../server/lib/i18n'; import { FileUpload } from '../../../file-upload/server'; import { notifyOnRoomChangedById, notifyOnSubscriptionChangedById } from '../lib/notifyListener'; @@ -47,7 +48,8 @@ export async function cleanRoomHistory({ }); const targetMessageIdsForAttachmentRemoval = new Set(); - const pruneMessageAttachment = { color: '#FD745E', text }; + // Since we remove every file from the messages, we don't need to specify which fileId has been removed. + const pruneMessageAttachment = { type: 'removed-file', color: NOTIFICATION_ATTACHMENT_COLOR, text }; async function performFileAttachmentCleanupBatch() { if (targetMessageIdsForAttachmentRemoval.size === 0) return; diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index 7b64ec9e68b09..bbd971e667e66 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -13,7 +13,6 @@ import { beforeAddUserToRoom } from '../../../../server/lib/callbacks/beforeAddU import { beforeCreateRoomCallback, prepareCreateRoomCallback } from '../../../../server/lib/callbacks/beforeCreateRoomCallback'; import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/lib/getSubscriptionAutotranslateDefaultConfig'; import { syncRoomRolePriorityForUserAndRoom } from '../../../../server/lib/roles/syncRoomRolePriority'; -import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { getDefaultSubscriptionPref } from '../../../utils/lib/getDefaultSubscriptionPref'; import { getValidRoomName } from '../../../utils/server/lib/getValidRoomName'; import { notifyOnRoomChanged, notifyOnSubscriptionChangedById } from '../lib/notifyListener'; @@ -184,12 +183,7 @@ export const createRoom = async ( const shouldBeHandledByFederation = extraData.federated === true; - if ( - shouldBeHandledByFederation && - owner && - !isUserNativeFederated(owner) && - !(await hasPermissionAsync(owner._id, 'access-federation')) - ) { + if (shouldBeHandledByFederation && owner && !isUserNativeFederated(owner) && !(await FederationMatrix.canUserAccessFederation(owner))) { throw new Meteor.Error('error-not-authorized-federation', 'Not authorized to access federation', { method: 'createRoom', }); diff --git a/apps/meteor/app/lib/server/functions/deleteMessage.ts b/apps/meteor/app/lib/server/functions/deleteMessage.ts index 09e7e631a8c41..f00e7edb98e7b 100644 --- a/apps/meteor/app/lib/server/functions/deleteMessage.ts +++ b/apps/meteor/app/lib/server/functions/deleteMessage.ts @@ -21,7 +21,7 @@ export const deleteMessageValidatingPermission = async (message: AtLeast { + it('should extract URLs from LINK nodes', () => { + const md = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'LINK', + value: { + src: { + type: 'PLAIN_TEXT', + value: 'https://rocket.chat', + }, + label: [ + { + type: 'PLAIN_TEXT', + value: 'rocket.chat', + }, + ], + }, + }, + ], + }, + ]; + + const urls = extractUrlsFromMessageAST(md as any); + expect(urls).to.deep.equal(['https://rocket.chat']); + }); + + it('should convert // prefix to https://', () => { + const md = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'LINK', + value: { + src: { + type: 'PLAIN_TEXT', + value: '//github.com/RocketChat/Rocket.Chat', + }, + label: [ + { + type: 'PLAIN_TEXT', + value: 'github.com/RocketChat/Rocket.Chat', + }, + ], + }, + }, + ], + }, + ]; + + const urls = extractUrlsFromMessageAST(md as any); + expect(urls).to.deep.equal(['https://github.com/RocketChat/Rocket.Chat']); + }); + + it('should handle multiple links', () => { + const md = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'LINK', + value: { + src: { + type: 'PLAIN_TEXT', + value: 'https://rocket.chat', + }, + label: [ + { + type: 'PLAIN_TEXT', + value: 'rocket.chat', + }, + ], + }, + }, + { + type: 'PLAIN_TEXT', + value: ' and ', + }, + { + type: 'LINK', + value: { + src: { + type: 'PLAIN_TEXT', + value: '//github.com/RocketChat', + }, + label: [ + { + type: 'PLAIN_TEXT', + value: 'github.com/RocketChat', + }, + ], + }, + }, + ], + }, + ]; + + const urls = extractUrlsFromMessageAST(md as any); + expect(urls).to.deep.equal(['https://rocket.chat', 'https://github.com/RocketChat']); + }); + + it('should return empty array for undefined or non-array input', () => { + expect(extractUrlsFromMessageAST(undefined)).to.deep.equal([]); + expect(extractUrlsFromMessageAST(null as any)).to.deep.equal([]); + expect(extractUrlsFromMessageAST({} as any)).to.deep.equal([]); + }); +}); diff --git a/apps/meteor/app/lib/server/functions/extractUrlsFromMessageAST.ts b/apps/meteor/app/lib/server/functions/extractUrlsFromMessageAST.ts new file mode 100644 index 0000000000000..222aa7c56d76d --- /dev/null +++ b/apps/meteor/app/lib/server/functions/extractUrlsFromMessageAST.ts @@ -0,0 +1,33 @@ +import type { Root } from '@rocket.chat/message-parser'; + +/** + * Extracts all URLs from parsed message AST (message-parser output) + * Looks for LINK nodes and extracts the src URL + */ +export const extractUrlsFromMessageAST = (md?: Root | Root[number] | Root[number]['value']): string[] => { + if (!md || !Array.isArray(md)) { + return []; + } + + const urls: string[] = []; + + const walk = (node: any): void => { + if (Array.isArray(node)) { + node.forEach(walk); + return; + } + if (typeof node !== 'object' || node === null) { + return; + } + if (node.type === 'LINK' && node.value?.src?.value) { + urls.push(node.value.src.value); + } + if (node.value !== undefined) { + walk(node.value); + } + }; + + walk(md); + + return urls; +}; diff --git a/apps/meteor/app/lib/server/functions/getAvatarSuggestionForUser.ts b/apps/meteor/app/lib/server/functions/getAvatarSuggestionForUser.ts index 2560d2f08b7de..278a0c5bd8ded 100644 --- a/apps/meteor/app/lib/server/functions/getAvatarSuggestionForUser.ts +++ b/apps/meteor/app/lib/server/functions/getAvatarSuggestionForUser.ts @@ -155,7 +155,10 @@ export async function getAvatarSuggestionForUser( const validAvatars: Record = {}; for await (const avatar of avatars) { try { - const response = await fetch(avatar.url); + const response = await fetch(avatar.url, { + ignoreSsrfValidation: false, + allowList: settings.get('SSRF_Allowlist'), + }); const newAvatar: { service: string; url: string; blob: string; contentType: string } = { service: avatar.service, url: avatar.url, diff --git a/apps/meteor/app/lib/server/functions/insertMessage.ts b/apps/meteor/app/lib/server/functions/insertMessage.ts index ab20be0dfe677..3c053ba424d1e 100644 --- a/apps/meteor/app/lib/server/functions/insertMessage.ts +++ b/apps/meteor/app/lib/server/functions/insertMessage.ts @@ -4,6 +4,7 @@ import { Messages, Rooms } from '@rocket.chat/models'; import { parseUrlsInMessage } from './parseUrlsInMessage'; import { validateMessage, prepareMessageObject } from './sendMessage'; +// TODO: remove and move to Message.Service export const insertMessage = async function ( user: Pick, message: IMessage, @@ -16,7 +17,7 @@ export const insertMessage = async function ( await validateMessage(message, { _id: rid }, user); prepareMessageObject(message, rid, user); - parseUrlsInMessage(message); + message.urls = parseUrlsInMessage(message); if (message._id && upsert) { const { _id, ...rest } = message; diff --git a/apps/meteor/app/lib/server/functions/notifications/email.js b/apps/meteor/app/lib/server/functions/notifications/email.js index 07cc5c949121b..a27699bc1d111 100644 --- a/apps/meteor/app/lib/server/functions/notifications/email.js +++ b/apps/meteor/app/lib/server/functions/notifications/email.js @@ -54,7 +54,7 @@ export async function getEmailContent({ message, user, room }) { }); } - if (message.t === 'e2e' && !message.file && !message.files?.length) { + if (message.t === 'e2e') { return settings.get('Email_notification_show_message') ? i18n.t('Encrypted_message_preview_unavailable', { lng }) : header; } diff --git a/apps/meteor/app/lib/server/functions/parseUrlsInMessage.ts b/apps/meteor/app/lib/server/functions/parseUrlsInMessage.ts index ea8bed9f77d46..233b4f4600c27 100644 --- a/apps/meteor/app/lib/server/functions/parseUrlsInMessage.ts +++ b/apps/meteor/app/lib/server/functions/parseUrlsInMessage.ts @@ -1,29 +1,37 @@ import type { IMessage, AtLeast } from '@rocket.chat/core-typings'; +import { extractUrlsFromMessageAST } from './extractUrlsFromMessageAST'; import { getMessageUrlRegex } from '../../../../lib/getMessageUrlRegex'; import { Markdown } from '../../../markdown/server'; import { settings } from '../../../settings/server'; -// TODO move this function to message service to be used like a "beforeSaveMessage" hook -export const parseUrlsInMessage = (message: AtLeast & { parseUrls?: boolean }, previewUrls?: string[]) => { - if (message.parseUrls === false) { - return message; - } +const prepareUrl = (url: string, previewUrls: string[] | undefined) => ({ + url, + meta: {}, + ...(previewUrls && !previewUrls.includes(url) && !url.includes(settings.get('Site_Url')) && { ignoreParse: true }), +}); - message.html = message.msg; - message = Markdown.code(message); +const prepareUrls = (urls: string[], previewUrls?: string[]) => [...new Set(urls)].map((url) => prepareUrl(url, previewUrls)); - const urls = message.html?.match(getMessageUrlRegex()) || []; - if (urls) { - message.urls = [...new Set(urls)].map((url) => ({ - url, - meta: {}, - ...(previewUrls && !previewUrls.includes(url) && !url.includes(settings.get('Site_Url')) && { ignoreParse: true }), - })); +export const parseUrlsInMessage = ( + message: AtLeast & { + parseUrls?: boolean; + }, + previewUrls?: string[], +) => { + // Also extract URLs from message blocks if they exist + if (message.md) { + const astUrls = extractUrlsFromMessageAST(message.md); + return prepareUrls(astUrls, previewUrls); } - message = Markdown.mountTokensBack(message, false); - message.msg = message.html || message.msg; - delete message.html; - delete message.tokens; + // TODO: remove this after make the parser official + // Parse the message to extract URLs from links without schema + // The message parser converts links like "github.com" to proper links with "//" prefix + const result = Markdown.code({ + html: message.msg, + msg: message.msg, + }); + const htmlUrls = result.html?.match(getMessageUrlRegex()) || []; + return prepareUrls(htmlUrls, previewUrls); }; diff --git a/apps/meteor/app/lib/server/functions/saveUser/saveNewUser.ts b/apps/meteor/app/lib/server/functions/saveUser/saveNewUser.ts index 508472110fc32..35fb39b5336f6 100644 --- a/apps/meteor/app/lib/server/functions/saveUser/saveNewUser.ts +++ b/apps/meteor/app/lib/server/functions/saveUser/saveNewUser.ts @@ -1,3 +1,4 @@ +import type { IUser } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import Gravatar from 'gravatar'; @@ -11,7 +12,7 @@ import { handleNickname } from './handleNickname'; import type { SaveUserData } from './saveUser'; import { sendPasswordEmail, sendWelcomeEmail } from './sendUserEmail'; -export const saveNewUser = async function (userData: SaveUserData, sendPassword: boolean) { +export const saveNewUser = async function (userData: SaveUserData, sendPassword: boolean, performedBy: IUser) { await validateEmailDomain(userData.email); const roles = (!!userData.roles && userData.roles.length > 0 && userData.roles) || getNewUserRoles(); @@ -25,6 +26,7 @@ export const saveNewUser = async function (userData: SaveUserData, sendPassword: isGuest, globalRoles: roles, skipNewUserRolesSetting: true, + performedBy, }; if (userData.email) { createUser.email = userData.email; diff --git a/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts b/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts index b727867b1e818..ad9f1599e31fe 100644 --- a/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts +++ b/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts @@ -18,7 +18,6 @@ import type { UserChangedAuditStore } from '../../../../../server/lib/auditServe import { callbacks } from '../../../../../server/lib/callbacks'; import { shouldBreakInVersion } from '../../../../../server/lib/shouldBreakInVersion'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; -import { safeGetMeteorUser } from '../../../../utils/server/functions/safeGetMeteorUser'; import { generatePassword } from '../../lib/generatePassword'; import { notifyOnUserChange } from '../../lib/notifyListener'; import { passwordPolicy } from '../../lib/passwordPolicy'; @@ -63,8 +62,19 @@ type SaveUserOptions = { auditStore?: UserChangedAuditStore; }; +const findUserById = async (uid: IUser['_id']): Promise => { + const user = await Users.findOneById(uid); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + + return user; +}; + const _saveUser = (session?: ClientSession) => async function (userId: IUser['_id'], userData: SaveUserData, options?: SaveUserOptions) { + const performedBy = await findUserById(userId); + const oldUserData = userData._id && (await Users.findOneById(userData._id)); if (oldUserData && isUserFederated(oldUserData)) { throw new Meteor.Error('Edit_Federated_User_Not_Allowed', 'Not possible to edit a federated user'); @@ -91,7 +101,7 @@ const _saveUser = (session?: ClientSession) => if (!isUpdateUserData(userData)) { // TODO audit new users - return saveNewUser(userData, sendPassword); + return saveNewUser(userData, sendPassword, performedBy); } if (!oldUserData) { @@ -125,7 +135,7 @@ const _saveUser = (session?: ClientSession) => } if (typeof userData.statusText === 'string') { - await setStatusText(userData._id, userData.statusText, { updater, session }); + await setStatusText(oldUserData, userData.statusText, { updater, session }); } if (userData.email) { @@ -212,7 +222,7 @@ const _saveUser = (session?: ClientSession) => await Apps.self?.triggerEvent(AppEvents.IPostUserUpdated, { user: userUpdated, previousUser: oldUserData, - performedBy: await safeGetMeteorUser(), + performedBy, }); if (sendPassword) { diff --git a/apps/meteor/app/lib/server/functions/saveUserIdentity.ts b/apps/meteor/app/lib/server/functions/saveUserIdentity.ts index e943e1b9128ef..aa7b186ccad69 100644 --- a/apps/meteor/app/lib/server/functions/saveUserIdentity.ts +++ b/apps/meteor/app/lib/server/functions/saveUserIdentity.ts @@ -3,7 +3,7 @@ import type { Updater } from '@rocket.chat/models'; import { Messages, VideoConference, LivechatDepartmentAgents, Rooms, Subscriptions, Users, CallHistory } from '@rocket.chat/models'; import type { ClientSession } from 'mongodb'; -import { _setRealName } from './setRealName'; +import { setRealName } from './setRealName'; import { _setUsername } from './setUsername'; import { updateGroupDMsName } from './updateGroupDMsName'; import { validateName } from './validateName'; @@ -65,7 +65,7 @@ export async function saveUserIdentity({ } if (typeof rawName !== 'undefined' && nameChanged) { - if (!(await _setRealName(_id, name, user, updater, session))) { + if (!(await setRealName(_id, name, user, updater, session))) { return false; } } @@ -88,7 +88,7 @@ export async function saveUserIdentity({ try { await updateUsernameReferences(handleUpdateParams); } catch (err) { - SystemLogger.error(err); + SystemLogger.error({ err }); } }); } else { diff --git a/apps/meteor/app/lib/server/functions/sendMessage.ts b/apps/meteor/app/lib/server/functions/sendMessage.ts index c41b52d959ad4..c6a8483b67b4d 100644 --- a/apps/meteor/app/lib/server/functions/sendMessage.ts +++ b/apps/meteor/app/lib/server/functions/sendMessage.ts @@ -4,7 +4,6 @@ import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; -import { parseUrlsInMessage } from './parseUrlsInMessage'; import { isRelativeURL } from '../../../../lib/utils/isRelativeURL'; import { isURL } from '../../../../lib/utils/isURL'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; @@ -14,6 +13,11 @@ import { afterSaveMessage } from '../lib/afterSaveMessage'; import { notifyOnRoomChangedById } from '../lib/notifyListener'; import { validateCustomMessageFields } from '../lib/validateCustomMessageFields'; +type SendMessageOptions = { + upsert?: boolean; + previewUrls?: string[]; +}; + // TODO: most of the types here are wrong, but I don't want to change them now /** @@ -217,7 +221,9 @@ export function prepareMessageObject( * Caller of the function should verify the Message_MaxAllowedSize if needed. * There might be same use cases which needs to override this setting. Example - sending error logs. */ -export const sendMessage = async function (user: any, message: any, room: any, upsert = false, previewUrls?: string[]) { +export const sendMessage = async function (user: any, message: any, room: any, options: SendMessageOptions = {}) { + const { upsert = false, previewUrls } = options; + if (!user || !message || !room._id) { return false; } @@ -250,9 +256,7 @@ export const sendMessage = async function (user: any, message: any, room: any, u } } - parseUrlsInMessage(message, previewUrls); - - message = await Message.beforeSave({ message, room, user }); + message = await Message.beforeSave({ message, room, user, previewUrls, parseUrls: message.parseUrls }); if (!message) { return; diff --git a/apps/meteor/app/lib/server/functions/setEmail.ts b/apps/meteor/app/lib/server/functions/setEmail.ts index 174b3893a9c3f..f9a911e85b3c3 100644 --- a/apps/meteor/app/lib/server/functions/setEmail.ts +++ b/apps/meteor/app/lib/server/functions/setEmail.ts @@ -6,10 +6,9 @@ import { Meteor } from 'meteor/meteor'; import type { ClientSession } from 'mongodb'; import { onceTransactionCommitedSuccessfully } from '../../../../server/database/utils'; -import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import * as Mailer from '../../../mailer/server/api'; import { settings } from '../../../settings/server'; -import { RateLimiter, validateEmailDomain } from '../lib'; +import { validateEmailDomain } from '../lib'; import { checkEmailAvailability } from './checkEmailAvailability'; import { sendConfirmationEmail } from '../../../../server/methods/sendConfirmationEmail'; @@ -42,7 +41,7 @@ const _sendEmailChangeNotification = async function (to: string, newEmail: strin } }; -const _setEmail = async function ( +export const setEmail = async function ( userId: string, email: string, shouldSendVerificationEmail = true, @@ -105,10 +104,3 @@ const _setEmail = async function ( } return result; }; - -export const setEmail = RateLimiter.limitFunction(_setEmail, 1, 60000, { - async 0() { - const userId = Meteor.userId(); - return !userId || !(await hasPermissionAsync(userId, 'edit-other-user-info')); - }, // Administrators have permission to change others emails, so don't limit those -}); diff --git a/apps/meteor/app/lib/server/functions/setRealName.ts b/apps/meteor/app/lib/server/functions/setRealName.ts index 530f828b2cf5e..d33aa01406230 100644 --- a/apps/meteor/app/lib/server/functions/setRealName.ts +++ b/apps/meteor/app/lib/server/functions/setRealName.ts @@ -2,15 +2,12 @@ import { api } from '@rocket.chat/core-services'; import type { IUser } from '@rocket.chat/core-typings'; import type { Updater } from '@rocket.chat/models'; import { Users } from '@rocket.chat/models'; -import { Meteor } from 'meteor/meteor'; import type { ClientSession } from 'mongodb'; import { onceTransactionCommitedSuccessfully } from '../../../../server/database/utils'; -import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { settings } from '../../../settings/server'; -import { RateLimiter } from '../lib'; -export const _setRealName = async function ( +export const setRealName = async function ( userId: string, name: string, fullUser?: IUser, @@ -65,10 +62,3 @@ export const _setRealName = async function ( return user; }; - -export const setRealName = RateLimiter.limitFunction(_setRealName, 1, 60000, { - async 0() { - const userId = Meteor.userId(); - return !userId || !(await hasPermissionAsync(userId, 'edit-other-user-info')); - }, // Administrators have permission to change others names, so don't limit those -}); diff --git a/apps/meteor/app/lib/server/functions/setStatusText.ts b/apps/meteor/app/lib/server/functions/setStatusText.ts index 8a5276d584ead..29b4a8ad72812 100644 --- a/apps/meteor/app/lib/server/functions/setStatusText.ts +++ b/apps/meteor/app/lib/server/functions/setStatusText.ts @@ -2,15 +2,12 @@ import { api } from '@rocket.chat/core-services'; import type { IUser } from '@rocket.chat/core-typings'; import type { Updater } from '@rocket.chat/models'; import { Users } from '@rocket.chat/models'; -import { Meteor } from 'meteor/meteor'; import type { ClientSession } from 'mongodb'; import { onceTransactionCommitedSuccessfully } from '../../../../server/database/utils'; -import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { RateLimiter } from '../lib'; -async function _setStatusText( - userId: string, +export async function setStatusText( + user: Pick, statusText: string, { updater, @@ -22,21 +19,8 @@ async function _setStatusText( emit?: boolean; } = {}, ): Promise { - if (!userId) { - return false; - } - statusText = statusText.trim().substr(0, 120); - const user = await Users.findOneById>(userId, { - projection: { username: 1, name: 1, status: 1, roles: 1, statusText: 1 }, - session, - }); - - if (!user) { - return false; - } - if (user.statusText === statusText) { return true; } @@ -59,11 +43,3 @@ async function _setStatusText( return true; } - -export const setStatusText = RateLimiter.limitFunction(_setStatusText, 5, 60000, { - async 0() { - // Administrators have permission to change others status, so don't limit those - const userId = Meteor.userId(); - return !userId || !(await hasPermissionAsync(userId, 'edit-other-user-info')); - }, -}); diff --git a/apps/meteor/app/lib/server/functions/setUserAvatar.ts b/apps/meteor/app/lib/server/functions/setUserAvatar.ts index 85e12ef044f44..2e4c07b535ed9 100644 --- a/apps/meteor/app/lib/server/functions/setUserAvatar.ts +++ b/apps/meteor/app/lib/server/functions/setUserAvatar.ts @@ -7,7 +7,6 @@ import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { Meteor } from 'meteor/meteor'; import type { ClientSession } from 'mongodb'; -import { checkUrlForSsrf } from './checkUrlForSsrf'; import { onceTransactionCommitedSuccessfully } from '../../../../server/database/utils'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; @@ -103,16 +102,11 @@ export async function setUserAvatar( if (service === 'url' && typeof dataURI === 'string') { let response: Response; - const isSsrfSafe = await checkUrlForSsrf(dataURI); - if (!isSsrfSafe) { - throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${encodeURI(dataURI)}`, { - function: 'setUserAvatar', - url: dataURI, - }); - } - try { - response = await fetch(dataURI, { redirect: 'error' }); + response = await fetch(dataURI, { + ignoreSsrfValidation: false, + allowList: settings.get('SSRF_Allowlist'), + }); } catch (e) { SystemLogger.info({ msg: 'Not a valid response from the avatar url', diff --git a/apps/meteor/app/lib/server/functions/setUsername.ts b/apps/meteor/app/lib/server/functions/setUsername.ts index ad0364d5617a0..9eeff3a14a0e3 100644 --- a/apps/meteor/app/lib/server/functions/setUsername.ts +++ b/apps/meteor/app/lib/server/functions/setUsername.ts @@ -123,8 +123,8 @@ export const _setUsername = async function ( setImmediate(() => { Accounts.sendEnrollmentEmail(user._id); }); - } catch (e: any) { - SystemLogger.error(e); + } catch (err: any) { + SystemLogger.error({ err }); } }, session); } diff --git a/apps/meteor/app/lib/server/functions/updateMessage.ts b/apps/meteor/app/lib/server/functions/updateMessage.ts index baf2628e73394..7f089f6872975 100644 --- a/apps/meteor/app/lib/server/functions/updateMessage.ts +++ b/apps/meteor/app/lib/server/functions/updateMessage.ts @@ -4,14 +4,18 @@ import type { IMessage, IUser, AtLeast } from '@rocket.chat/core-typings'; import { Messages, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { parseUrlsInMessage } from './parseUrlsInMessage'; import { settings } from '../../../settings/server'; import { afterSaveMessage } from '../lib/afterSaveMessage'; import { notifyOnRoomChangedById } from '../lib/notifyListener'; import { validateCustomMessageFields } from '../lib/validateCustomMessageFields'; export const updateMessage = async function ( - message: AtLeast | AtLeast, + { + parseUrls, + ...message + }: (AtLeast | AtLeast) & { + parseUrls?: boolean; + }, user: IUser, originalMsg?: IMessage, previewUrls?: string[], @@ -51,14 +55,12 @@ export const updateMessage = async function ( }, }); - parseUrlsInMessage(messageData, previewUrls); - const room = await Rooms.findOneById(messageData.rid); if (!room) { return; } - messageData = await Message.beforeSave({ message: messageData, room, user }); + messageData = await Message.beforeSave({ message: messageData, room, user, previewUrls, parseUrls }); if (messageData.customFields) { validateCustomMessageFields({ diff --git a/apps/meteor/app/lib/server/lib/RateLimiter.js b/apps/meteor/app/lib/server/lib/RateLimiter.js index e1986e49e81eb..cdb32299263d2 100644 --- a/apps/meteor/app/lib/server/lib/RateLimiter.js +++ b/apps/meteor/app/lib/server/lib/RateLimiter.js @@ -1,90 +1,6 @@ import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; -import { Meteor } from 'meteor/meteor'; -import { RateLimiter } from 'meteor/rate-limit'; export const RateLimiterClass = new (class { - limitFunction(fn, numRequests, timeInterval, matchers) { - if (process.env.TEST_MODE === 'true') { - return fn; - } - const rateLimiter = new (class extends RateLimiter { - async check(input) { - const reply = { - allowed: true, - timeToReset: 0, - numInvocationsLeft: Infinity, - }; - - const matchedRules = this._findAllMatchingRules(input); - - for await (const rule of matchedRules) { - const ruleResult = await rule.apply(input); - let numInvocations = rule.counters[ruleResult.key]; - - if (ruleResult.timeToNextReset < 0) { - // Reset all the counters since the rule has reset - await rule.resetCounter(); - ruleResult.timeSinceLastReset = new Date().getTime() - rule._lastResetTime; - ruleResult.timeToNextReset = rule.options.intervalTime; - numInvocations = 0; - } - - if (numInvocations > rule.options.numRequestsAllowed) { - // Only update timeToReset if the new time would be longer than the - // previously set time. This is to ensure that if this input triggers - // multiple rules, we return the longest period of time until they can - // successfully make another call - if (reply.timeToReset < ruleResult.timeToNextReset) { - reply.timeToReset = ruleResult.timeToNextReset; - } - reply.allowed = false; - reply.numInvocationsLeft = 0; - reply.ruleId = rule.id; - await rule._executeCallback(reply, input); - } else { - // If this is an allowed attempt and we haven't failed on any of the - // other rules that match, update the reply field. - if (rule.options.numRequestsAllowed - numInvocations < reply.numInvocationsLeft && reply.allowed) { - reply.timeToReset = ruleResult.timeToNextReset; - reply.numInvocationsLeft = rule.options.numRequestsAllowed - numInvocations; - } - reply.ruleId = rule.id; - await rule._executeCallback(reply, input); - } - } - return reply; - } - })(); - Object.entries(matchers).forEach(([key, matcher]) => { - matchers[key] = matcher; - }); - - rateLimiter.addRule(matchers, numRequests, timeInterval); - return async function (...args) { - const match = {}; - - Object.keys(matchers).forEach((key) => { - match[key] = args[key]; - }); - - rateLimiter.increment(match); - const rateLimitResult = await rateLimiter.check(match); - if (rateLimitResult.allowed) { - return fn.apply(null, args); - } - throw new Meteor.Error( - 'error-too-many-requests', - `Error, too many requests. Please slow down. You must wait ${Math.ceil( - rateLimitResult.timeToReset / 1000, - )} seconds before trying again.`, - { - timeToReset: rateLimitResult.timeToReset, - seconds: Math.ceil(rateLimitResult.timeToReset / 1000), - }, - ); - }; - } - limitMethod(methodName, numRequests, timeInterval, matchers) { if (process.env.TEST_MODE === 'true') { return; diff --git a/apps/meteor/app/lib/server/lib/index.ts b/apps/meteor/app/lib/server/lib/index.ts index 9a5ee594a5a1e..1794b9927aff4 100644 --- a/apps/meteor/app/lib/server/lib/index.ts +++ b/apps/meteor/app/lib/server/lib/index.ts @@ -6,7 +6,6 @@ library files. */ import './notifyUsersOnMessage'; -import './meteorFixes'; export { sendNotification } from './sendNotificationsOnMessage'; export { passwordPolicy } from './passwordPolicy'; diff --git a/apps/meteor/app/lib/server/lib/meteorFixes.js b/apps/meteor/app/lib/server/lib/meteorFixes.js deleted file mode 100644 index dd0764c468263..0000000000000 --- a/apps/meteor/app/lib/server/lib/meteorFixes.js +++ /dev/null @@ -1,44 +0,0 @@ -import { MongoInternals } from 'meteor/mongo'; - -const timeoutQuery = parseInt(process.env.OBSERVERS_CHECK_TIMEOUT) || 2 * 60 * 1000; -const interval = parseInt(process.env.OBSERVERS_CHECK_INTERVAL) || 60 * 1000; -const debug = Boolean(process.env.OBSERVERS_CHECK_DEBUG); - -/** - * When the Observer Driver stuck in QUERYING status it stop processing records - * here https://github.com/meteor/meteor/blob/be6e529a739f47446950e045f4547ee60e5de7ae/packages/mongo/oplog_observe_driver.js#L166 - * and nothing is able to change the status back to STEADY. - * If this happens with the User's collection the frontend will freeze after login with username/password or resume token - * waiting the 'update' response from DDP - * here https://github.com/meteor/meteor/blob/be6e529a739f47446950e045f4547ee60e5de7ae/packages/ddp-server/livedata_server.js#L663 - * since the login is a block request and wait for the update to execute next calls. - * - * A good way to freeze a observer is running the instance with --inspect and execute in inspector the following code: - * multiplexer = Object.values(MongoInternals.defaultRemoteCollectionDriver().mongo._observeMultiplexers)[0] - * multiplexer._observeDriver._needToPollQuery() - * This will raise an error of bindEnvironment and block the observer - * here https://github.com/meteor/meteor/blob/be6e529a739f47446950e045f4547ee60e5de7ae/packages/mongo/oplog_observe_driver.js#L698 - * - * This code will check for observer instances in QUERYING mode for more than 2 minutes and will manually set them back - * to STEADY and force the query again to refresh the data and flush the _writesToCommitWhenWeReachSteady callbacks. - */ - -setInterval(() => { - if (debug) { - console.log('Checking for stuck observers'); - } - const now = Date.now(); - const driver = MongoInternals.defaultRemoteCollectionDriver(); - - Object.entries(driver.mongo._observeMultiplexers) - .filter(([, { _observeDriver }]) => _observeDriver._phase === 'QUERYING' && timeoutQuery < now - _observeDriver._phaseStartTime) - .forEach(([observeKey, { _observeDriver }]) => { - console.error('TIMEOUT QUERY OPERATION', { - observeKey, - writesToCommitWhenWeReachSteadyLength: _observeDriver._writesToCommitWhenWeReachSteady.length, - cursorDescription: JSON.stringify(_observeDriver._cursorDescription), - }); - _observeDriver._registerPhaseChange('STEADY'); - _observeDriver._needToPollQuery(); - }); -}, interval); diff --git a/apps/meteor/app/lib/server/lib/validateCustomMessageFields.ts b/apps/meteor/app/lib/server/lib/validateCustomMessageFields.ts index b0126fa07ed67..62d6e518c3b64 100644 --- a/apps/meteor/app/lib/server/lib/validateCustomMessageFields.ts +++ b/apps/meteor/app/lib/server/lib/validateCustomMessageFields.ts @@ -1,8 +1,6 @@ -import Ajv from 'ajv'; +import { ajv } from '@rocket.chat/rest-typings'; import mem from 'mem'; -const ajv = new Ajv(); - const customFieldsValidate = mem( (customFieldsSetting: string) => { const schema = JSON.parse(customFieldsSetting); diff --git a/apps/meteor/app/lib/server/methods/saveSetting.ts b/apps/meteor/app/lib/server/methods/saveSetting.ts index ed48dc4e9f4fc..044a618dacac0 100644 --- a/apps/meteor/app/lib/server/methods/saveSetting.ts +++ b/apps/meteor/app/lib/server/methods/saveSetting.ts @@ -1,4 +1,4 @@ -import type { SettingValue } from '@rocket.chat/core-typings'; +import type { SettingEditor, SettingValue } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Settings } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; @@ -14,12 +14,12 @@ import { notifyOnSettingChanged } from '../lib/notifyListener'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { - saveSetting(_id: string, value: SettingValue, editor?: string): Promise; + saveSetting(_id: string, value: SettingValue, editor: SettingEditor): Promise; } } Meteor.methods({ - saveSetting: twoFactorRequired(async function (_id, value, editor) { + saveSetting: twoFactorRequired(async function (_id: string, value: SettingValue, editor: SettingEditor) { const uid = Meteor.userId(); if (!uid) { throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { diff --git a/apps/meteor/app/lib/server/methods/sendMessage.ts b/apps/meteor/app/lib/server/methods/sendMessage.ts index db7a017ee7a01..895ccc27a5b87 100644 --- a/apps/meteor/app/lib/server/methods/sendMessage.ts +++ b/apps/meteor/app/lib/server/methods/sendMessage.ts @@ -106,7 +106,7 @@ export async function executeSendMessage( } metrics.messagesSent.inc(); // TODO This line needs to be moved to it's proper place. See the comments on: https://github.com/RocketChat/Rocket.Chat/pull/5736 - return await sendMessage(user, message, room, false, extraInfo?.previewUrls); + return await sendMessage(user, message, room, { previewUrls: extraInfo?.previewUrls }); } catch (err: any) { SystemLogger.error({ msg: 'Error sending message:', err }); diff --git a/apps/meteor/app/lib/server/methods/setRealName.ts b/apps/meteor/app/lib/server/methods/setRealName.ts index f347eef1580ef..2a1bac15b63bc 100644 --- a/apps/meteor/app/lib/server/methods/setRealName.ts +++ b/apps/meteor/app/lib/server/methods/setRealName.ts @@ -16,8 +16,8 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async setRealName(name) { check(name, String); - - if (!Meteor.userId()) { + const userId = Meteor.userId(); + if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setRealName' }); } @@ -25,7 +25,7 @@ Meteor.methods({ throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setRealName' }); } - if (!(await setRealName(Meteor.userId(), name))) { + if (!(await setRealName(userId, name))) { throw new Meteor.Error('error-could-not-change-name', 'Could not change name', { method: 'setRealName', }); diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.ts b/apps/meteor/app/livechat/imports/server/rest/sms.ts index b271194c1f05c..e0fdf587e60e3 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/sms.ts @@ -18,7 +18,6 @@ import { Meteor } from 'meteor/meteor'; import { getFileExtension } from '../../../../../lib/utils/getFileExtension'; import { API } from '../../../../api/server'; import { FileUpload } from '../../../../file-upload/server'; -import { checkUrlForSsrf } from '../../../../lib/server/functions/checkUrlForSsrf'; import { settings } from '../../../../settings/server'; import { setCustomField } from '../../../server/api/lib/customFields'; import type { ILivechatMessage } from '../../../server/lib/localTypes'; @@ -28,12 +27,10 @@ import { createRoom } from '../../../server/lib/rooms'; const logger = new Logger('SMS'); const getUploadFile = async (details: Omit, fileUrl: string) => { - const isSsrfSafe = await checkUrlForSsrf(fileUrl); - if (!isSsrfSafe) { - throw new Meteor.Error('error-invalid-url', 'Invalid URL'); - } - - const response = await fetch(fileUrl, { redirect: 'error' }); + const response = await fetch(fileUrl, { + ignoreSsrfValidation: false, + allowList: settings.get('SSRF_Allowlist'), + }); const content = Buffer.from(await response.arrayBuffer()); diff --git a/apps/meteor/app/livechat/server/api/v1/customField.ts b/apps/meteor/app/livechat/server/api/v1/customField.ts index c4e3130a6f014..42b334c45c407 100644 --- a/apps/meteor/app/livechat/server/api/v1/customField.ts +++ b/apps/meteor/app/livechat/server/api/v1/customField.ts @@ -111,10 +111,6 @@ const livechatCustomFieldsEndpoints = API.v1 async function action() { const { customFieldId, customFieldData } = this.bodyParams; - if (!/^[0-9a-zA-Z-_]+$/.test(customFieldId)) { - return API.v1.failure('Invalid custom field name. Use only letters, numbers, hyphens and underscores.'); - } - if (customFieldId) { const customField = await LivechatCustomField.findOneById(customFieldId); if (!customField) { diff --git a/apps/meteor/app/livechat/server/api/v1/webhooks.ts b/apps/meteor/app/livechat/server/api/v1/webhooks.ts index 917fbeeb43dbc..4a5fdb50f7e44 100644 --- a/apps/meteor/app/livechat/server/api/v1/webhooks.ts +++ b/apps/meteor/app/livechat/server/api/v1/webhooks.ts @@ -1,4 +1,5 @@ import { Logger } from '@rocket.chat/logger'; +import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { API } from '../../../../api/server'; @@ -63,7 +64,9 @@ API.v1.addRoute( 'Accept': 'application/json', }, body: sampleData, - }; + // SECURITY: Webhooks can only be configured by users with enough privileges. It's ok to disable this check here. + ignoreSsrfValidation: true, + } as ExtendedFetchOptions; const webhookUrl = settings.get('Livechat_webhookUrl'); diff --git a/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts b/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts index e83bb6bbf37a3..f7302f42cfb8d 100644 --- a/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts +++ b/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts @@ -223,11 +223,21 @@ export class BusinessHourManager { } private async openWorkHoursCallback(day: string, hour: string): Promise { - return this.behavior.openBusinessHoursByDayAndHour(day, hour); + try { + return await this.behavior.openBusinessHoursByDayAndHour(day, hour); + } catch (err) { + businessHourLogger.error({ msg: 'Error while opening business hours', err }); + throw err; + } } private async closeWorkHoursCallback(day: string, hour: string): Promise { - return this.behavior.closeBusinessHoursByDayAndHour(day, hour); + try { + return await this.behavior.closeBusinessHoursByDayAndHour(day, hour); + } catch (err) { + businessHourLogger.error({ msg: 'Error while closing business hours', err }); + throw err; + } } private getBusinessHourType(type: string): IBusinessHourType | undefined { diff --git a/apps/meteor/app/livechat/server/business-hour/Helper.ts b/apps/meteor/app/livechat/server/business-hour/Helper.ts index e5b16865d8f91..b18863ead06aa 100644 --- a/apps/meteor/app/livechat/server/business-hour/Helper.ts +++ b/apps/meteor/app/livechat/server/business-hour/Helper.ts @@ -22,25 +22,29 @@ export const filterBusinessHoursThatMustBeOpenedByDay = async ( }; export const openBusinessHourDefault = async (): Promise => { - await Users.removeBusinessHoursFromAllUsers(); - const currentTime = moment(moment().format('dddd:HH:mm'), 'dddd:HH:mm'); - const day = currentTime.format('dddd'); - const activeBusinessHours = await LivechatBusinessHours.findDefaultActiveAndOpenBusinessHoursByDay(day, { - projection: { - workHours: 1, - timezone: 1, - type: 1, - active: 1, - }, - }); + try { + await Users.removeBusinessHoursFromAllUsers(); + const currentTime = moment(moment().format('dddd:HH:mm'), 'dddd:HH:mm'); + const day = currentTime.format('dddd'); + const activeBusinessHours = await LivechatBusinessHours.findDefaultActiveAndOpenBusinessHoursByDay(day, { + projection: { + workHours: 1, + timezone: 1, + type: 1, + active: 1, + }, + }); - const businessHoursToOpenIds = (await filterBusinessHoursThatMustBeOpened(activeBusinessHours)).map((businessHour) => businessHour._id); - businessHourLogger.debug({ msg: 'Opening default business hours', businessHoursToOpenIds }); - await Users.openAgentsBusinessHoursByBusinessHourId(businessHoursToOpenIds); - if (businessHoursToOpenIds.length) { - await makeOnlineAgentsAvailable(); + const businessHoursToOpenIds = (await filterBusinessHoursThatMustBeOpened(activeBusinessHours)).map((businessHour) => businessHour._id); + businessHourLogger.debug({ msg: 'Opening default business hours', businessHoursToOpenIds }); + await Users.openAgentsBusinessHoursByBusinessHourId(businessHoursToOpenIds); + if (businessHoursToOpenIds.length) { + await makeOnlineAgentsAvailable(); + } + await makeAgentsUnavailableBasedOnBusinessHour(); + } catch (err) { + businessHourLogger.error({ msg: 'Error while opening default business hours', err }); } - await makeAgentsUnavailableBasedOnBusinessHour(); }; export const createDefaultBusinessHourIfNotExists = async (): Promise => { diff --git a/apps/meteor/app/livechat/server/business-hour/Single.ts b/apps/meteor/app/livechat/server/business-hour/Single.ts index e427db9d2f59f..ddd8d4d09e9c5 100644 --- a/apps/meteor/app/livechat/server/business-hour/Single.ts +++ b/apps/meteor/app/livechat/server/business-hour/Single.ts @@ -9,7 +9,7 @@ import { businessHourLogger } from '../lib/logger'; export class SingleBusinessHourBehavior extends AbstractBusinessHourBehavior implements IBusinessHourBehavior { async openBusinessHoursByDayAndHour(): Promise { - return openBusinessHourDefault(); + await openBusinessHourDefault(); } async closeBusinessHoursByDayAndHour(day: string, hour: string): Promise { @@ -24,7 +24,7 @@ export class SingleBusinessHourBehavior extends AbstractBusinessHourBehavior imp } async onStartBusinessHours(): Promise { - return openBusinessHourDefault(); + await openBusinessHourDefault(); } async onNewAgentCreated(agentId: string): Promise { diff --git a/apps/meteor/app/livechat/server/hooks/offlineMessageToChannel.ts b/apps/meteor/app/livechat/server/hooks/offlineMessageToChannel.ts index f15c7d717b8f3..c2ba5edbf314e 100644 --- a/apps/meteor/app/livechat/server/hooks/offlineMessageToChannel.ts +++ b/apps/meteor/app/livechat/server/hooks/offlineMessageToChannel.ts @@ -63,7 +63,7 @@ callbacks.add( groupable: false, }; - await sendMessage(user, message, room, true); + await sendMessage(user, message, room, { upsert: true }); }, callbacks.priority.MEDIUM, 'livechat-send-email-offline-message-to-channel', diff --git a/apps/meteor/app/livechat/server/lib/RoutingManager.ts b/apps/meteor/app/livechat/server/lib/RoutingManager.ts index 56f4f6b8a3ad6..8c6fb51c08b2d 100644 --- a/apps/meteor/app/livechat/server/lib/RoutingManager.ts +++ b/apps/meteor/app/livechat/server/lib/RoutingManager.ts @@ -28,6 +28,7 @@ import { updateChatDepartment, allowAgentSkipQueue, } from './Helper'; +import { conditionalLockAgent } from './conditionalLockAgent'; import { afterTakeInquiry, beforeDelegateAgent } from './hooks'; import { callbacks } from '../../../../server/lib/callbacks'; import { notifyOnLivechatInquiryChangedById, notifyOnLivechatInquiryChanged } from '../../../lib/server/lib/notifyListener'; @@ -257,19 +258,35 @@ export const RoutingManager: Routing = { return room; } - try { - await callbacks.run('livechat.checkAgentBeforeTakeInquiry', { - agent, - inquiry, - options, + const lock = await conditionalLockAgent(agent.agentId); + if (!lock.acquired && lock.required) { + logger.debug({ + msg: 'Cannot take inquiry because agent is currently locked by another process', + agentId: agent.agentId, + inquiryId: _id, }); - } catch (e) { if (options.clientAction && !options.forwardingToDepartment) { - throw e; + throw new Error('error-agent-is-locked'); } agent = null; } + if (agent) { + try { + await callbacks.run('livechat.checkAgentBeforeTakeInquiry', { + agent, + inquiry, + options, + }); + } catch (e) { + await lock.unlock(); + if (options.clientAction && !options.forwardingToDepartment) { + throw e; + } + agent = null; + } + } + if (!agent) { logger.debug({ msg: 'Cannot take inquiry. Precondition failed for agent', inquiryId: inquiry._id }); const cbRoom = await callbacks.run<'livechat.onAgentAssignmentFailed'>('livechat.onAgentAssignmentFailed', room, { @@ -279,35 +296,39 @@ export const RoutingManager: Routing = { return cbRoom; } - const result = await LivechatInquiry.takeInquiry(_id, inquiry.lockedAt); - if (result.modifiedCount === 0) { - logger.error({ msg: 'Failed to take inquiry, could not match lockedAt', inquiryId: _id, lockedAt: inquiry.lockedAt }); - throw new Error('error-taking-inquiry-lockedAt-mismatch'); - } + try { + const result = await LivechatInquiry.takeInquiry(_id, inquiry.lockedAt); + if (result.modifiedCount === 0) { + logger.error({ msg: 'Failed to take inquiry because lockedAt did not match', inquiryId: _id, lockedAt: inquiry.lockedAt }); + throw new Error('error-taking-inquiry-lockedAt-mismatch'); + } - logger.info({ msg: 'Inquiry taken by agent', inquiryId: inquiry._id, agentId: agent.agentId }); + logger.info({ msg: 'Inquiry taken', inquiryId: _id, agentId: agent.agentId }); - // assignAgent changes the room data to add the agent serving the conversation. afterTakeInquiry expects room object to be updated - const { inquiry: returnedInquiry, user } = await this.assignAgent(inquiry as InquiryWithAgentInfo, agent); - const roomAfterUpdate = await LivechatRooms.findOneById(rid); + // assignAgent changes the room data to add the agent serving the conversation. afterTakeInquiry expects room object to be updated + const { inquiry: returnedInquiry, user } = await this.assignAgent(inquiry, agent); + const roomAfterUpdate = await LivechatRooms.findOneById(rid); - if (!roomAfterUpdate) { - // This should never happen - throw new Error('error-room-not-found'); - } + if (!roomAfterUpdate) { + // This should never happen + throw new Error('error-room-not-found'); + } - void Apps.self?.triggerEvent(AppEvents.IPostLivechatAgentAssigned, { room: roomAfterUpdate, user }); - void afterTakeInquiry({ inquiry: returnedInquiry, room: roomAfterUpdate, agent }); + void Apps.self?.triggerEvent(AppEvents.IPostLivechatAgentAssigned, { room: roomAfterUpdate, user }); + void afterTakeInquiry({ inquiry: returnedInquiry, room: roomAfterUpdate, agent }); - void notifyOnLivechatInquiryChangedById(inquiry._id, 'updated', { - status: LivechatInquiryStatus.TAKEN, - takenAt: new Date(), - defaultAgent: undefined, - estimatedInactivityCloseTimeAt: undefined, - queuedAt: undefined, - }); + void notifyOnLivechatInquiryChangedById(inquiry._id, 'updated', { + status: LivechatInquiryStatus.TAKEN, + takenAt: new Date(), + defaultAgent: undefined, + estimatedInactivityCloseTimeAt: undefined, + queuedAt: undefined, + }); - return roomAfterUpdate; + return roomAfterUpdate; + } finally { + await lock.unlock(); + } }, async transferRoom(room, guest, transferData) { diff --git a/apps/meteor/app/livechat/server/lib/conditionalLockAgent.ts b/apps/meteor/app/livechat/server/lib/conditionalLockAgent.ts new file mode 100644 index 0000000000000..44f4d416c18a1 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/conditionalLockAgent.ts @@ -0,0 +1,35 @@ +import { Users } from '@rocket.chat/models'; + +import { settings } from '../../../settings/server'; + +type LockResult = { + acquired: boolean; + required: boolean; + unlock: () => Promise; +}; + +export async function conditionalLockAgent(agentId: string): Promise { + // Lock and chats limits enforcement are only required when waiting_queue is enabled + const shouldLock = settings.get('Livechat_waiting_queue'); + + if (!shouldLock) { + return { + acquired: false, + required: false, + unlock: async () => { + // no-op + }, + }; + } + + const lockTime = new Date(); + const lockAcquired = await Users.acquireAgentLock(agentId, lockTime); + + return { + acquired: !!lockAcquired, + required: true, + unlock: async () => { + await Users.releaseAgentLock(agentId, lockTime); + }, + }; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/mapVisitorToContact.spec.ts b/apps/meteor/app/livechat/server/lib/contacts/mapVisitorToContact.spec.ts index c8c15fe0d0d9c..07a10f6f5236a 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/mapVisitorToContact.spec.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/mapVisitorToContact.spec.ts @@ -27,6 +27,7 @@ const dataMap: [Partial, IOmnichannelSource, CreateContactPara contactManager: { username: 'user1', }, + lastChat: { _id: 'afdsfdasf', ts: testDate }, }, { type: OmnichannelSourceType.WIDGET, @@ -50,8 +51,10 @@ const dataMap: [Partial, IOmnichannelSource, CreateContactPara details: { type: OmnichannelSourceType.WIDGET, }, + lastChat: { _id: 'afdsfdasf', ts: testDate }, }, ], + lastChat: { _id: 'afdsfdasf', ts: testDate }, customFields: undefined, shouldValidateCustomFields: false, contactManager: 'manager1', @@ -62,6 +65,7 @@ const dataMap: [Partial, IOmnichannelSource, CreateContactPara { _id: 'visitor1', username: 'Username', + lastChat: { _id: 'afdsfdasf', ts: testDate }, }, { type: OmnichannelSourceType.SMS, @@ -85,11 +89,13 @@ const dataMap: [Partial, IOmnichannelSource, CreateContactPara details: { type: OmnichannelSourceType.SMS, }, + lastChat: { _id: 'afdsfdasf', ts: testDate }, }, ], customFields: undefined, shouldValidateCustomFields: false, contactManager: undefined, + lastChat: { _id: 'afdsfdasf', ts: testDate }, }, ], @@ -113,7 +119,7 @@ const dataMap: [Partial, IOmnichannelSource, CreateContactPara unknown: false, channels: [ { - name: 'sms', + name: 'widget', visitor: { visitorId: 'visitor1', source: { @@ -150,6 +156,7 @@ const dataMap: [Partial, IOmnichannelSource, CreateContactPara invalidCustomFieldId: 'invalidCustomFieldValue', }, activity: [], + lastChat: { _id: 'afdsfdasf', ts: testDate }, }, { type: OmnichannelSourceType.WIDGET, @@ -161,7 +168,7 @@ const dataMap: [Partial, IOmnichannelSource, CreateContactPara unknown: true, channels: [ { - name: 'sms', + name: 'widget', visitor: { visitorId: 'visitor1', source: { @@ -173,6 +180,7 @@ const dataMap: [Partial, IOmnichannelSource, CreateContactPara details: { type: OmnichannelSourceType.WIDGET, }, + lastChat: { _id: 'afdsfdasf', ts: testDate }, }, ], customFields: { @@ -180,6 +188,7 @@ const dataMap: [Partial, IOmnichannelSource, CreateContactPara }, shouldValidateCustomFields: false, contactManager: undefined, + lastChat: { _id: 'afdsfdasf', ts: testDate }, }, ], ]; @@ -197,10 +206,9 @@ describe('mapVisitorToContact', () => { getAllowedCustomFields.resolves([{ _id: 'customFieldId', label: 'custom-field-label' }]); }); - const index = 0; - for (const [visitor, source, contact] of dataMap) { + dataMap.forEach(([visitor, source, contact], index) => { it(`should map an ILivechatVisitor + IOmnichannelSource to an ILivechatContact [${index}]`, async () => { expect(await mapVisitorToContact(visitor, source)).to.be.deep.equal(contact); }); - } + }); }); diff --git a/apps/meteor/app/livechat/server/lib/contacts/patchContact.ts b/apps/meteor/app/livechat/server/lib/contacts/patchContact.ts new file mode 100644 index 0000000000000..75c94b9c3cf22 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/patchContact.ts @@ -0,0 +1,14 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; + +export const patchContact = async ( + contactId: ILivechatContact['_id'], + data: { set?: Partial; unset?: Partial> }, +): Promise => { + const { set = {}, unset = {} } = data; + + if (Object.keys(set).length === 0 && Object.keys(unset).length === 0) { + return LivechatContacts.findOneEnabledById(contactId); + } + return LivechatContacts.patchContact(contactId, data); +}; diff --git a/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.spec.ts b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.spec.ts index 9694c8f7e932a..0974465f68470 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.spec.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.spec.ts @@ -6,7 +6,7 @@ import sinon from 'sinon'; const modelsMock = { LivechatContacts: { findOneEnabledById: sinon.stub(), - updateContact: sinon.stub(), + patchContact: sinon.stub(), }, Settings: { incrementValueById: sinon.stub(), @@ -15,18 +15,26 @@ const modelsMock = { const validateContactManagerMock = sinon.stub(); +const { patchContact } = proxyquire.noCallThru().load('./patchContact.ts', { + '@rocket.chat/models': modelsMock, +}); + const { resolveContactConflicts } = proxyquire.noCallThru().load('./resolveContactConflicts', { '@rocket.chat/models': modelsMock, './validateContactManager': { validateContactManager: validateContactManagerMock, }, + './patchContact': { + patchContact, + }, }); describe('resolveContactConflicts', () => { beforeEach(() => { modelsMock.LivechatContacts.findOneEnabledById.reset(); modelsMock.Settings.incrementValueById.reset(); - modelsMock.LivechatContacts.updateContact.reset(); + modelsMock.LivechatContacts.patchContact.reset(); + validateContactManagerMock.reset(); }); it('should update the contact with the resolved custom field', async () => { @@ -36,25 +44,22 @@ describe('resolveContactConflicts', () => { conflictingFields: [{ field: 'customFields.customField', value: 'oldValue' }], }); modelsMock.Settings.incrementValueById.resolves(1); - modelsMock.LivechatContacts.updateContact.resolves({ + modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId', - customField: { customField: 'newValue' }, - conflictingFields: [{ field: 'customFields.customField', value: 'oldValue' }], + customFields: { customField: 'newestValue' }, + conflictingFields: [], } as Partial); - const result = await resolveContactConflicts({ contactId: 'contactId', customField: { customField: 'newValue' } }); + await resolveContactConflicts({ contactId: 'contactId', customFields: { customField: 'newestValue' } }); expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.Settings.incrementValueById.getCall(0).args[0]).to.be.equal('Livechat_conflicting_fields_counter'); - expect(modelsMock.Settings.incrementValueById.getCall(0).args[1]).to.be.equal(1); - - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[1]).to.be.deep.contain({ customFields: { customField: 'newValue' } }); - expect(result).to.be.deep.equal({ - _id: 'contactId', - customField: { customField: 'newValue' }, - conflictingFields: [], + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ + set: { + customFields: { customField: 'newestValue' }, + conflictingFields: [], + }, }); }); @@ -66,27 +71,23 @@ describe('resolveContactConflicts', () => { conflictingFields: [{ field: 'name', value: 'Old Name' }], }); modelsMock.Settings.incrementValueById.resolves(1); - modelsMock.LivechatContacts.updateContact.resolves({ + modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId', name: 'New Name', - customField: { customField: 'newValue' }, + customFields: { customField: 'newValue' }, conflictingFields: [], } as Partial); - const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name' }); + await resolveContactConflicts({ contactId: 'contactId', name: 'New Name' }); expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.Settings.incrementValueById.getCall(0).args[0]).to.be.equal('Livechat_conflicting_fields_counter'); - expect(modelsMock.Settings.incrementValueById.getCall(0).args[1]).to.be.equal(1); - - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[1]).to.be.deep.contain({ name: 'New Name' }); - expect(result).to.be.deep.equal({ - _id: 'contactId', - name: 'New Name', - customField: { customField: 'newValue' }, - conflictingFields: [], + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ + set: { + name: 'New Name', + conflictingFields: [], + }, }); }); @@ -96,114 +97,117 @@ describe('resolveContactConflicts', () => { name: 'Name', contactManager: 'contactManagerId', customFields: { customField: 'value' }, - conflictingFields: [{ field: 'manager', value: 'newContactManagerId' }], + conflictingFields: [{ field: 'manager', value: 'oldManagerId' }], }); + validateContactManagerMock.resolves(); modelsMock.Settings.incrementValueById.resolves(1); - modelsMock.LivechatContacts.updateContact.resolves({ + modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId', name: 'Name', contactManager: 'newContactManagerId', - customField: { customField: 'value' }, + customFields: { customField: 'value' }, conflictingFields: [], } as Partial); - const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name' }); + await resolveContactConflicts({ contactId: 'contactId', contactManager: 'newContactManagerId' }); expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId'); + expect(validateContactManagerMock.getCall(0).args[0]).to.be.equal('newContactManagerId'); - expect(modelsMock.Settings.incrementValueById.getCall(0).args[0]).to.be.equal('Livechat_conflicting_fields_counter'); - expect(modelsMock.Settings.incrementValueById.getCall(0).args[1]).to.be.equal(1); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ + set: { + contactManager: 'newContactManagerId', + conflictingFields: [], + }, + }); + }); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[1]).to.be.deep.contain({ contactManager: 'newContactManagerId' }); - expect(result).to.be.deep.equal({ + it('should wipe all conflicts if wipeConflicts = true', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ + _id: 'contactId', + name: 'Name', + customFields: { customField: 'newValue' }, + conflictingFields: [ + { field: 'name', value: 'NameTest' }, + { field: 'customFields.customField', value: 'value' }, + ], + }); + modelsMock.Settings.incrementValueById.resolves({ _id: 'Resolved_Conflicts_Count', value: 2 }); + modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId', name: 'New Name', - customField: { customField: 'newValue' }, + customFields: { customField: 'newValue' }, conflictingFields: [], - }); - }); - - it('should wipe conflicts if wipeConflicts = true', async () => { - it('should update the contact with the resolved name', async () => { - modelsMock.LivechatContacts.findOneEnabledById.resolves({ - _id: 'contactId', - name: 'Name', - customFields: { customField: 'newValue' }, - conflictingFields: [ - { field: 'name', value: 'NameTest' }, - { field: 'customFields.customField', value: 'value' }, - ], - }); - modelsMock.Settings.incrementValueById.resolves(2); - modelsMock.LivechatContacts.updateContact.resolves({ - _id: 'contactId', - name: 'New Name', - customField: { customField: 'newValue' }, - conflictingFields: [], - } as Partial); + } as Partial); - const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name', wipeConflicts: true }); + const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name', wipeConflicts: true }); - expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.Settings.incrementValueById.getCall(0).args[0]).to.be.equal('Livechat_conflicting_fields_counter'); - expect(modelsMock.Settings.incrementValueById.getCall(0).args[1]).to.be.equal(2); + expect(modelsMock.Settings.incrementValueById.getCall(0).args[0]).to.be.equal('Resolved_Conflicts_Count'); + expect(modelsMock.Settings.incrementValueById.getCall(0).args[1]).to.be.equal(2); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[1]).to.be.deep.contain({ name: 'New Name' }); - expect(result).to.be.deep.equal({ - _id: 'contactId', + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ + set: { name: 'New Name', - customField: { customField: 'newValue' }, conflictingFields: [], - }); + }, + }); + expect(result).to.be.deep.equal({ + _id: 'contactId', + name: 'New Name', + customFields: { customField: 'newValue' }, + conflictingFields: [], }); }); - it('should wipe conflicts if wipeConflicts = true', async () => { - it('should update the contact with the resolved name', async () => { - modelsMock.LivechatContacts.findOneEnabledById.resolves({ - _id: 'contactId', - name: 'Name', - customFields: { customField: 'newValue' }, - conflictingFields: [ - { field: 'name', value: 'NameTest' }, - { field: 'customFields.customField', value: 'value' }, - ], - }); - modelsMock.Settings.incrementValueById.resolves(2); - modelsMock.LivechatContacts.updateContact.resolves({ - _id: 'contactId', - name: 'New Name', - customField: { customField: 'newValue' }, - conflictingFields: [], - } as Partial); + it('should only resolve specified conflicts when wipeConflicts = false', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ + _id: 'contactId', + name: 'Name', + customFields: { customField: 'newValue' }, + conflictingFields: [ + { field: 'name', value: 'NameTest' }, + { field: 'customFields.customField', value: 'value' }, + ], + }); + modelsMock.LivechatContacts.patchContact.resolves({ + _id: 'contactId', + name: 'New Name', + customFields: { customField: 'newValue' }, + conflictingFields: [{ field: 'customFields.customField', value: 'value' }], + } as Partial); - const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name', wipeConflicts: false }); + const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name', wipeConflicts: false }); - expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.Settings.incrementValueById.getCall(0).args[0]).to.be.equal('Livechat_conflicting_fields_counter'); - expect(modelsMock.Settings.incrementValueById.getCall(0).args[1]).to.be.equal(1); + // When wipeConflicts is false, incrementValueById should NOT be called + expect(modelsMock.Settings.incrementValueById.called).to.be.false; - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[1]).to.be.deep.contain({ name: 'New Name' }); - expect(result).to.be.deep.equal({ - _id: 'contactId', + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ + set: { name: 'New Name', - customField: { customField: 'newValue' }, conflictingFields: [{ field: 'customFields.customField', value: 'value' }], - }); + }, + }); + expect(result).to.be.deep.equal({ + _id: 'contactId', + name: 'New Name', + customFields: { customField: 'newValue' }, + conflictingFields: [{ field: 'customFields.customField', value: 'value' }], }); }); it('should throw an error if the contact does not exist', async () => { modelsMock.LivechatContacts.findOneEnabledById.resolves(undefined); - await expect(resolveContactConflicts({ contactId: 'id', customField: { customField: 'newValue' } })).to.be.rejectedWith( + await expect(resolveContactConflicts({ contactId: 'id', customFields: { customField: 'newValue' } })).to.be.rejectedWith( 'error-contact-not-found', ); - expect(modelsMock.LivechatContacts.updateContact.getCall(0)).to.be.null; + expect(modelsMock.LivechatContacts.patchContact.called).to.be.false; }); it('should throw an error if the contact has no conflicting fields', async () => { @@ -214,26 +218,61 @@ describe('resolveContactConflicts', () => { customFields: { customField: 'value' }, conflictingFields: [], }); - await expect(resolveContactConflicts({ contactId: 'id', customField: { customField: 'newValue' } })).to.be.rejectedWith( + await expect(resolveContactConflicts({ contactId: 'id', customFields: { customField: 'newValue' } })).to.be.rejectedWith( 'error-contact-has-no-conflicts', ); - expect(modelsMock.LivechatContacts.updateContact.getCall(0)).to.be.null; + expect(modelsMock.LivechatContacts.patchContact.called).to.be.false; }); - it('should throw an error if the contact manager is invalid', async () => { + it('should unset contactManager when explicitly set to empty string', async () => { modelsMock.LivechatContacts.findOneEnabledById.resolves({ _id: 'contactId', name: 'Name', - contactManager: 'contactManagerId', + contactManager: 'oldManagerId', customFields: { customField: 'value' }, - conflictingFields: [{ field: 'manager', value: 'newContactManagerId' }], + conflictingFields: [{ field: 'manager', value: 'oldManagerId' }], }); - await expect(resolveContactConflicts({ contactId: 'id', contactManager: 'invalid' })).to.be.rejectedWith( - 'error-contact-manager-not-found', - ); + modelsMock.LivechatContacts.patchContact.resolves({ + _id: 'contactId', + name: 'Name', + customFields: { customField: 'value' }, + conflictingFields: [], + } as Partial); - expect(validateContactManagerMock.getCall(0).args[0]).to.be.equal('invalid'); + await resolveContactConflicts({ contactId: 'contactId', contactManager: '' }); - expect(modelsMock.LivechatContacts.updateContact.getCall(0)).to.be.null; + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.deep.include({ + set: { + conflictingFields: [], + }, + unset: { contactManager: '' }, + }); + expect(validateContactManagerMock.called).to.be.false; + }); + + it('should unset contactManager when explicitly set to undefined', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ + _id: 'contactId', + name: 'Name', + contactManager: 'oldManagerId', + customFields: { customField: 'value' }, + conflictingFields: [{ field: 'manager', value: 'oldManagerId' }], + }); + modelsMock.LivechatContacts.patchContact.resolves({ + _id: 'contactId', + name: 'Name', + customFields: { customField: 'value' }, + conflictingFields: [], + } as Partial); + + await resolveContactConflicts({ contactId: 'contactId', contactManager: undefined }); + + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.deep.include({ + set: { + conflictingFields: [], + }, + unset: { contactManager: '' }, + }); + expect(validateContactManagerMock.called).to.be.false; }); }); diff --git a/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts index f6d03757531d0..84e36c5995b3e 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts @@ -1,6 +1,7 @@ import type { ILivechatContact, ILivechatContactConflictingField } from '@rocket.chat/core-typings'; import { LivechatContacts, Settings } from '@rocket.chat/models'; +import { patchContact } from './patchContact'; import { validateContactManager } from './validateContactManager'; import { notifyOnSettingChanged } from '../../../../lib/server/lib/notifyListener'; @@ -46,7 +47,7 @@ export async function resolveContactConflicts(params: ResolveContactConflictsPar const fieldsToRemove = new Set( [ name && 'name', - contactManager && 'manager', + 'contactManager' in params && 'manager', ...(customFields ? Object.keys(customFields).map((key) => `customFields.${key}`) : []), ].filter((field): field is string => !!field), ); @@ -56,12 +57,21 @@ export async function resolveContactConflicts(params: ResolveContactConflictsPar ) as ILivechatContactConflictingField[]; } - const dataToUpdate = { + const set = { ...(name && { name }), ...(contactManager && { contactManager }), ...(customFields && { customFields: { ...contact.customFields, ...customFields } }), conflictingFields: updatedConflictingFieldsArr, }; - return LivechatContacts.updateContact(contactId, dataToUpdate); + const unset: Partial> = + 'contactManager' in params && !contactManager ? { contactManager: '' } : {}; + + const updatedContact = await patchContact(contactId, { set, unset }); + + if (!updatedContact) { + throw new Error('error-contact-not-found'); + } + + return updatedContact; } diff --git a/apps/meteor/app/livechat/server/lib/contacts/updateContact.spec.ts b/apps/meteor/app/livechat/server/lib/contacts/updateContact.spec.ts index 348154e998353..3a8fb24ff52ef 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/updateContact.spec.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/updateContact.spec.ts @@ -5,16 +5,20 @@ import sinon from 'sinon'; const modelsMock = { LivechatContacts: { findOneEnabledById: sinon.stub(), - updateContact: sinon.stub(), + patchContact: sinon.stub(), }, LivechatRooms: { updateContactDataByContactId: sinon.stub(), }, }; +const { patchContact } = proxyquire.noCallThru().load('./patchContact.ts', { + '@rocket.chat/models': modelsMock, +}); + const { updateContact } = proxyquire.noCallThru().load('./updateContact', { './getAllowedCustomFields': { - getAllowedCustomFields: sinon.stub(), + getAllowedCustomFields: sinon.stub().resolves([]), }, './validateContactManager': { validateContactManager: sinon.stub(), @@ -24,29 +28,54 @@ const { updateContact } = proxyquire.noCallThru().load('./updateContact', { }, '@rocket.chat/models': modelsMock, + + './patchContact': { + patchContact, + }, }); describe('updateContact', () => { beforeEach(() => { modelsMock.LivechatContacts.findOneEnabledById.reset(); - modelsMock.LivechatContacts.updateContact.reset(); + modelsMock.LivechatContacts.patchContact.reset(); modelsMock.LivechatRooms.updateContactDataByContactId.reset(); }); it('should throw an error if the contact does not exist', async () => { modelsMock.LivechatContacts.findOneEnabledById.resolves(undefined); await expect(updateContact('any_id')).to.be.rejectedWith('error-contact-not-found'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0)).to.be.null; + expect(modelsMock.LivechatContacts.patchContact.getCall(0)).to.be.null; }); it('should update the contact with correct params', async () => { modelsMock.LivechatContacts.findOneEnabledById.resolves({ _id: 'contactId' }); - modelsMock.LivechatContacts.updateContact.resolves({ _id: 'contactId', name: 'John Doe' } as any); + modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId', name: 'John Doe' } as any); const updatedContact = await updateContact({ contactId: 'contactId', name: 'John Doe' }); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[1]).to.be.deep.contain({ name: 'John Doe' }); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ set: { name: 'John Doe' } }); expect(updatedContact).to.be.deep.equal({ _id: 'contactId', name: 'John Doe' }); }); + + it('should be able to clear the contact manager when passing an empty string for contactManager', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ _id: 'contactId', contactManager: 'manager' }); + modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId' } as any); + + const updatedContact = await updateContact({ contactId: 'contactId', contactManager: '' }); + + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ unset: { contactManager: '' } }); + expect(updatedContact).to.be.deep.equal({ _id: 'contactId' }); + }); + + it('should be able to clear the contact manager when passing undefined for contactManager', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ _id: 'contactId', contactManager: 'manager' }); + modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId' } as any); + + const updatedContact = await updateContact({ contactId: 'contactId', contactManager: undefined }); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ unset: { contactManager: '' } }); + expect(updatedContact).to.be.deep.equal({ _id: 'contactId' }); + }); }); diff --git a/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts index a7389420d4af0..f8e84b1e617fa 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts @@ -2,6 +2,7 @@ import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/cor import { LivechatContacts, LivechatInquiry, LivechatRooms, Settings, Subscriptions } from '@rocket.chat/models'; import { getAllowedCustomFields } from './getAllowedCustomFields'; +import { patchContact } from './patchContact'; import { validateContactManager } from './validateContactManager'; import { validateCustomFields } from './validateCustomFields'; import { @@ -71,15 +72,23 @@ export async function updateContact(params: UpdateContactParams): Promise ({ address })) }), ...(phones && { phones: phones?.map((phoneNumber) => ({ phoneNumber })) }), ...(contactManager && { contactManager }), ...(channels && { channels }), ...(customFieldsToUpdate && { customFields: customFieldsToUpdate }), ...(wipeConflicts && { conflictingFields: [] }), - }); + }; + const unset: Partial> = + 'contactManager' in params && !contactManager ? { contactManager: '' } : {}; + + const updatedContact = await patchContact(contactId, { set, unset }); + + if (!updatedContact) { + throw new Error('error-contact-not-found'); + } // If the contact name changed, update the name of its existing rooms and subscriptions if (name !== undefined && name !== contact.name) { diff --git a/apps/meteor/app/livechat/server/lib/contacts/validateCustomFields.spec.ts b/apps/meteor/app/livechat/server/lib/contacts/validateCustomFields.spec.ts index 39684a62fd91c..4bb0d48ef42c0 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/validateCustomFields.spec.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/validateCustomFields.spec.ts @@ -55,7 +55,7 @@ describe('validateCustomFields', () => { const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; const customFields = { field2: 'value' }; - expect(() => validateCustomFields(allowedCustomFields, customFields, { ignoreValidationErrors: true })) + expect(() => validateCustomFields(allowedCustomFields, customFields, { ignoreAdditionalFields: true })) .not.to.throw() .and.to.equal({}); }); diff --git a/apps/meteor/app/livechat/server/lib/rooms.ts b/apps/meteor/app/livechat/server/lib/rooms.ts index a4b6d0dbc22b6..070946089142e 100644 --- a/apps/meteor/app/livechat/server/lib/rooms.ts +++ b/apps/meteor/app/livechat/server/lib/rooms.ts @@ -254,8 +254,8 @@ export async function returnRoomAsInquiry(room: IOmnichannelRoom, departmentId?: try { await saveTransferHistory(room, transferData); await RoutingManager.unassignAgent(inquiry, departmentId); - } catch (e) { - livechatLogger.error(e); + } catch (err) { + livechatLogger.error({ err }); throw new Meteor.Error('error-returning-inquiry'); } diff --git a/apps/meteor/app/livechat/server/lib/routing/External.ts b/apps/meteor/app/livechat/server/lib/routing/External.ts index 9bd3965f7326f..b5aaad05472e1 100644 --- a/apps/meteor/app/livechat/server/lib/routing/External.ts +++ b/apps/meteor/app/livechat/server/lib/routing/External.ts @@ -51,6 +51,8 @@ class ExternalQueue implements IRoutingMethod { ...(department && { departmentId: department }), ...(ignoreAgentId && { ignoreAgentId }), }, + // // SECURITY: The URL is a value that is only configurable by admins/users with the right permissions. It's ok to disable it here. + ignoreSsrfValidation: true, }); const result = (await request.json()) as { username?: string }; diff --git a/apps/meteor/app/livechat/server/lib/sendTranscript.ts b/apps/meteor/app/livechat/server/lib/sendTranscript.ts index 8a6f861907488..11ca540ab87bf 100644 --- a/apps/meteor/app/livechat/server/lib/sendTranscript.ts +++ b/apps/meteor/app/livechat/server/lib/sendTranscript.ts @@ -9,7 +9,7 @@ import { isFileImageAttachment, type AtLeast, } from '@rocket.chat/core-typings'; -import colors from '@rocket.chat/fuselage-tokens/colors'; +import colors from '@rocket.chat/fuselage-tokens/colors.json'; import { Logger } from '@rocket.chat/logger'; import { MessageTypes } from '@rocket.chat/message-types'; import { LivechatRooms, Messages, Uploads, Users } from '@rocket.chat/models'; diff --git a/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts b/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts index d2862c93847fa..07e4f8c5ce7d8 100644 --- a/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts +++ b/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts @@ -77,7 +77,8 @@ export const onlineAgents = { } } catch (e) { logger.error({ - msg: `Cannot perform action ${action}`, + msg: 'Cannot perform action', + action, err: e, }); } diff --git a/apps/meteor/app/livechat/server/lib/webhooks.ts b/apps/meteor/app/livechat/server/lib/webhooks.ts index e973484fe57e1..b0d2cd94f80e2 100644 --- a/apps/meteor/app/livechat/server/lib/webhooks.ts +++ b/apps/meteor/app/livechat/server/lib/webhooks.ts @@ -27,6 +27,8 @@ export async function sendRequest( }, body: postData, timeout, + // SECURITY: Webhooks can only be configured by users with enough privileges. It's ok to disable this check here. + ignoreSsrfValidation: true, }); if (result.status === 200) { diff --git a/apps/meteor/app/livechat/server/sendMessageBySMS.ts b/apps/meteor/app/livechat/server/sendMessageBySMS.ts index 2d370410fa337..32c96b529e497 100644 --- a/apps/meteor/app/livechat/server/sendMessageBySMS.ts +++ b/apps/meteor/app/livechat/server/sendMessageBySMS.ts @@ -68,8 +68,8 @@ callbacks.add( phoneNumber: visitor.phone[0].phoneNumber, service, }); - } catch (e) { - callbackLogger.error(e); + } catch (err) { + callbackLogger.error({ msg: 'Error sending SMS message', err }); } return message; diff --git a/apps/meteor/app/mail-messages/server/functions/unsubscribe.ts b/apps/meteor/app/mail-messages/server/functions/unsubscribe.ts index c3d61eef0d8f9..3d14f801fdb2f 100644 --- a/apps/meteor/app/mail-messages/server/functions/unsubscribe.ts +++ b/apps/meteor/app/mail-messages/server/functions/unsubscribe.ts @@ -6,7 +6,12 @@ export const unsubscribe = async function (_id: string, createdAt: string): Prom if (_id && createdAt) { const affectedRows = (await Users.rocketMailUnsubscribe(_id, createdAt)) === 1; - SystemLogger.debug('[Mailer:Unsubscribe]', _id, createdAt, new Date(parseInt(createdAt)), affectedRows); + SystemLogger.debug({ + msg: '[Mailer:Unsubscribe]', + _id, + createdAt, + affectedRows, + }); return affectedRows; } diff --git a/apps/meteor/app/markdown/lib/parser/original/token.ts b/apps/meteor/app/markdown/lib/parser/original/token.ts index d4b5a4ef8ace6..f7170b4e69075 100644 --- a/apps/meteor/app/markdown/lib/parser/original/token.ts +++ b/apps/meteor/app/markdown/lib/parser/original/token.ts @@ -2,10 +2,19 @@ * Markdown is a named function that will parse markdown syntax * @param {String} msg - The message html */ -import type { IMessage, TokenType, TokenExtra } from '@rocket.chat/core-typings'; +import type { TokenType, TokenExtra } from '@rocket.chat/core-typings'; import { Random } from '@rocket.chat/random'; -export const addAsToken = (message: IMessage, html: string, type: TokenType, extra?: TokenExtra): string => { +type MessageTokens = { + tokens?: { + token: string; + type: TokenType; + text: string; + extra?: TokenExtra; + }[]; +}; + +export const addAsToken = (message: MessageTokens, html: string, type: TokenType, extra?: TokenExtra): string => { if (!message.tokens) { message.tokens = []; } @@ -22,7 +31,7 @@ export const addAsToken = (message: IMessage, html: string, type: TokenType, ext export const isToken = (msg: string): boolean => /=!=[.a-z0-9]{17}=!=/gim.test(msg.trim()); -export const validateAllowedTokens = (message: IMessage, id: string, desiredTokens: TokenType[]): boolean => { +export const validateAllowedTokens = (message: MessageTokens, id: string, desiredTokens: TokenType[]): boolean => { const tokens: string[] = id.match(/=!=[.a-z0-9]{17}=!=/gim) || []; const tokensFound = message.tokens?.filter(({ token }) => tokens.includes(token)) || []; return tokensFound.length === 0 || tokensFound.every((token) => token.type && desiredTokens.includes(token.type)); diff --git a/apps/meteor/app/message-pin/server/pinMessage.ts b/apps/meteor/app/message-pin/server/pinMessage.ts index e11e601ce2ba7..204868d9dcda3 100644 --- a/apps/meteor/app/message-pin/server/pinMessage.ts +++ b/apps/meteor/app/message-pin/server/pinMessage.ts @@ -109,7 +109,7 @@ export async function pinMessage(message: IMessage, userId: string, pinnedAt?: D } // App IPostMessagePinned event hook - await Apps.self?.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, await Meteor.userAsync(), originalMessage.pinned); + await Apps.self?.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, me, originalMessage.pinned); const pinMessageType = originalMessage.t === 'e2e' ? 'message_pinned_e2e' : 'message_pinned'; @@ -189,7 +189,7 @@ export const unpinMessage = async (userId: string, message: IMessage) => { } // App IPostMessagePinned event hook - await Apps.self?.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, await Meteor.userAsync(), originalMessage.pinned); + await Apps.self?.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, me, originalMessage.pinned); await Messages.setPinnedByIdAndUserId(originalMessage._id, originalMessage.pinnedBy, originalMessage.pinned); if (settings.get('Message_Read_Receipt_Store_Users')) { diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts index dc9b83eb67f7f..fdac8b0b77fa8 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts @@ -276,7 +276,7 @@ export class SAML { } private static async _logoutRemoveTokens(userId: string): Promise { - SAMLUtils.log(`Found user ${userId}`); + SAMLUtils.log({ msg: 'Found user', userId }); await Users.unsetLoginTokens(userId); await Users.removeSamlServiceSession(userId); @@ -342,8 +342,8 @@ export class SAML { redirect(url); }); - } catch (e: any) { - SystemLogger.error(e); + } catch (err: any) { + SystemLogger.error({ err }); redirect(); } }); @@ -351,7 +351,7 @@ export class SAML { private static async processLogoutResponse(req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions): Promise { if (!req.query.SAMLResponse) { - SAMLUtils.error('Invalid LogoutResponse, missing SAMLResponse', req.query); + SAMLUtils.error({ msg: 'Invalid LogoutResponse received: missing SAMLResponse parameter.', query: req.query }); throw new Error('Invalid LogoutResponse received.'); } @@ -366,7 +366,7 @@ export class SAML { } const logOutUser = async (inResponseTo: string): Promise => { - SAMLUtils.log(`Logging Out user via inResponseTo ${inResponseTo}`); + SAMLUtils.log({ msg: 'Processing logout for inResponseTo', inResponseTo }); const loggedOutUsers = await Users.findBySAMLInResponseTo(inResponseTo).toArray(); if (loggedOutUsers.length > 1) { @@ -410,8 +410,7 @@ export class SAML { try { url = await serviceProvider.getAuthorizeUrl(samlObject.credentialToken); } catch (err: any) { - SAMLUtils.error('Unable to generate authorize url'); - SAMLUtils.error(err); + SAMLUtils.error({ err, msg: 'Unable to generate authorize url' }); url = Meteor.absoluteUrl(); } @@ -455,8 +454,8 @@ export class SAML { Location: url, }); res.end(); - } catch (error) { - SAMLUtils.error(error); + } catch (err) { + SAMLUtils.error({ err }); res.writeHead(302, { Location: Meteor.absoluteUrl(), }); @@ -521,7 +520,7 @@ export class SAML { } } } catch (err: any) { - SystemLogger.error(err); + SystemLogger.error({ err }); } } } diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/ServiceProvider.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/ServiceProvider.ts index bdce2978850d8..f60b65952d67e 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/ServiceProvider.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/ServiceProvider.ts @@ -175,8 +175,7 @@ export class SAMLServiceProvider { public async getAuthorizeUrl(credentialToken: string): Promise { const request = this.generateAuthorizeRequest(credentialToken); - SAMLUtils.log('-----REQUEST------'); - SAMLUtils.log(request); + SAMLUtils.log({ request, msg: 'getAuthorizeUrl' }); return this.requestToUrl(request, 'authorize'); } diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/Utils.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/Utils.ts index 5d43e122c7a7e..ec8924c69a7f4 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/Utils.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/Utils.ts @@ -51,7 +51,7 @@ export class SAMLUtils { } public static getServiceProviderOptions(providerName: string): IServiceProviderOptions | undefined { - this.log(providerName, providerList); + this.log({ providerName, providerList }); return providerList.find((providerOptions) => providerOptions.provider === providerName); } @@ -133,15 +133,15 @@ export class SAMLUtils { return `saml/${credentialToken}?saml_idp_credentialToken=${credentialToken}`; } - public static log(obj: any, ...args: Array): void { + public static log(obj: object | string): void { if (debug && logger) { - logger.debug(obj, ...args); + logger.debug(obj); } } - public static error(obj: any, ...args: Array): void { + public static error(obj: object | string): void { if (logger) { - logger.error(obj, ...args); + logger.error(obj); } } @@ -219,9 +219,8 @@ export class SAMLUtils { try { map = JSON.parse(userDataFieldMap); - } catch (e) { - SAMLUtils.log(userDataFieldMap); - SAMLUtils.log(e); + } catch (err) { + SAMLUtils.log({ userDataFieldMap, err }); throw new Error('Failed to parse custom user field map'); } @@ -412,7 +411,7 @@ export class SAMLUtils { public static mapProfileToUserObject(profile: Record): ISAMLUser { const userDataMap = this.getUserDataMapping(); - SAMLUtils.log('parsed userDataMap', userDataMap); + SAMLUtils.log({ msg: 'Mapping SAML Profile to User Object', userDataMap }); if (userDataMap.identifier.type === 'custom') { if (!userDataMap.identifier.attribute) { diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/generators/LogoutRequest.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/generators/LogoutRequest.ts index ebca0b4b45f8e..733ffd46a89ca 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/generators/LogoutRequest.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/generators/LogoutRequest.ts @@ -12,8 +12,7 @@ export class LogoutRequest { const data = this.getDataForNewRequest(serviceProviderOptions, nameID, sessionIndex); const request = SAMLUtils.fillTemplateData(serviceProviderOptions.logoutRequestTemplate || defaultLogoutRequestTemplate, data); - SAMLUtils.log('------- SAML Logout request -----------'); - SAMLUtils.log(request); + SAMLUtils.log({ request, msg: '------- SAML Logout request -----------' }); return { request, diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/generators/LogoutResponse.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/generators/LogoutResponse.ts index a2563cede90f7..604d395a1bf04 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/generators/LogoutResponse.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/generators/LogoutResponse.ts @@ -17,8 +17,7 @@ export class LogoutResponse { const data = this.getDataForNewResponse(serviceProviderOptions, nameID, sessionIndex, inResponseToId); const response = SAMLUtils.fillTemplateData(serviceProviderOptions.logoutResponseTemplate || defaultLogoutResponseTemplate, data); - SAMLUtils.log('------- SAML Logout response -----------'); - SAMLUtils.log(response); + SAMLUtils.log({ response, msg: '------- SAML Logout response -----------' }); return { response, diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/parsers/LogoutRequest.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/parsers/LogoutRequest.ts index bbae84556a9a9..c609328ea21bf 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/parsers/LogoutRequest.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/parsers/LogoutRequest.ts @@ -12,7 +12,7 @@ export class LogoutRequestParser { } public async validate(xmlString: string, callback: ILogoutRequestValidateCallback): Promise { - SAMLUtils.log(`LogoutRequest: ${xmlString}`); + SAMLUtils.log({ msg: 'Validating SAML Logout Request', xmlString }); const doc = new xmldom.DOMParser().parseFromString(xmlString, 'text/xml'); if (!doc) { @@ -37,14 +37,13 @@ export class LogoutRequestParser { const id = request.getAttribute('ID'); return callback(null, { idpSession, nameID, id }); - } catch (e) { - SAMLUtils.error(e); - SAMLUtils.log(`Caught error: ${e}`); + } catch (err) { + SAMLUtils.error({ err }); - const msg = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage'); - SAMLUtils.log(`Unexpected msg from IDP. Does your session still exist at IDP? Idp returned: \n ${msg}`); + const statusMessage = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage'); + SAMLUtils.log({ msg: `Unexpected msg from IDP. Does your session still exist at IDP?`, statusMessage }); - return callback(e instanceof Error ? e : String(e), null); + return callback(err instanceof Error ? err : String(err), null); } } } diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/parsers/LogoutResponse.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/parsers/LogoutResponse.ts index 54db1a675c9ac..af9c176233cdf 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/parsers/LogoutResponse.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/parsers/LogoutResponse.ts @@ -12,7 +12,7 @@ export class LogoutResponseParser { } public async validate(xmlString: string, callback: ILogoutResponseValidateCallback): Promise { - SAMLUtils.log(`LogoutResponse: ${xmlString}`); + SAMLUtils.log({ msg: 'Validating SAML Logout Response', xmlString }); const doc = new xmldom.DOMParser().parseFromString(xmlString, 'text/xml'); if (!doc) { @@ -28,9 +28,9 @@ export class LogoutResponseParser { let inResponseTo; try { inResponseTo = response.getAttribute('InResponseTo'); - SAMLUtils.log(`In Response to: ${inResponseTo}`); - } catch (e) { - SAMLUtils.log(`Caught error: ${e}`); + SAMLUtils.log({ msg: `Found InResponseTo`, inResponseTo }); + } catch (err) { + SAMLUtils.log({ err }); const msg = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage'); SAMLUtils.log(`Unexpected msg from IDP. Does your session still exist at IDP? Idp returned: \n ${msg}`); } diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/parsers/Response.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/parsers/Response.ts index af052f43b7fe0..87aeb4ad9f6c8 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/parsers/Response.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/parsers/Response.ts @@ -19,7 +19,7 @@ export class ResponseParser { public validate(xml: string, callback: IResponseValidateCallback): void { // We currently use RelayState to save SAML provider - SAMLUtils.log(`Validating response with relay state: ${xml}`); + SAMLUtils.log({ msg: 'Validating SAML Response', xml }); let error: Error | null = null; @@ -145,7 +145,7 @@ export class ResponseParser { if (authnStatement) { if (authnStatement.hasAttribute('SessionIndex')) { profile.sessionIndex = authnStatement.getAttribute('SessionIndex'); - SAMLUtils.log(`Session Index: ${profile.sessionIndex}`); + SAMLUtils.log({ msg: 'Session Index Found', sessionIndex: profile.sessionIndex }); } else { SAMLUtils.log('No Session Index Found'); } @@ -353,7 +353,7 @@ export class ResponseParser { const options = { key: this.serviceProviderOptions.privateKey }; xmlenc.decrypt(encSubject.getElementsByTagNameNS('*', 'EncryptedData')[0], options, (err, result) => { if (err) { - SAMLUtils.error(err); + SAMLUtils.error({ err }); } subject = new xmldom.DOMParser().parseFromString(result, 'text/xml'); }); @@ -418,9 +418,9 @@ export class ResponseParser { } private mapAttributes(attributeStatement: Element, profile: Record): void { - SAMLUtils.log(`Attribute Statement found in SAML response: ${attributeStatement}`); + SAMLUtils.log({ msg: 'Attribute Statement found, mapping attributes to profile.', attributeStatement }); const attributes = attributeStatement.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Attribute'); - SAMLUtils.log(`Attributes will be processed: ${attributes.length}`); + SAMLUtils.log({ msg: 'Attributes will be processed', count: attributes.length }); if (attributes) { for (let i = 0; i < attributes.length; i++) { @@ -437,8 +437,7 @@ export class ResponseParser { const key = attributes[i].getAttribute('Name'); if (key) { - SAMLUtils.log(`Attribute: ${attributes[i]} has ${values.length} value(s).`); - SAMLUtils.log(`Adding attribute from SAML response to profile: ${key} = ${value}`); + SAMLUtils.log({ msg: 'Mapping attribute to profile', attribute: key, value }); profile[key] = value; } } diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts index 3b93fe22c88b4..dacdd014806e4 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts @@ -124,7 +124,7 @@ export const loadSamlServiceProviders = async function (): Promise { services.map(async ([key, value]) => { if (value === true) { const samlConfigs = getSamlConfigs(key); - SAMLUtils.log(key); + SAMLUtils.log({ key }); await LoginServiceConfiguration.createOrUpdateService(serviceName, samlConfigs); void notifyOnLoginServiceConfigurationChangedByService(serviceName); return configureSamlService(samlConfigs); diff --git a/apps/meteor/app/meteor-accounts-saml/server/loginHandler.ts b/apps/meteor/app/meteor-accounts-saml/server/loginHandler.ts index b95513fef0366..e9b861b4a511a 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/loginHandler.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/loginHandler.ts @@ -33,16 +33,16 @@ Accounts.registerLoginHandler('saml', async (loginRequest) => { SAMLUtils.events.emit('updateCustomFields', loginResult, updatedUser); return updatedUser; - } catch (error: any) { - SystemLogger.error(error); + } catch (err: any) { + SystemLogger.error({ err }); - let message = error.toString(); + let message = err.toString(); let errorCode = ''; - if (error instanceof Meteor.Error) { - errorCode = (error.error || error.message) as string; - } else if (error instanceof Error) { - errorCode = error.message; + if (err instanceof Meteor.Error) { + errorCode = (err.error || err.message) as string; + } else if (err instanceof Error) { + errorCode = err.message; } if (errorCode) { diff --git a/apps/meteor/app/meteor-accounts-saml/server/methods/samlLogout.ts b/apps/meteor/app/meteor-accounts-saml/server/methods/samlLogout.ts index a7f9e87a93de9..0570a7e1914ca 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/methods/samlLogout.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/methods/samlLogout.ts @@ -63,8 +63,7 @@ Meteor.methods({ sessionIndex: idpSession, }); - SAMLUtils.log('----Logout Request----'); - SAMLUtils.log(request); + SAMLUtils.log({ request, msg: '----Logout Request---' }); // request.request: actual XML SAML Request // request.id: comminucation id which will be mentioned in the ResponseTo field of SAMLResponse @@ -72,7 +71,7 @@ Meteor.methods({ await Users.setSamlInResponseTo(userId, request.id); const result = await _saml.requestToUrl(request.request, 'logout'); - SAMLUtils.log(`SAML Logout Request ${result}`); + SAMLUtils.log({ msg: 'SAML Logout Request URL generated', result }); return result; }, diff --git a/apps/meteor/app/metrics/server/lib/collectMetrics.ts b/apps/meteor/app/metrics/server/lib/collectMetrics.ts index a1ad41c9a95cf..6a393bb406506 100644 --- a/apps/meteor/app/metrics/server/lib/collectMetrics.ts +++ b/apps/meteor/app/metrics/server/lib/collectMetrics.ts @@ -5,7 +5,6 @@ import { tracerSpan } from '@rocket.chat/tracing'; import connect from 'connect'; import { Facts } from 'meteor/facts-base'; import { Meteor } from 'meteor/meteor'; -import { MongoInternals } from 'meteor/mongo'; import client from 'prom-client'; import gcStats from 'prometheus-gc-stats'; import _ from 'underscore'; @@ -17,8 +16,6 @@ import { settings } from '../../../settings/server'; import { getAppsStatistics } from '../../../statistics/server/lib/getAppsStatistics'; import { Info } from '../../../utils/rocketchat.info'; -const { mongo } = MongoInternals.defaultRemoteCollectionDriver(); - Facts.incrementServerFact = function (pkg: 'pkg' | 'fact', fact: string | number, increment: number): void { metrics.meteorFacts.inc({ pkg, fact }, increment); }; @@ -46,9 +43,6 @@ const setPrometheusData = async (): Promise => { metrics.totalAppsEnabled.set(totalActive || 0); metrics.totalAppsFailed.set(totalFailed || 0); - const oplogQueue = (mongo as any)._oplogHandle?._entryQueue?.length || 0; - metrics.oplogQueue.set(oplogQueue); - const statistics = await Statistics.findLast(); if (!statistics) { return; @@ -57,7 +51,6 @@ const setPrometheusData = async (): Promise => { metrics.version.set({ version: statistics.version }, 1); metrics.migration.set((await getControl()).version); metrics.instanceCount.set(statistics.instanceCount); - metrics.oplogEnabled.set({ enabled: `${statistics.oplogEnabled}` }, 1); // User statistics metrics.totalUsers.set(statistics.totalUsers); @@ -208,7 +201,7 @@ const updatePrometheusConfig = async (): Promise => { gcStats(client.register)(); } } catch (error) { - SystemLogger.error(error); + SystemLogger.error({ err: error }); } Object.assign(was, is); diff --git a/apps/meteor/app/metrics/server/lib/metrics.ts b/apps/meteor/app/metrics/server/lib/metrics.ts index 36967954a8dbb..c35cd51f70ca6 100644 --- a/apps/meteor/app/metrics/server/lib/metrics.ts +++ b/apps/meteor/app/metrics/server/lib/metrics.ts @@ -100,22 +100,6 @@ export const metrics = { name: 'rocketchat_instance_count', help: 'instances running', }), - oplogEnabled: new client.Gauge({ - name: 'rocketchat_oplog_enabled', - labelNames: ['enabled'], - help: 'oplog enabled', - }), - oplogQueue: new client.Gauge({ - name: 'rocketchat_oplog_queue', - labelNames: ['queue'], - help: 'oplog queue', - }), - oplog: new client.Counter({ - name: 'rocketchat_oplog', - help: 'summary of oplog operations', - labelNames: ['collection', 'op'], - }), - pushQueue: new client.Gauge({ name: 'rocketchat_push_queue', labelNames: ['queue'], diff --git a/apps/meteor/app/nextcloud/server/addWebdavServer.ts b/apps/meteor/app/nextcloud/server/addWebdavServer.ts index d439389299ae2..6d3ea80f6d178 100644 --- a/apps/meteor/app/nextcloud/server/addWebdavServer.ts +++ b/apps/meteor/app/nextcloud/server/addWebdavServer.ts @@ -28,8 +28,8 @@ Meteor.startup(() => { }; try { await addWebdavAccountByToken(user._id, data); - } catch (error) { - SystemLogger.error(error); + } catch (err) { + SystemLogger.error({ err }); } }, callbacks.priority.MEDIUM, diff --git a/apps/meteor/app/notification-queue/server/NotificationQueue.ts b/apps/meteor/app/notification-queue/server/NotificationQueue.ts index 6690bea41f521..52035313949a9 100644 --- a/apps/meteor/app/notification-queue/server/NotificationQueue.ts +++ b/apps/meteor/app/notification-queue/server/NotificationQueue.ts @@ -93,9 +93,9 @@ class NotificationClass { } await NotificationQueue.removeById(notification._id); - } catch (e) { - SystemLogger.error(e); - await NotificationQueue.setErrorById(notification._id, e instanceof Error ? e.message : String(e)); + } catch (err) { + SystemLogger.error({ err }); + await NotificationQueue.setErrorById(notification._id, err instanceof Error ? err.message : String(err)); } if (counter >= this.maxBatchSize) { diff --git a/apps/meteor/app/notifications/client/lib/Presence.ts b/apps/meteor/app/notifications/client/lib/Presence.ts index 09d0e3be16930..d6890d12aa52d 100644 --- a/apps/meteor/app/notifications/client/lib/Presence.ts +++ b/apps/meteor/app/notifications/client/lib/Presence.ts @@ -2,16 +2,17 @@ import { UserStatus } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { Presence } from '../../../../client/lib/presence'; +import { streamerCentral } from '../../../../client/lib/streamer'; // TODO implement API on Streamer to be able to listen to all streamed data // this is a hacky way to listen to all streamed data from user-presence Streamer -new Meteor.Streamer('user-presence'); +streamerCentral.getStreamer('user-presence', { ddpConnection: Meteor.connection }); type args = [username: string, statusChanged?: UserStatus, statusText?: string]; export const STATUS_MAP = [UserStatus.OFFLINE, UserStatus.ONLINE, UserStatus.AWAY, UserStatus.BUSY, UserStatus.DISABLED]; -Meteor.StreamerCentral.on('stream-user-presence', (uid: string, [username, statusChanged, statusText]: args) => { +streamerCentral.on('stream-user-presence', (uid: string, [username, statusChanged, statusText]: args) => { Presence.notify({ _id: uid, username, status: STATUS_MAP[statusChanged as any], statusText }); }); diff --git a/apps/meteor/app/notifications/server/lib/Presence.ts b/apps/meteor/app/notifications/server/lib/Presence.ts index 7e147dfec9ca4..bfb327d8eff1d 100644 --- a/apps/meteor/app/notifications/server/lib/Presence.ts +++ b/apps/meteor/app/notifications/server/lib/Presence.ts @@ -1,7 +1,8 @@ import type { IUser } from '@rocket.chat/core-typings'; import type { StreamerEvents } from '@rocket.chat/ddp-client'; import { Emitter } from '@rocket.chat/emitter'; -import type { IPublication, IStreamerConstructor, Connection, IStreamer } from 'meteor/rocketchat:streamer'; + +import type { IPublication, IStreamerConstructor, Connection, IStreamer } from '../../../../server/modules/streamer/types'; type UserPresenceStreamProps = { added: IUser['_id'][]; diff --git a/apps/meteor/app/push/server/fcm.ts b/apps/meteor/app/push/server/fcm.ts index d31cd07e003d6..9a3529d02e1f4 100644 --- a/apps/meteor/app/push/server/fcm.ts +++ b/apps/meteor/app/push/server/fcm.ts @@ -188,7 +188,13 @@ export const sendFCM = function ({ userTokens, notification, _removeToken, optio token && _removeToken({ gcm: token }); }; - const response = fetchWithRetry(url, removeToken, { method: 'POST', headers, body: JSON.stringify(fcmRequest) }); + const response = fetchWithRetry(url, removeToken, { + method: 'POST', + headers, + body: JSON.stringify(fcmRequest), + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + }); response.catch((err) => { logger.error({ msg: 'sendFCM error', err }); diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index 6d13a34e7669f..04e217822156a 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -1,8 +1,9 @@ import type { IAppsTokens, RequiredField, Optional, IPushNotificationConfig } from '@rocket.chat/core-typings'; import { AppsTokens } from '@rocket.chat/models'; +import { ajv } from '@rocket.chat/rest-typings'; +import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { pick, truncateString } from '@rocket.chat/tools'; -import Ajv from 'ajv'; import { JWT } from 'google-auth-library'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -18,10 +19,6 @@ export const _matchToken = Match.OneOf({ apn: String }, { gcm: String }); const PUSH_TITLE_LIMIT = 65; const PUSH_MESSAGE_BODY_LIMIT = 240; -const ajv = new Ajv({ - coerceTypes: true, -}); - type FCMCredentials = { type: string; project_id: string; @@ -163,7 +160,7 @@ class PushClass { this.isConfigured = true; - logger.debug('Configure', this.options); + logger.debug({ msg: 'Configure', options: this.options }); if (this.options.apn) { initAPN({ options: this.options as RequiredField, absoluteUrl: Meteor.absoluteUrl() }); @@ -188,7 +185,7 @@ class PushClass { countApn: string[], countGcm: string[], ): Promise { - logger.debug('send to token', app.token); + logger.debug({ msg: 'send to token', token: app.token }); if ('apn' in app.token && app.token.apn) { countApn.push(app._id); @@ -248,7 +245,7 @@ class PushClass { projectId: credentials.project_id, }; } catch (error) { - logger.error('Error getting FCM token', error); + logger.error({ msg: 'Error getting FCM token', err: error }); throw new Error('Error getting FCM token'); } } @@ -263,19 +260,21 @@ class PushClass { notification.uniqueId = this.options.uniqueId; const options = { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, method: 'POST', body: { token, options: notification, }, ...(token && this.options.getAuthorization && { headers: { Authorization: await this.options.getAuthorization() } }), - }; + } as ExtendedFetchOptions; const result = await fetch(`${gateway}/push/${service}/send`, options); const response = await result.text(); if (result.status === 406) { - logger.info('removing push token', token); + logger.info({ msg: 'removing push token', token }); await AppsTokens.deleteMany({ $or: [ { @@ -290,12 +289,12 @@ class PushClass { } if (result.status === 422) { - logger.info('gateway rejected push notification. not retrying.', response); + logger.info({ msg: 'gateway rejected push notification. not retrying.', response }); return; } if (result.status === 401) { - logger.warn('Error sending push to gateway (not authorized)', response); + logger.warn({ msg: 'authorization failed when sending push to gateway. not retrying.', response }); return; } @@ -309,7 +308,7 @@ class PushClass { // [1, 2, 4, 8, 16] minutes (total 31) const ms = 60000 * Math.pow(2, tries); - logger.log('Trying sending push to gateway again in', ms, 'milliseconds'); + logger.log({ msg: 'Retrying push to gateway', tries: tries + 1, in: ms }); setTimeout(() => this.sendGatewayPush(gateway, service, token, notification, tries + 1), ms); } @@ -338,7 +337,7 @@ class PushClass { const gatewayNotification = this.getGatewayNotificationData(notification); for (const gateway of this.options.gateways) { - logger.debug('send to token', app.token); + logger.debug({ msg: 'send to token', token: app.token }); if ('apn' in app.token && app.token.apn) { countApn.push(app._id); @@ -353,7 +352,7 @@ class PushClass { } private async sendNotification(notification: PendingPushNotification): Promise<{ apn: string[]; gcm: string[] }> { - logger.debug('Sending notification', notification); + logger.debug({ msg: 'Sending notification', notification }); const countApn: string[] = []; const countGcm: string[] = []; @@ -382,7 +381,7 @@ class PushClass { const appTokens = AppsTokens.find(query); for await (const app of appTokens) { - logger.debug('send to token', app.token); + logger.debug({ msg: 'send to token', token: app.token }); if (this.shouldUseGateway()) { await this.sendNotificationGateway(app, notification, countApn, countGcm); @@ -503,7 +502,6 @@ class PushClass { userId: notification.userId, err: error, }); - logger.debug(error.stack); } } } diff --git a/apps/meteor/app/settings/server/CachedSettings.ts b/apps/meteor/app/settings/server/CachedSettings.ts index 6a16a4c761313..3c46dd05a6806 100644 --- a/apps/meteor/app/settings/server/CachedSettings.ts +++ b/apps/meteor/app/settings/server/CachedSettings.ts @@ -108,7 +108,7 @@ export class CachedSettings */ public override has(_id: ISetting['_id']): boolean { if (!this.ready && warn) { - SystemLogger.warn(`Settings not initialized yet. getting: ${_id}`); + SystemLogger.warn({ msg: 'Settings not initialized yet. getting', _id }); } return this.store.has(_id); } @@ -120,7 +120,7 @@ export class CachedSettings */ public getSetting(_id: ISetting['_id']): ISetting | undefined { if (!this.ready && warn) { - SystemLogger.warn(`Settings not initialized yet. getting: ${_id}`); + SystemLogger.warn({ msg: 'Settings not initialized yet. getting', _id }); } return this.store.get(_id); } @@ -134,7 +134,7 @@ export class CachedSettings */ public get(_id: ISetting['_id']): T { if (!this.ready && warn) { - SystemLogger.warn(`Settings not initialized yet. getting: ${_id}`); + SystemLogger.warn({ msg: 'Settings not initialized yet. getting', _id }); } return this.store.get(_id)?.value as T; } @@ -148,7 +148,7 @@ export class CachedSettings */ public getByRegexp(_id: RegExp): [string, T][] { if (!this.ready && warn) { - SystemLogger.warn(`Settings not initialized yet. getting: ${_id}`); + SystemLogger.warn({ msg: 'Settings not initialized yet. getting', _id }); } return [...this.store.entries()].filter(([key]) => _id.test(key)).map(([key, setting]) => [key, setting.value]) as [string, T][]; diff --git a/apps/meteor/app/settings/server/SettingsRegistry.ts b/apps/meteor/app/settings/server/SettingsRegistry.ts index cb6f9da20ab97..3b93ca5c0bfb6 100644 --- a/apps/meteor/app/settings/server/SettingsRegistry.ts +++ b/apps/meteor/app/settings/server/SettingsRegistry.ts @@ -132,7 +132,7 @@ export class SettingsRegistry { ); if (isSettingEnterprise(settingFromCode) && !('invalidValue' in settingFromCode)) { - SystemLogger.error(`Enterprise setting ${_id} is missing the invalidValue option`); + SystemLogger.error({ msg: 'Enterprise setting is missing the invalidValue option', _id }); throw new Error(`Enterprise setting ${_id} is missing the invalidValue option`); } @@ -145,7 +145,7 @@ export class SettingsRegistry { try { validateSetting(settingFromCode._id, settingFromCode.type, settingFromCode.value); } catch (e) { - IS_DEVELOPMENT && SystemLogger.error(`Invalid setting code ${_id}: ${(e as Error).message}`); + IS_DEVELOPMENT && SystemLogger.error({ msg: 'Invalid setting code', _id, err: e as Error }); } const isOverwritten = settingFromCode !== settingFromCodeOverwritten || (settingStored && settingStored !== settingStoredOverwritten); @@ -189,7 +189,7 @@ export class SettingsRegistry { try { validateSetting(settingFromCode._id, settingFromCode.type, settingStored?.value); } catch (e) { - IS_DEVELOPMENT && SystemLogger.error(`Invalid setting stored ${_id}: ${(e as Error).message}`); + IS_DEVELOPMENT && SystemLogger.error({ msg: 'Invalid setting stored', _id, err: e as Error }); } return; } diff --git a/apps/meteor/app/slackbridge/server/RocketAdapter.js b/apps/meteor/app/slackbridge/server/RocketAdapter.ts similarity index 96% rename from apps/meteor/app/slackbridge/server/RocketAdapter.js rename to apps/meteor/app/slackbridge/server/RocketAdapter.ts index 624d4a72de068..925bb4ef2dc4d 100644 --- a/apps/meteor/app/slackbridge/server/RocketAdapter.js +++ b/apps/meteor/app/slackbridge/server/RocketAdapter.ts @@ -1,3 +1,8 @@ +// This is a JS File that was renamed to TS so it won't lose its git history when converted to TS +// TODO: Remove the following lint/ts instructions when the file gets properly converted +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ +// @ts-nocheck import util from 'util'; import { Messages, Rooms, Users } from '@rocket.chat/models'; @@ -343,7 +348,7 @@ export default class RocketAdapter { } async addUser(slackUserID) { - rocketLogger.debug('Adding Rocket.Chat user from Slack', slackUserID); + rocketLogger.debug({ msg: 'Adding Rocket.Chat user from Slack', slackUserID }); let addedUser; for await (const slack of this.slackAdapters) { if (addedUser) { @@ -410,8 +415,8 @@ export default class RocketAdapter { if (url) { try { await setUserAvatar(user, url, null, 'url'); - } catch (error) { - rocketLogger.debug('Error setting user avatar', error.message); + } catch (err) { + rocketLogger.debug({ msg: 'Error setting user avatar from Slack', err }); } } } @@ -482,6 +487,7 @@ export default class RocketAdapter { rocketMsgObj.tmid = tmessage._id; } } + if (slackMessage.subtype === 'bot_message') { rocketUser = await Users.findOneById('rocket.cat', { projection: { username: 1 } }); } @@ -497,13 +503,13 @@ export default class RocketAdapter { // Make sure that a message with the same bot_id and timestamp doesn't already exists const msg = await Messages.findOneBySlackBotIdAndSlackTs(slackMessage.bot_id, slackMessage.ts); if (!msg) { - void sendMessage(rocketUser, rocketMsgObj, rocketChannel, true); + void sendMessage(rocketUser, rocketMsgObj, rocketChannel, { upsert: true }); } } }, 500); } else { rocketLogger.debug('Send message to Rocket.Chat'); - await sendMessage(rocketUser, rocketMsgObj, rocketChannel, true); + await sendMessage(rocketUser, rocketMsgObj, rocketChannel, { upsert: true }); } } } diff --git a/apps/meteor/app/slackbridge/server/SlackAPI.js b/apps/meteor/app/slackbridge/server/SlackAPI.ts similarity index 72% rename from apps/meteor/app/slackbridge/server/SlackAPI.js rename to apps/meteor/app/slackbridge/server/SlackAPI.ts index 540aa3b911605..a352537059c0c 100644 --- a/apps/meteor/app/slackbridge/server/SlackAPI.js +++ b/apps/meteor/app/slackbridge/server/SlackAPI.ts @@ -1,3 +1,7 @@ +// This is a JS File that was renamed to TS so it won't lose its git history when converted to TS +// TODO: Remove the following lint/ts instructions when the file gets properly converted +/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ +// @ts-nocheck import { serverFetch as fetch } from '@rocket.chat/server-fetch'; export class SlackAPI { @@ -7,7 +11,9 @@ export class SlackAPI { async getChannels(cursor = null) { let channels = []; + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. const request = await fetch('https://slack.com/api/conversations.list', { + ignoreSsrfValidation: true, headers: { Authorization: `Bearer ${this.token}`, }, @@ -33,7 +39,9 @@ export class SlackAPI { async getGroups(cursor = null) { let groups = []; + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. const request = await fetch('https://slack.com/api/conversations.list', { + ignoreSsrfValidation: true, headers: { Authorization: `Bearer ${this.token}`, }, @@ -58,7 +66,9 @@ export class SlackAPI { } async getRoomInfo(roomId) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. const request = await fetch(`https://slack.com/api/conversations.info`, { + ignoreSsrfValidation: true, headers: { Authorization: `Bearer ${this.token}`, }, @@ -79,6 +89,8 @@ export class SlackAPI { for (let index = 0; index < num_members; index += MAX_MEMBERS_PER_CALL) { // eslint-disable-next-line no-await-in-loop const request = await fetch('https://slack.com/api/conversations.members', { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, headers: { Authorization: `Bearer ${this.token}`, }, @@ -102,7 +114,9 @@ export class SlackAPI { } async react(data) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. const request = await fetch('https://slack.com/api/reactions.add', { + ignoreSsrfValidation: true, headers: { Authorization: `Bearer ${this.token}`, }, @@ -114,7 +128,9 @@ export class SlackAPI { } async removeReaction(data) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. const request = await fetch('https://slack.com/api/reactions.remove', { + ignoreSsrfValidation: true, headers: { Authorization: `Bearer ${this.token}`, }, @@ -126,7 +142,9 @@ export class SlackAPI { } async removeMessage(data) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. const request = await fetch('https://slack.com/api/chat.delete', { + ignoreSsrfValidation: true, headers: { Authorization: `Bearer ${this.token}`, }, @@ -138,7 +156,9 @@ export class SlackAPI { } async sendMessage(data) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. const request = await fetch('https://slack.com/api/chat.postMessage', { + ignoreSsrfValidation: true, headers: { Authorization: `Bearer ${this.token}`, }, @@ -149,7 +169,9 @@ export class SlackAPI { } async updateMessage(data) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. const request = await fetch('https://slack.com/api/chat.update', { + ignoreSsrfValidation: true, headers: { Authorization: `Bearer ${this.token}`, }, @@ -161,7 +183,9 @@ export class SlackAPI { } async getHistory(options) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. const request = await fetch(`https://slack.com/api/conversations.history`, { + ignoreSsrfValidation: true, headers: { Authorization: `Bearer ${this.token}`, }, @@ -172,7 +196,9 @@ export class SlackAPI { } async getPins(channelId) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. const request = await fetch('https://slack.com/api/pins.list', { + ignoreSsrfValidation: true, headers: { Authorization: `Bearer ${this.token}`, }, @@ -185,7 +211,9 @@ export class SlackAPI { } async getUser(userId) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. const request = await fetch('https://slack.com/api/users.info', { + ignoreSsrfValidation: true, headers: { Authorization: `Bearer ${this.token}`, }, @@ -198,7 +226,9 @@ export class SlackAPI { } static async verifyToken(token) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. const request = await fetch('https://slack.com/api/auth.test', { + ignoreSsrfValidation: true, headers: { Authorization: `Bearer ${token}`, }, @@ -209,7 +239,9 @@ export class SlackAPI { } static async verifyAppCredentials({ botToken, appToken }) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. const request = await fetch('https://slack.com/api/apps.connections.open', { + ignoreSsrfValidation: true, headers: { Authorization: `Bearer ${appToken}`, }, diff --git a/apps/meteor/app/slackbridge/server/SlackAdapter.js b/apps/meteor/app/slackbridge/server/SlackAdapter.ts similarity index 94% rename from apps/meteor/app/slackbridge/server/SlackAdapter.js rename to apps/meteor/app/slackbridge/server/SlackAdapter.ts index 46a5ab6d35b5e..e62d0bcdcd932 100644 --- a/apps/meteor/app/slackbridge/server/SlackAdapter.js +++ b/apps/meteor/app/slackbridge/server/SlackAdapter.ts @@ -1,3 +1,9 @@ +// This is a JS File that was renamed to TS so it won't lose its git history when converted to TS +// TODO: Remove the following lint/ts instructions when the file gets properly converted +/* eslint-disable @typescript-eslint/no-empty-function */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ +// @ts-nocheck import http from 'http'; import https from 'https'; import url from 'url'; @@ -25,7 +31,7 @@ import { getUserAvatarURL } from '../../utils/server/getUserAvatarURL'; export default class SlackAdapter { constructor(slackBridge) { - slackLogger.debug('constructor'); + slackLogger.debug({ msg: 'constructor' }); this.slackBridge = slackBridge; this.rtm = {}; // slack-client Real Time Messaging API this.apiToken = {}; // Slack API Token passed in via Connect @@ -45,8 +51,8 @@ export default class SlackAdapter { const connectResult = await (appCredential ? this.connectApp(appCredential) : this.connectLegacy(apiToken)); if (connectResult) { - slackLogger.info('Connected to Slack'); - slackLogger.debug('Slack connection result: ', connectResult); + slackLogger.info({ msg: 'Connected to Slack' }); + slackLogger.debug({ msg: 'Slack connection result', connectResult }); Meteor.startup(async () => { try { await this.populateMembershipChannelMap(); // If run outside of Meteor.startup, HTTP is not defined @@ -153,7 +159,7 @@ export default class SlackAdapter { * } */ this.slackApp.message(async ({ message }) => { - slackLogger.debug('OnSlackEvent-MESSAGE: ', message); + slackLogger.debug({ msg: 'OnSlackEvent-MESSAGE', message }); if (message) { try { await this.onMessage(message); @@ -179,9 +185,8 @@ export default class SlackAdapter { * } */ this.slackApp.event('reaction_added', async ({ event }) => { - slackLogger.debug('OnSlackEvent-REACTION_ADDED: ', event); + slackLogger.debug({ msg: 'OnSlackEvent-REACTION_ADDED', event }); try { - slackLogger.error({ event }); await this.onReactionAdded(event); } catch (err) { slackLogger.error({ msg: 'Unhandled error onReactionAdded', err }); @@ -204,7 +209,7 @@ export default class SlackAdapter { * } */ this.slackApp.event('reaction_removed', async ({ event }) => { - slackLogger.debug('OnSlackEvent-REACTION_REMOVED: ', event); + slackLogger.debug({ msg: 'OnSlackEvent-REACTION_REMOVED', event }); try { await this.onReactionRemoved(event); } catch (err) { @@ -225,7 +230,7 @@ export default class SlackAdapter { * } */ this.slackApp.event('member_joined_channel', async ({ event, context }) => { - slackLogger.debug('OnSlackEvent-CHANNEL_LEFT: ', event); + slackLogger.debug({ msg: 'OnSlackEvent-CHANNEL_LEFT', event }); try { await this.processMemberJoinChannel(event, context); } catch (err) { @@ -234,7 +239,7 @@ export default class SlackAdapter { }); this.slackApp.event('channel_left', async ({ event }) => { - slackLogger.debug('OnSlackEvent-CHANNEL_LEFT: ', event); + slackLogger.debug({ msg: 'OnSlackEvent-CHANNEL_LEFT', event }); try { this.onChannelLeft(event); } catch (err) { @@ -251,7 +256,7 @@ export default class SlackAdapter { * @deprecated */ registerForEventsLegacy() { - slackLogger.debug('Register for events'); + slackLogger.debug({ msg: 'Register for events' }); this.rtm.on('authenticated', () => { slackLogger.info('Connected to Slack'); }); @@ -279,7 +284,7 @@ export default class SlackAdapter { * } **/ this.rtm.on('message', async (slackMessage) => { - slackLogger.debug('OnSlackEvent-MESSAGE: ', slackMessage); + slackLogger.debug({ msg: 'OnSlackEvent-MESSAGE', slackMessage }); if (slackMessage) { try { await this.onMessage(slackMessage); @@ -290,7 +295,7 @@ export default class SlackAdapter { }); this.rtm.on('reaction_added', async (reactionMsg) => { - slackLogger.debug('OnSlackEvent-REACTION_ADDED: ', reactionMsg); + slackLogger.debug({ msg: 'OnSlackEvent-REACTION_ADDED', reactionMsg }); if (reactionMsg) { try { await this.onReactionAdded(reactionMsg); @@ -301,7 +306,7 @@ export default class SlackAdapter { }); this.rtm.on('reaction_removed', async (reactionMsg) => { - slackLogger.debug('OnSlackEvent-REACTION_REMOVED: ', reactionMsg); + slackLogger.debug({ msg: 'OnSlackEvent-REACTION_REMOVED', reactionMsg }); if (reactionMsg) { try { await this.onReactionRemoved(reactionMsg); @@ -370,7 +375,7 @@ export default class SlackAdapter { * } **/ this.rtm.on('channel_left', (channelLeftMsg) => { - slackLogger.debug('OnSlackEvent-CHANNEL_LEFT: ', channelLeftMsg); + slackLogger.debug({ msg: 'OnSlackEvent-CHANNEL_LEFT', channelLeftMsg }); if (channelLeftMsg) { try { this.onChannelLeft(channelLeftMsg); @@ -629,7 +634,7 @@ export default class SlackAdapter { } async postFindChannel(rocketChannelName) { - slackLogger.debug('Searching for Slack channel or group', rocketChannelName); + slackLogger.debug({ msg: 'Searching for Slack channel or group', rocketChannelName }); const channels = await this.slackAPI.getChannels(); if (channels && channels.length > 0) { for (const channel of channels) { @@ -680,7 +685,7 @@ export default class SlackAdapter { addSlackChannel(rocketChID, slackChID) { const ch = this.getSlackChannel(rocketChID); if (ch == null) { - slackLogger.debug('Added channel', { rocketChID, slackChID }); + slackLogger.debug({ msg: 'Added channel', rocketChID, slackChID }); this.slackChannelRocketBotMembershipMap.set(rocketChID, { id: slackChID, family: slackChID.charAt(0) === 'C' ? 'channels' : 'groups', @@ -855,7 +860,7 @@ export default class SlackAdapter { data.thread_ts = tmessage.slackTs; } } - slackLogger.debug('Post Message To Slack', data); + slackLogger.debug({ msg: 'Post Message To Slack', data }); // If we don't have the bot id yet and we have multiple slack bridges, we need to keep track of the messages that are being sent if (!this.slackBotId && this.rocket.slackAdapters && this.rocket.slackAdapters.length >= 2) { @@ -871,7 +876,12 @@ export default class SlackAdapter { if (postResult && postResult.message && postResult.message.bot_id && postResult.message.ts) { this.slackBotId = postResult.message.bot_id; await Messages.setSlackBotIdAndSlackTs(rocketMessage._id, postResult.message.bot_id, postResult.message.ts); - slackLogger.debug(`RocketMsgID=${rocketMessage._id} SlackMsgID=${postResult.message.ts} SlackBotID=${postResult.message.bot_id}`); + slackLogger.debug({ + msg: 'Message posted to Slack', + rocketMessageId: rocketMessage._id, + slackMessageId: postResult.message.ts, + slackBotId: postResult.message.bot_id, + }); } } } @@ -887,7 +897,7 @@ export default class SlackAdapter { text: rocketMessage.msg, as_user: true, }; - slackLogger.debug('Post UpdateMessage To Slack', data); + slackLogger.debug({ msg: 'Post UpdateMessage To Slack', data }); const postResult = await this.slackAPI.updateMessage(data); if (postResult) { slackLogger.debug('Message updated on Slack'); @@ -896,7 +906,7 @@ export default class SlackAdapter { } async processMemberJoinChannel(event, context) { - slackLogger.debug('Member join channel', event.channel); + slackLogger.debug({ msg: 'Member join channel', channel: event.channel }); const rocketCh = await this.rocket.getChannel({ channel: event.channel }); if (rocketCh != null) { this.addSlackChannel(rocketCh._id, event.channel); @@ -908,7 +918,7 @@ export default class SlackAdapter { } async processChannelJoin(slackMessage) { - slackLogger.debug('Channel join', slackMessage.channel.id); + slackLogger.debug({ msg: 'Channel join', channelId: slackMessage.channel.id }); const rocketCh = await this.rocket.addChannel(slackMessage.channel); if (rocketCh != null) { this.addSlackChannel(rocketCh._id, slackMessage.channel); @@ -1310,7 +1320,7 @@ export default class SlackAdapter { msg._id = details.message_id; } - void sendMessage(rocketUser, msg, rocketChannel, true); + void sendMessage(rocketUser, msg, rocketChannel, { upsert: true }); }); } @@ -1320,7 +1330,7 @@ export default class SlackAdapter { if (Array.isArray(data.messages) && data.messages.length) { let latest = 0; for await (const message of data.messages.reverse()) { - slackLogger.debug('MESSAGE: ', message); + slackLogger.debug({ msg: 'MESSAGE', message }); if (!latest || message.ts > latest) { latest = message.ts; } @@ -1332,7 +1342,7 @@ export default class SlackAdapter { } async copyChannelInfo(rid, channelMap) { - slackLogger.debug('Copying users from Slack channel to Rocket.Chat', channelMap.id, rid); + slackLogger.debug({ msg: 'Copying users from Slack channel to Rocket.Chat', channelId: channelMap.id, rid }); const channel = await this.slackAPI.getRoomInfo(channelMap.id); if (channel) { const members = await this.slackAPI.getMembers(channelMap.id); @@ -1340,7 +1350,7 @@ export default class SlackAdapter { for await (const member of members) { const user = (await this.rocket.findUser(member)) || (await this.rocket.addUser(member)); if (user) { - slackLogger.debug('Adding user to room', user.username, rid); + slackLogger.debug({ msg: 'Adding user to room', username: user.username, rid }); await addUserToRoom(rid, user, null, { skipSystemMessage: true }); } } @@ -1369,7 +1379,7 @@ export default class SlackAdapter { if (topic) { const creator = (await this.rocket.findUser(topic_creator)) || (await this.rocket.addUser(topic_creator)); - slackLogger.debug('Setting room topic', rid, topic, creator.username); + slackLogger.debug({ msg: 'Setting room topic', rid, topic, username: creator.username }); await saveRoomTopic(rid, topic, creator, false); } } @@ -1411,13 +1421,13 @@ export default class SlackAdapter { } async importMessages(rid, callback) { - slackLogger.info('importMessages: ', rid); + slackLogger.info({ msg: 'importMessages', rid }); const rocketchat_room = await Rooms.findOneById(rid); if (rocketchat_room) { if (this.getSlackChannel(rid)) { await this.copyChannelInfo(rid, this.getSlackChannel(rid)); - slackLogger.debug('Importing messages from Slack to Rocket.Chat', this.getSlackChannel(rid), rid); + slackLogger.debug({ msg: 'Importing messages from Slack to Rocket.Chat', slackChannel: this.getSlackChannel(rid), rid }); let results = await this.importFromHistory({ channel: this.getSlackChannel(rid).id, @@ -1431,7 +1441,7 @@ export default class SlackAdapter { }); } - slackLogger.debug('Pinning Slack channel messages to Rocket.Chat', this.getSlackChannel(rid), rid); + slackLogger.debug({ msg: 'Pinning Slack channel messages to Rocket.Chat', slackChannel: this.getSlackChannel(rid), rid }); await this.copyPins(rid, this.getSlackChannel(rid)); return callback(); diff --git a/apps/meteor/app/slackbridge/server/slackbridge.js b/apps/meteor/app/slackbridge/server/slackbridge.ts similarity index 80% rename from apps/meteor/app/slackbridge/server/slackbridge.js rename to apps/meteor/app/slackbridge/server/slackbridge.ts index 89ff66a13397e..13455b8068508 100644 --- a/apps/meteor/app/slackbridge/server/slackbridge.js +++ b/apps/meteor/app/slackbridge/server/slackbridge.ts @@ -1,7 +1,12 @@ +// This is a JS File that was renamed to TS so it won't lose its git history when converted to TS +// TODO: Remove the following lint/ts instructions when the file gets properly converted +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ +// @ts-nocheck import { debounce } from 'lodash'; -import RocketAdapter from './RocketAdapter.js'; -import SlackAdapter from './SlackAdapter.js'; +import RocketAdapter from './RocketAdapter'; +import SlackAdapter from './SlackAdapter'; import { classLogger, connLogger } from './logger'; import { settings } from '../../settings/server'; @@ -45,7 +50,7 @@ class SlackBridgeClass { this.rocket.addSlack(slack); this.slackAdapters.push(slack); - slack.connect({ apiToken }).catch((err) => connLogger.error('error connecting to slack', err)); + slack.connect({ apiToken }).catch((err) => connLogger.error({ msg: 'error connecting to slack', err })); }); } else { const botTokenList = this.botTokens.split('\n'); // Bot token list @@ -70,7 +75,7 @@ class SlackBridgeClass { this.rocket.addSlack(slack); this.slackAdapters.push(slack); - slack.connect({ appCredential }).catch((err) => connLogger.error('error connecting to slack', err)); + slack.connect({ appCredential }).catch((err) => connLogger.error({ msg: 'error connecting to slack', err })); }); } @@ -109,7 +114,7 @@ class SlackBridgeClass { connLogger.info('Slack Bridge Disconnected'); } } catch (error) { - connLogger.error('An error occurred during disconnection', error); + connLogger.error({ msg: 'An error occurred during disconnection', err: error }); } } @@ -120,7 +125,7 @@ class SlackBridgeClass { this.isLegacyRTM = value; this.debouncedReconnectIfEnabled(); } - classLogger.debug('Setting: SlackBridge_UseLegacy', value); + classLogger.debug({ msg: 'Setting: SlackBridge_UseLegacy', value }); }); // Slack installtion Bot token @@ -129,7 +134,7 @@ class SlackBridgeClass { this.botTokens = value; this.debouncedReconnectIfEnabled(); } - classLogger.debug('Setting: SlackBridge_BotToken', value); + classLogger.debug({ msg: 'Setting: SlackBridge_BotToken', value }); }); // Slack installtion App token settings.watch('SlackBridge_AppToken', (value) => { @@ -137,7 +142,7 @@ class SlackBridgeClass { this.appTokens = value; this.debouncedReconnectIfEnabled(); } - classLogger.debug('Setting: SlackBridge_AppToken', value); + classLogger.debug({ msg: 'Setting: SlackBridge_AppToken', value }); }); // Slack installtion Signing token settings.watch('SlackBridge_SigningSecret', (value) => { @@ -145,7 +150,7 @@ class SlackBridgeClass { this.signingSecrets = value; this.debouncedReconnectIfEnabled(); } - classLogger.debug('Setting: SlackBridge_SigningSecret', value); + classLogger.debug({ msg: 'Setting: SlackBridge_SigningSecret', value }); }); // Slack installation API token @@ -155,25 +160,25 @@ class SlackBridgeClass { this.debouncedReconnectIfEnabled(); } - classLogger.debug('Setting: SlackBridge_APIToken', value); + classLogger.debug({ msg: 'Setting: SlackBridge_APIToken', value }); }); // Import messages from Slack with an alias; %s is replaced by the username of the user. If empty, no alias will be used. settings.watch('SlackBridge_AliasFormat', (value) => { this.aliasFormat = value; - classLogger.debug('Setting: SlackBridge_AliasFormat', value); + classLogger.debug({ msg: 'Setting: SlackBridge_AliasFormat', value }); }); // Do not propagate messages from bots whose name matches the regular expression above. If left empty, all messages from bots will be propagated. settings.watch('SlackBridge_ExcludeBotnames', (value) => { this.excludeBotnames = value; - classLogger.debug('Setting: SlackBridge_ExcludeBotnames', value); + classLogger.debug({ msg: 'Setting: SlackBridge_ExcludeBotnames', value }); }); // Reactions settings.watch('SlackBridge_Reactions_Enabled', (value) => { this.isReactionsEnabled = value; - classLogger.debug('Setting: SlackBridge_Reactions_Enabled', value); + classLogger.debug({ msg: 'Setting: SlackBridge_Reactions_Enabled', value }); }); // Is this entire SlackBridge enabled @@ -186,7 +191,7 @@ class SlackBridgeClass { this.disconnect(); } } - classLogger.debug('Setting: SlackBridge_Enabled', value); + classLogger.debug({ msg: 'Setting: SlackBridge_Enabled', value }); }); } } diff --git a/apps/meteor/app/slackbridge/server/slackbridge_import.server.js b/apps/meteor/app/slackbridge/server/slackbridge_import.server.ts similarity index 88% rename from apps/meteor/app/slackbridge/server/slackbridge_import.server.js rename to apps/meteor/app/slackbridge/server/slackbridge_import.server.ts index 6e7117af976a2..7eda03f908c44 100644 --- a/apps/meteor/app/slackbridge/server/slackbridge_import.server.js +++ b/apps/meteor/app/slackbridge/server/slackbridge_import.server.ts @@ -1,3 +1,7 @@ +// This is a JS File that was renamed to TS so it won't lose its git history when converted to TS +// TODO: Remove the following lint/ts instructions when the file gets properly converted +/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ +// @ts-nocheck import { Rooms, Users } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import { Match } from 'meteor/check'; diff --git a/apps/meteor/app/slashcommands-help/server/server.ts b/apps/meteor/app/slashcommands-help/server/server.ts index 80efaffeb8526..4d826996b43f6 100644 --- a/apps/meteor/app/slashcommands-help/server/server.ts +++ b/apps/meteor/app/slashcommands-help/server/server.ts @@ -65,6 +65,7 @@ slashCommands.add({ }); void api.broadcast('notify.ephemeralMessage', userId, message.rid, { msg, + ...(message.tmid && { tmid: message.tmid }), }); }, options: { diff --git a/apps/meteor/app/slashcommands-join/server/server.ts b/apps/meteor/app/slashcommands-join/server/server.ts index 2a70552ef839f..4d7b3fe001f7b 100644 --- a/apps/meteor/app/slashcommands-join/server/server.ts +++ b/apps/meteor/app/slashcommands-join/server/server.ts @@ -43,7 +43,7 @@ slashCommands.add({ }); } - const user = await Users.findOneById(userId, { projection: { federated: 1, federation: 1 } }); + const user = await Users.findOneById(userId); if (!user) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'slashCommands', diff --git a/apps/meteor/app/slashcommands-status/server/status.ts b/apps/meteor/app/slashcommands-status/server/status.ts index a2ff6483d398e..0a1f02b182571 100644 --- a/apps/meteor/app/slashcommands-status/server/status.ts +++ b/apps/meteor/app/slashcommands-status/server/status.ts @@ -1,5 +1,5 @@ import { api } from '@rocket.chat/core-services'; -import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import type { SlashCommandCallbackParams, IUser } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import { i18n } from '../../../server/lib/i18n'; @@ -14,11 +14,19 @@ slashCommands.add({ return; } - const user = await Users.findOneById(userId, { projection: { language: 1 } }); + const user = await Users.findOneById>( + userId, + { + projection: { language: 1, username: 1, name: 1, status: 1, roles: 1, statusText: 1 }, + }, + ); const lng = user?.language || settings.get('Language') || 'en'; + if (!user) { + return; + } try { - await setUserStatusMethod(userId, undefined, params); + await setUserStatusMethod(user, undefined, params); void api.broadcast('notify.ephemeralMessage', userId, message.rid, { msg: i18n.t('StatusMessage_Changed_Successfully', { lng }), diff --git a/apps/meteor/app/statistics/server/functions/sendUsageReport.ts b/apps/meteor/app/statistics/server/functions/sendUsageReport.ts index 048d0f54d9882..b96c8177da17a 100644 --- a/apps/meteor/app/statistics/server/functions/sendUsageReport.ts +++ b/apps/meteor/app/statistics/server/functions/sendUsageReport.ts @@ -22,6 +22,8 @@ async function sendStats(logger: Logger, cronStatistics: IStats): Promise { + sauEvents.on('sau.socket.disconnected', async ({ connectionId, instanceId }) => { if (!this.isRunning()) { return; } - await Sessions.closeByInstanceIdAndSessionId(instanceId, id); + await Sessions.closeByInstanceIdAndSessionId(instanceId, connectionId); }); } @@ -111,7 +120,7 @@ export class SAUMonitorClass { return; } - sauEvents.on('accounts.login', async ({ userId, connection }) => { + sauEvents.on('sau.accounts.login', async ({ userId, instanceId, userAgent, loginToken, connectionId, clientAddress, host }) => { if (!this.isRunning()) { return; } @@ -121,23 +130,22 @@ export class SAUMonitorClass { const mostImportantRole = getMostImportantRole(roles); const loginAt = new Date(); - const params = { userId, roles, mostImportantRole, loginAt, ...getDateObj() }; - await this._handleSession(connection, params); + const params = { roles, mostImportantRole, loginAt, ...getDateObj() }; + await this._handleSession({ userId, instanceId, userAgent, loginToken, connectionId, clientAddress, host }, params); }); - sauEvents.on('accounts.logout', async ({ userId, connection }) => { + sauEvents.on('sau.accounts.logout', async ({ userId, sessionId }) => { if (!this.isRunning()) { return; } if (!userId) { - logger.warn({ msg: "Received 'accounts.logout' event without 'userId'" }); + logger.warn({ msg: "Received 'sau.accounts.logout' event without 'userId'" }); return; } - const { id: sessionId } = connection; if (!sessionId) { - logger.warn({ msg: "Received 'accounts.logout' event without 'sessionId'" }); + logger.warn({ msg: "Received 'sau.accounts.logout' event without 'sessionId'" }); return; } @@ -157,14 +165,20 @@ export class SAUMonitorClass { } private async _handleSession( - connection: ISocketConnectionLogged, - params: Pick, + { userId, instanceId, userAgent, loginToken, connectionId, clientAddress, host }: HandleSessionArgs, + params: Pick, ): Promise { - const data = this._getConnectionInfo(connection, params); - - if (!data) { - return; - } + const data: Omit = { + userId, + ...(loginToken && { loginToken }), + ip: clientAddress, + host, + sessionId: connectionId, + instanceId, + type: 'session', + ...(loginToken && this._getUserAgentInfo(userAgent)), + ...params, + }; const searchTerm = this._getSearchTerm(data); @@ -221,37 +235,7 @@ export class SAUMonitorClass { .join(''); } - private _getConnectionInfo( - connection: ISocketConnectionLogged, - params: Pick, - ): Omit | undefined { - if (!connection) { - return; - } - - const ip = getClientAddress(connection); - - const host = connection.httpHeaders?.host ?? ''; - - return { - type: 'session', - sessionId: connection.id, - instanceId: connection.instanceId, - ...(connection.loginToken && { loginToken: connection.loginToken }), - ip, - host, - ...this._getUserAgentInfo(connection), - ...params, - }; - } - - private _getUserAgentInfo(connection: ISocketConnectionLogged): { device: ISessionDevice } | undefined { - if (!connection?.httpHeaders?.['user-agent']) { - return; - } - - const uaString = connection.httpHeaders['user-agent']; - + private _getUserAgentInfo(uaString: string): { device: ISessionDevice } | undefined { // TODO define a type for "result" below // | UAParser.IResult // | { device: { type: string; model?: string }; browser: undefined; os: undefined; app: { name: string; version: string } } diff --git a/apps/meteor/app/statistics/server/lib/statistics.ts b/apps/meteor/app/statistics/server/lib/statistics.ts index 29f09fef5386f..c0aadb9965691 100644 --- a/apps/meteor/app/statistics/server/lib/statistics.ts +++ b/apps/meteor/app/statistics/server/lib/statistics.ts @@ -587,6 +587,8 @@ export const statistics = { ); } + statistics.allowUnsafeQueryAndFieldsApiParamsEnabled = process.env.ALLOW_UNSAFE_QUERY_AND_FIELDS_API_PARAMS?.toUpperCase() === 'TRUE'; + await Promise.all(statsPms).catch(log); return statistics; diff --git a/apps/meteor/app/theme/client/imports/general/base_old.css b/apps/meteor/app/theme/client/imports/general/base_old.css index 5cf4a01fe8f45..7486bd9aacb07 100644 --- a/apps/meteor/app/theme/client/imports/general/base_old.css +++ b/apps/meteor/app/theme/client/imports/general/base_old.css @@ -112,7 +112,6 @@ } code { - margin: 5px 0; padding: 0.5em; text-align: left; vertical-align: middle; diff --git a/apps/meteor/app/theme/client/main.css b/apps/meteor/app/theme/client/main.css index 2b2e026f57b93..036ed328b4233 100644 --- a/apps/meteor/app/theme/client/main.css +++ b/apps/meteor/app/theme/client/main.css @@ -12,6 +12,5 @@ /* Legacy theming */ @import url('imports/general/theme_old.css'); -@import url('./vendor/fontello/css/fontello.css'); @import url('./rocketchat.font.css'); @import url('../../../node_modules/@rocket.chat/fuselage/dist/fuselage.css'); diff --git a/apps/meteor/app/theme/client/vendor/fontello/css/fontello.css b/apps/meteor/app/theme/client/vendor/fontello/css/fontello.css deleted file mode 100755 index bb6b8ca4667b6..0000000000000 --- a/apps/meteor/app/theme/client/vendor/fontello/css/fontello.css +++ /dev/null @@ -1,85 +0,0 @@ -@font-face { - font-family: 'fontello'; - src: url('/font/fontello.eot'); - src: url('/font/fontello.eot#iefix') format('embedded-opentype'), url('/font/fontello.woff2') format('woff2'), - url('/font/fontello.woff') format('woff'), url('/font/fontello.ttf') format('truetype'), - url('/font/fontello.svg#fontello') format('svg'); - font-weight: normal; - font-style: normal; -} -/* Chrome hack: SVG is rendered more smooth in Windozze. 100% magic, uncomment if you need it. */ -/* Note, that will break hinting! In other OS-es font will be not as sharp as it could be */ -/* -@media screen and (-webkit-min-device-pixel-ratio:0) { - @font-face { - font-family: 'fontello'; - src: url('../font/fontello.svg?41526386#fontello') format('svg'); - } -} -*/ -[class^='icon-']:before, -[class*=' icon-']:before { - font-family: 'fontello'; - font-style: normal; - font-weight: normal; - speak: never; - - display: inline-block; - text-decoration: inherit; - width: 1em; - margin-right: 0.2em; - text-align: center; - /* opacity: .8; */ - - /* For safety - reset parent styles, that can break glyph codes*/ - font-variant: normal; - text-transform: none; - - /* fix buttons height, for twitter bootstrap */ - line-height: 1em; - - /* Animation center compensation - margins should be symmetric */ - /* remove if not needed */ - margin-left: 0.2em; - - /* you can be more comfortable with increased icons size */ - /* font-size: 120%; */ - - /* Font smoothing. That was taken from TWBS */ - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - /* Uncomment for 3D effect */ - /* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */ -} - -.icon-rocket:before { - content: '\e8da'; -} /* '' */ -.icon-food:before { - content: '\e8f8'; -} /* '' */ -.icon-travel:before { - content: '\e966'; -} /* '' */ -.icon-symbols:before { - content: '\e967'; -} /* '' */ -.icon-recent:before { - content: '\e968'; -} /* '' */ -.icon-people:before { - content: '\e969'; -} /* '' */ -.icon-objects:before { - content: '\e96a'; -} /* '' */ -.icon-nature:before { - content: '\e96b'; -} /* '' */ -.icon-activity:before { - content: '\e96d'; -} /* '' */ -.icon-flags:before { - content: '\e96e'; -} /* '' */ diff --git a/apps/meteor/app/threads/server/methods/followMessage.ts b/apps/meteor/app/threads/server/methods/followMessage.ts index 88d1b6274002a..ef32595e930a0 100644 --- a/apps/meteor/app/threads/server/methods/followMessage.ts +++ b/apps/meteor/app/threads/server/methods/followMessage.ts @@ -1,5 +1,5 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; -import type { IMessage } from '@rocket.chat/core-typings'; +import type { IMessage, IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Messages } from '@rocket.chat/models'; import { check } from 'meteor/check'; @@ -18,7 +18,7 @@ declare module '@rocket.chat/ddp-client' { } } -export const followMessage = async (userId: string, { mid }: { mid: IMessage['_id'] }): Promise => { +export const followMessage = async (user: IUser, { mid }: { mid: IMessage['_id'] }): Promise => { if (mid && !settings.get('Threads_enabled')) { throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'followMessage' }); } @@ -30,20 +30,20 @@ export const followMessage = async (userId: string, { mid }: { mid: IMessage['_i }); } - if (!(await canAccessRoomIdAsync(message.rid, userId))) { + if (!(await canAccessRoomIdAsync(message.rid, user._id))) { throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'followMessage' }); } const id = message.tmid || message._id; - const followResult = await follow({ tmid: id, uid: userId }); + const followResult = await follow({ tmid: id, uid: user._id }); void notifyOnMessageChange({ id, }); const isFollowed = true; - await Apps.self?.triggerEvent(AppEvents.IPostMessageFollowed, message, await Meteor.userAsync(), isFollowed); + await Apps.self?.triggerEvent(AppEvents.IPostMessageFollowed, message, user, isFollowed); return followResult; }; @@ -52,12 +52,12 @@ Meteor.methods({ async followMessage({ mid }) { check(mid, String); - const uid = Meteor.userId(); - if (!uid) { + const user = (await Meteor.userAsync()) as IUser; + if (!user) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'followMessage' }); } - return followMessage(uid, { mid }); + return followMessage(user, { mid }); }, }); diff --git a/apps/meteor/app/threads/server/methods/unfollowMessage.ts b/apps/meteor/app/threads/server/methods/unfollowMessage.ts index d19bdf6040051..f5ffaa19fc7af 100644 --- a/apps/meteor/app/threads/server/methods/unfollowMessage.ts +++ b/apps/meteor/app/threads/server/methods/unfollowMessage.ts @@ -1,5 +1,5 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; -import type { IMessage } from '@rocket.chat/core-typings'; +import type { IMessage, IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Messages } from '@rocket.chat/models'; import { check } from 'meteor/check'; @@ -18,7 +18,7 @@ declare module '@rocket.chat/ddp-client' { } } -export const unfollowMessage = async (userId: string, { mid }: { mid: IMessage['_id'] }): Promise => { +export const unfollowMessage = async (user: IUser, { mid }: { mid: IMessage['_id'] }): Promise => { if (mid && !settings.get('Threads_enabled')) { throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'unfollowMessage' }); } @@ -30,20 +30,20 @@ export const unfollowMessage = async (userId: string, { mid }: { mid: IMessage[' }); } - if (!(await canAccessRoomIdAsync(message.rid, userId))) { + if (!(await canAccessRoomIdAsync(message.rid, user._id))) { throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'unfollowMessage' }); } const id = message.tmid || message._id; - const unfollowResult = await unfollow({ rid: message.rid, tmid: id, uid: userId }); + const unfollowResult = await unfollow({ rid: message.rid, tmid: id, uid: user._id }); void notifyOnMessageChange({ id, }); const isFollowed = false; - await Apps.self?.triggerEvent(AppEvents.IPostMessageFollowed, message, await Meteor.userAsync(), isFollowed); + await Apps.self?.triggerEvent(AppEvents.IPostMessageFollowed, message, user, isFollowed); return unfollowResult; }; @@ -52,12 +52,12 @@ Meteor.methods({ async unfollowMessage({ mid }) { check(mid, String); - const uid = Meteor.userId(); - if (!uid) { + const user = (await Meteor.userAsync()) as IUser; + if (!user) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'unfollowMessage' }); } - return unfollowMessage(uid, { mid }); + return unfollowMessage(user, { mid }); }, }); diff --git a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts index f0f207b55cffe..1d901f0c80bdb 100644 --- a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts +++ b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts @@ -4,7 +4,6 @@ import { differenceInMilliseconds } from 'date-fns'; import { ReactiveVar } from 'meteor/reactive-var'; import { Tracker } from 'meteor/tracker'; import type { MutableRefObject } from 'react'; -import { v4 as uuidv4 } from 'uuid'; import { onClientMessageReceived } from '../../../../client/lib/onClientMessageReceived'; import { getUserId } from '../../../../client/lib/user'; @@ -23,7 +22,7 @@ const processMessage = async (msg: IMessage & { ignored?: boolean }, { subscript msg.ignored = true; } - if (msg.t === 'e2e' && !msg.file) { + if (msg.t === 'e2e') { msg.e2e = 'pending'; } @@ -86,7 +85,7 @@ class RoomHistoryManagerClass extends Emitter { private async queue(): Promise { return new Promise((resolve) => { - const requestId = uuidv4(); + const requestId = crypto.randomUUID(); const done = () => { this.lastRequest = new Date(); resolve(); diff --git a/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.spec.ts b/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.spec.ts new file mode 100644 index 0000000000000..f0a4e58f8a6f8 --- /dev/null +++ b/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.spec.ts @@ -0,0 +1,188 @@ +import { VideoRecorder } from './videoRecorder'; +import { createDeferredPromise } from '../../../../../tests/mocks/utils/createDeferredMockFn'; + +jest.mock('meteor/reactive-var', () => ({ + ReactiveVar: jest.fn().mockImplementation((initialValue) => { + let value = initialValue; + return { + get: jest.fn(() => value), + set: jest.fn((newValue) => { + value = newValue; + }), + }; + }), +})); + +describe('VideoRecorder', () => { + let mockStream: MediaStream; + let mockVideoTrack: MediaStreamTrack; + let mockAudioTrack: MediaStreamTrack; + let mockVideoElement: HTMLVideoElement; + let getUserMediaMock: jest.Mock; + + const createMockStream = (videoTrack?: MediaStreamTrack, audioTrack?: MediaStreamTrack): MediaStream => { + return { + getVideoTracks: jest.fn(() => [videoTrack || ({ stop: jest.fn() } as unknown as MediaStreamTrack)]), + getAudioTracks: jest.fn(() => [audioTrack || ({ stop: jest.fn() } as unknown as MediaStreamTrack)]), + } as unknown as MediaStream; + }; + + beforeEach(() => { + jest.useFakeTimers(); + + mockVideoTrack = { + stop: jest.fn(), + } as unknown as MediaStreamTrack; + + mockAudioTrack = { + stop: jest.fn(), + } as unknown as MediaStreamTrack; + + mockStream = createMockStream(mockVideoTrack, mockAudioTrack); + + mockVideoElement = document.createElement('video'); + mockVideoElement.load = jest.fn(); + mockVideoElement.play = jest.fn().mockResolvedValue(undefined); + mockVideoElement.pause = jest.fn(); + + getUserMediaMock = jest.fn(); + + Object.defineProperty(global.navigator, 'mediaDevices', { + writable: true, + value: { + getUserMedia: getUserMediaMock, + }, + }); + + global.MediaRecorder = { + isTypeSupported: jest.fn((type: string) => type === 'video/webm'), + } as any; + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + describe('Asynchronous start and stop handling', () => { + it('should stop camera tracks when stop is called before getUserMedia resolves', async () => { + const streamDeferred = createDeferredPromise(); + + getUserMediaMock.mockReturnValue(streamDeferred.promise); + + const callback = jest.fn(); + VideoRecorder.start(mockVideoElement, callback); + VideoRecorder.stop(); + + streamDeferred.resolve(mockStream); + await jest.runAllTimersAsync(); + + expect(mockVideoTrack.stop).toHaveBeenCalled(); + expect(mockAudioTrack.stop).toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalledWith(true); + }); + + it('should not initialize camera when stopped early', async () => { + const streamDeferred = createDeferredPromise(); + + getUserMediaMock.mockReturnValue(streamDeferred.promise); + + VideoRecorder.start(mockVideoElement, jest.fn()); + VideoRecorder.stop(); + + streamDeferred.resolve(mockStream); + await jest.runAllTimersAsync(); + + expect(VideoRecorder.cameraStarted.get()).toBe(false); + }); + + it('should handle multiple start/stop cycles', async () => { + const stream1 = createMockStream(); + const stream2 = createMockStream(mockVideoTrack, mockAudioTrack); + + getUserMediaMock.mockReturnValueOnce(Promise.resolve(stream1)); + + VideoRecorder.start(mockVideoElement, jest.fn()); + VideoRecorder.stop(); + + const stream2Deferred = createDeferredPromise(); + getUserMediaMock.mockReturnValueOnce(stream2Deferred.promise); + + const cb = jest.fn(); + VideoRecorder.start(mockVideoElement, cb); + + stream2Deferred.resolve(stream2); + await jest.runAllTimersAsync(); + + expect(cb).toHaveBeenCalledWith(true); + expect(VideoRecorder.cameraStarted.get()).toBe(true); + }); + + it('should invalidate pending callbacks from previous start when new start is called', async () => { + const firstStream = createMockStream(); + const secondStream = createMockStream(mockVideoTrack, mockAudioTrack); + + const firstDeferred = createDeferredPromise(); + const secondDeferred = createDeferredPromise(); + + getUserMediaMock.mockReturnValueOnce(firstDeferred.promise).mockReturnValueOnce(secondDeferred.promise); + + const cb1 = jest.fn(); + const cb2 = jest.fn(); + + VideoRecorder.start(mockVideoElement, cb1); + VideoRecorder.start(mockVideoElement, cb2); + + secondDeferred.resolve(secondStream); + await jest.runAllTimersAsync(); + firstDeferred.resolve(firstStream); + await jest.runAllTimersAsync(); + + expect(firstStream.getVideoTracks).toHaveBeenCalled(); + expect(firstStream.getAudioTracks).toHaveBeenCalled(); + expect(cb2).toHaveBeenCalledWith(true); + expect(cb1).not.toHaveBeenCalledWith(true); + }); + }); + + describe('Normal operation', () => { + it('should initialize camera', async () => { + getUserMediaMock.mockResolvedValue(mockStream); + + const cb = jest.fn(); + VideoRecorder.start(mockVideoElement, cb); + + await jest.runAllTimersAsync(); + + expect(cb).toHaveBeenCalledWith(true); + expect(VideoRecorder.cameraStarted.get()).toBe(true); + }); + + it('should stop camera tracks', () => { + (VideoRecorder as any).stream = mockStream; + (VideoRecorder as any).started = true; + VideoRecorder.cameraStarted.set(true); + + VideoRecorder.stop(); + + expect(mockVideoTrack.stop).toHaveBeenCalled(); + expect(mockAudioTrack.stop).toHaveBeenCalled(); + expect(VideoRecorder.cameraStarted.get()).toBe(false); + }); + + it('should return supported mime types', () => { + expect(VideoRecorder.getSupportedMimeTypes()).toBe('video/webm; codecs=vp8,opus'); + }); + + it('should handle permission errors', async () => { + getUserMediaMock.mockRejectedValue(new Error('Permission denied')); + + const cb = jest.fn(); + VideoRecorder.start(mockVideoElement, cb); + + await jest.runAllTimersAsync(); + + expect(cb).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.ts b/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.ts index 10424c5b3f860..0557f4706cd8b 100644 --- a/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.ts +++ b/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.ts @@ -17,6 +17,8 @@ class VideoRecorder { private mediaRecorder: MediaRecorder | undefined; + private sessionId = 0; + public getSupportedMimeTypes() { if (window.MediaRecorder.isTypeSupported('video/webm')) { return 'video/webm; codecs=vp8,opus'; @@ -29,8 +31,13 @@ class VideoRecorder { public start(videoel?: HTMLVideoElement, cb?: (this: this, success: boolean) => void) { this.videoel = videoel; + const currentSessionId = ++this.sessionId; const handleSuccess = (stream: MediaStream) => { + if (this.isStaleSession(currentSessionId)) { + this.stopStreamTracks(stream); + return; + } this.startUserMedia(stream); cb?.call(this, true); }; @@ -72,6 +79,22 @@ class VideoRecorder { return this.recording.set(true); } + private stopStreamTracks(stream: MediaStream) { + const vtracks = stream.getVideoTracks(); + for (const vtrack of Array.from(vtracks)) { + vtrack.stop(); + } + + const atracks = stream.getAudioTracks(); + for (const atrack of Array.from(atracks)) { + atrack.stop(); + } + } + + private isStaleSession(sessionId: number): boolean { + return this.sessionId !== sessionId; + } + private startUserMedia(stream: MediaStream) { if (!this.videoel) { return; @@ -90,34 +113,25 @@ class VideoRecorder { } public stop(cb?: (blob: Blob) => void) { - if (!this.started) { - return; - } + this.sessionId++; this.stopRecording(); if (this.stream) { - const vtracks = this.stream.getVideoTracks(); - for (const vtrack of Array.from(vtracks)) { - vtrack.stop(); - } - - const atracks = this.stream.getAudioTracks(); - for (const atrack of Array.from(atracks)) { - atrack.stop(); - } + this.stopStreamTracks(this.stream); } if (this.videoel) { - this.videoel.pause; + this.videoel.pause(); this.videoel.src = ''; } + const wasStarted = this.started; this.started = false; this.cameraStarted.set(false); this.recordingAvailable.set(false); - if (cb && this.chunks) { + if (cb && this.chunks && wasStarted) { const blob = new Blob(this.chunks); cb(blob); } diff --git a/apps/meteor/app/user-status/server/methods/setUserStatus.ts b/apps/meteor/app/user-status/server/methods/setUserStatus.ts index 0b40e7e37246b..0235c4b681853 100644 --- a/apps/meteor/app/user-status/server/methods/setUserStatus.ts +++ b/apps/meteor/app/user-status/server/methods/setUserStatus.ts @@ -15,14 +15,18 @@ declare module '@rocket.chat/ddp-client' { } } -export const setUserStatusMethod = async (userId: string, statusType: IUser['status'], statusText: IUser['statusText']): Promise => { +export const setUserStatusMethod = async ( + user: Pick, + statusType: IUser['status'], + statusText: IUser['statusText'], +): Promise => { if (statusType) { if (statusType === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) { throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', { method: 'setUserStatus', }); } - await Presence.setStatus(userId, statusType); + await Presence.setStatus(user._id, statusType); } if (statusText || statusText === '') { @@ -34,18 +38,18 @@ export const setUserStatusMethod = async (userId: string, statusType: IUser['sta }); } - await setStatusText(userId, statusText); + await setStatusText(user, statusText); } }; Meteor.methods({ setUserStatus: async (statusType, statusText) => { - const userId = Meteor.userId(); - if (!userId) { + const user = (await Meteor.userAsync()) as IUser; + if (!user) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setUserStatus' }); } - await setUserStatusMethod(userId, statusType, statusText); + await setUserStatusMethod(user, statusType, statusText); }, }); diff --git a/apps/meteor/app/utils/rocketchat.info b/apps/meteor/app/utils/rocketchat.info index b64ed0c59605b..0121e2772256b 100644 --- a/apps/meteor/app/utils/rocketchat.info +++ b/apps/meteor/app/utils/rocketchat.info @@ -1,3 +1,3 @@ { - "version": "8.1.1" + "version": "8.2.0-rc.0" } diff --git a/apps/meteor/app/utils/server/functions/safeGetMeteorUser.ts b/apps/meteor/app/utils/server/functions/safeGetMeteorUser.ts deleted file mode 100644 index 055e0f1ef89db..0000000000000 --- a/apps/meteor/app/utils/server/functions/safeGetMeteorUser.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -const invalidEnvironmentErrorMessage = 'Meteor.userId can only be invoked in method calls or publications.'; - -/** - * Helper that executes the `Meteor.userAsync()`, but - * supresses errors thrown if the code isn't - * executed inside Meteor's environment - * - * Use this function only if it the code path is - * expected to run out of Meteor's environment and - * is prepared to handle those cases. Otherwise, it - * is advisable to call `Meteor.userAsync()` directly - * - * @returns The current user in the Meteor session, or null if not available - */ -export async function safeGetMeteorUser(): Promise { - try { - // Explicitly await here otherwise the try...catch wouldn't work. - return await Meteor.userAsync(); - } catch (error: any) { - // This is the only type of error we want to capture and supress, - // so if the error thrown is different from what we expect, we let it go - if (error?.message !== invalidEnvironmentErrorMessage) { - throw error; - } - - return null; - } -} diff --git a/apps/meteor/app/version-check/server/functions/getNewUpdates.ts b/apps/meteor/app/version-check/server/functions/getNewUpdates.ts index 926926253a6c9..60bacbe71c9d4 100644 --- a/apps/meteor/app/version-check/server/functions/getNewUpdates.ts +++ b/apps/meteor/app/version-check/server/functions/getNewUpdates.ts @@ -38,6 +38,8 @@ export const getNewUpdates = async () => { const response = await fetch(url, { headers, params, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, }); const data = await response.json(); diff --git a/apps/meteor/app/webdav/server/methods/uploadFileToWebdav.ts b/apps/meteor/app/webdav/server/methods/uploadFileToWebdav.ts index 97bcf46322161..6d66031aa4173 100644 --- a/apps/meteor/app/webdav/server/methods/uploadFileToWebdav.ts +++ b/apps/meteor/app/webdav/server/methods/uploadFileToWebdav.ts @@ -38,17 +38,17 @@ Meteor.methods({ try { await uploadFileToWebdav(accountId, fileData instanceof ArrayBuffer ? Buffer.from(fileData) : fileData, name); return { success: true }; - } catch (error: any) { - if (typeof error === 'object' && error instanceof Error && error.name === 'error-invalid-account') { - throw new MeteorError(error.name, 'Invalid WebDAV Account', { + } catch (err: any) { + if (typeof err === 'object' && err instanceof Error && err.name === 'error-invalid-account') { + throw new MeteorError(err.name, 'Invalid WebDAV Account', { method: 'uploadFileToWebdav', }); } - logger.error(error); + logger.error({ err }); - if (error.response) { - const { status } = error.response; + if (err.response) { + const { status } = err.response; if (status === 404) { return { success: false, message: 'webdav-server-not-found' }; } diff --git a/apps/meteor/client/components/ABAC/ABACUpsellModal/__snapshots__/ABACUpsellModal.spec.tsx.snap b/apps/meteor/client/components/ABAC/ABACUpsellModal/__snapshots__/ABACUpsellModal.spec.tsx.snap index d3afc0672db5e..3d544cb7d96a3 100644 --- a/apps/meteor/client/components/ABAC/ABACUpsellModal/__snapshots__/ABACUpsellModal.spec.tsx.snap +++ b/apps/meteor/client/components/ABAC/ABACUpsellModal/__snapshots__/ABACUpsellModal.spec.tsx.snap @@ -36,6 +36,7 @@ exports[`ABACUpsellModal should render the modal with correct content 1`] = ` - - - - + + {errors.parentRoom && ( + + {errors.parentRoom.message} + + )} + + + + {t('Name')} + + + ( + } + /> + )} + /> + + {errors.name && ( + + {errors.name.message} + + )} + + + {t('Topic')} + + } + /> + + + {t('Displayed_next_to_name')} + + + + {t('Members')} + + ( + + )} + /> + + + + {t('Discussion_first_message_title')} + + ( + + )} + /> + + {encrypted ? ( + {t('Discussion_first_message_disabled_due_to_e2e')} + ) : ( + {t('First_message_hint')} + )} + + + + {t('Encrypted')} + ( + + )} + /> + + {getEncryptedHint({ isPrivate: true, encrypted })} + + + ); }; diff --git a/apps/meteor/client/components/CreateDiscussion/DefaultParentRoomField.tsx b/apps/meteor/client/components/CreateDiscussion/DefaultParentRoomField.tsx index fd012f7c5f8a3..b34940ab5bd4b 100644 --- a/apps/meteor/client/components/CreateDiscussion/DefaultParentRoomField.tsx +++ b/apps/meteor/client/components/CreateDiscussion/DefaultParentRoomField.tsx @@ -1,12 +1,16 @@ import { Skeleton, TextInput, Callout } from '@rocket.chat/fuselage'; import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; -import type { ReactElement } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import { useMemo } from 'react'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; -const DefaultParentRoomField = ({ defaultParentRoom }: { defaultParentRoom: string }): ReactElement => { +type DefaultParentRoomFieldProps = { + defaultParentRoom: string; +} & Omit, 'defaultValue' | 'disabled'>; + +const DefaultParentRoomField = ({ defaultParentRoom, ...props }: DefaultParentRoomFieldProps) => { const t = useTranslation(); const query = useMemo( @@ -34,6 +38,7 @@ const DefaultParentRoomField = ({ defaultParentRoom }: { defaultParentRoom: stri return ( +
+ +
+
+
+
+

+ Discussion_title +

+
+ +
+
+
+
+
+ Discussion_description +
+
+
+ + +
+
+ +
+
+ +
+
+
+
+
+ + + + +
+
+ + + + + + + Displayed_next_to_name + + +
+
+ + +
+
+
+ +
+
+
+ +
+
+
+
+
+ + +