diff --git a/.changeset/bump-patch-1771886743378.md b/.changeset/bump-patch-1771886743378.md
new file mode 100644
index 0000000000000..e1eaa7980afb1
--- /dev/null
+++ b/.changeset/bump-patch-1771886743378.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/meteor': patch
+---
+
+Bump @rocket.chat/meteor version.
diff --git a/.changeset/bump-patch-1771997173145.md b/.changeset/bump-patch-1771997173145.md
new file mode 100644
index 0000000000000..e1eaa7980afb1
--- /dev/null
+++ b/.changeset/bump-patch-1771997173145.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/meteor': patch
+---
+
+Bump @rocket.chat/meteor version.
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..eb43b15d654b1
--- /dev/null
+++ b/.changeset/pre.json
@@ -0,0 +1,118 @@
+{
+ "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": [
+ "bump-patch-1771886743378",
+ "bump-patch-1771997173145",
+ "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..b0bf46e03108d 100644
--- a/apps/meteor/CHANGELOG.md
+++ b/apps/meteor/CHANGELOG.md
@@ -1,5 +1,194 @@
# @rocket.chat/meteor
+## 8.2.0-rc.2
+
+### Patch Changes
+
+- Bump @rocket.chat/meteor version.
+
+- Updated dependencies []:
+
+ - @rocket.chat/core-typings@8.2.0-rc.2
+ - @rocket.chat/rest-typings@8.2.0-rc.2
+ - @rocket.chat/abac@0.1.4-rc.2
+ - @rocket.chat/federation-matrix@0.0.13-rc.2
+ - @rocket.chat/license@1.1.11-rc.2
+ - @rocket.chat/media-calls@0.2.4-rc.2
+ - @rocket.chat/omnichannel-services@0.3.48-rc.2
+ - @rocket.chat/pdf-worker@0.3.30-rc.2
+ - @rocket.chat/presence@0.2.51-rc.2
+ - @rocket.chat/api-client@0.2.51-rc.2
+ - @rocket.chat/apps@0.6.4-rc.2
+ - @rocket.chat/core-services@0.13.0-rc.2
+ - @rocket.chat/cron@0.1.51-rc.2
+ - @rocket.chat/fuselage-ui-kit@28.0.0-rc.2
+ - @rocket.chat/gazzodown@28.0.0-rc.2
+ - @rocket.chat/http-router@7.9.18-rc.2
+ - @rocket.chat/message-types@0.1.0
+ - @rocket.chat/model-typings@2.1.0-rc.2
+ - @rocket.chat/ui-avatar@24.0.0-rc.2
+ - @rocket.chat/ui-client@28.0.0-rc.2
+ - @rocket.chat/ui-contexts@28.0.0-rc.2
+ - @rocket.chat/ui-voip@18.0.0-rc.2
+ - @rocket.chat/web-ui-registration@28.0.0-rc.2
+ - @rocket.chat/models@2.1.0-rc.2
+ - @rocket.chat/server-cloud-communication@0.0.2
+ - @rocket.chat/network-broker@0.2.30-rc.2
+ - @rocket.chat/omni-core-ee@0.0.16-rc.2
+ - @rocket.chat/ui-theming@0.4.4
+ - @rocket.chat/ui-video-conf@28.0.0-rc.2
+ - @rocket.chat/instance-status@0.1.51-rc.2
+ - @rocket.chat/omni-core@0.0.16-rc.2
+ - @rocket.chat/server-fetch@0.1.0-rc.2
+
+
+## 8.2.0-rc.1
+
+### Patch Changes
+
+- Bump @rocket.chat/meteor version.
+
+- Updated dependencies []:
+
+ - @rocket.chat/core-typings@8.2.0-rc.1
+ - @rocket.chat/rest-typings@8.2.0-rc.1
+ - @rocket.chat/abac@0.1.4-rc.1
+ - @rocket.chat/federation-matrix@0.0.13-rc.1
+ - @rocket.chat/license@1.1.11-rc.1
+ - @rocket.chat/media-calls@0.2.4-rc.1
+ - @rocket.chat/omnichannel-services@0.3.48-rc.1
+ - @rocket.chat/pdf-worker@0.3.30-rc.1
+ - @rocket.chat/presence@0.2.51-rc.1
+ - @rocket.chat/api-client@0.2.51-rc.1
+ - @rocket.chat/apps@0.6.4-rc.1
+ - @rocket.chat/core-services@0.13.0-rc.1
+ - @rocket.chat/cron@0.1.51-rc.1
+ - @rocket.chat/fuselage-ui-kit@28.0.0-rc.1
+ - @rocket.chat/gazzodown@28.0.0-rc.1
+ - @rocket.chat/http-router@7.9.18-rc.1
+ - @rocket.chat/message-types@0.1.0
+ - @rocket.chat/model-typings@2.1.0-rc.1
+ - @rocket.chat/ui-avatar@24.0.0-rc.1
+ - @rocket.chat/ui-client@28.0.0-rc.1
+ - @rocket.chat/ui-contexts@28.0.0-rc.1
+ - @rocket.chat/ui-voip@18.0.0-rc.1
+ - @rocket.chat/web-ui-registration@28.0.0-rc.1
+ - @rocket.chat/models@2.1.0-rc.1
+ - @rocket.chat/server-cloud-communication@0.0.2
+ - @rocket.chat/network-broker@0.2.30-rc.1
+ - @rocket.chat/omni-core-ee@0.0.16-rc.1
+ - @rocket.chat/ui-theming@0.4.4
+ - @rocket.chat/ui-video-conf@28.0.0-rc.1
+ - @rocket.chat/instance-status@0.1.51-rc.1
+ - @rocket.chat/omni-core@0.0.16-rc.1
+ - @rocket.chat/server-fetch@0.1.0-rc.1
+
+
+## 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/commands.ts b/apps/meteor/app/api/server/v1/commands.ts
index 59b90baafa8a1..fea1d978cbafe 100644
--- a/apps/meteor/app/api/server/v1/commands.ts
+++ b/apps/meteor/app/api/server/v1/commands.ts
@@ -277,6 +277,7 @@ API.v1.addRoute(
cmd,
params,
msg: { rid: query.roomId },
+ userId: this.userId,
});
return API.v1.success({ preview });
@@ -344,6 +345,7 @@ API.v1.addRoute(
triggerId: body.triggerId,
},
body.previewItem,
+ this.userId,
);
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/commands.ts b/apps/meteor/app/apps/server/bridges/commands.ts
index 5ffef64730519..0f378a4a7abc4 100644
--- a/apps/meteor/app/apps/server/bridges/commands.ts
+++ b/apps/meteor/app/apps/server/bridges/commands.ts
@@ -3,7 +3,6 @@ import type { ISlashCommand, ISlashCommandPreview, ISlashCommandPreviewItem } fr
import { SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands';
import { CommandBridge } from '@rocket.chat/apps-engine/server/bridges/CommandBridge';
import type { IMessage, RequiredField, SlashCommand, SlashCommandCallbackParams } from '@rocket.chat/core-typings';
-import { Meteor } from 'meteor/meteor';
import { Utilities } from '../../../../ee/lib/misc/Utilities';
import { parseParameters } from '../../../../lib/utils/parseParameters';
@@ -179,10 +178,10 @@ export class AppCommandsBridge extends CommandBridge {
command: string,
parameters: any,
message: RequiredField, 'rid'>,
+ userId: string,
): Promise {
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
- const uid = Meteor.userId() as string;
- const user: IAppsUser | undefined = await this.orch.getConverters()?.get('users').convertById(uid);
+ const user: IAppsUser | undefined = await this.orch.getConverters()?.get('users').convertById(userId);
const room: IAppsRoom | undefined = await this.orch.getConverters()?.get('rooms').convertById(message.rid);
const threadId = message.tmid;
const params = parseParameters(parameters);
@@ -201,11 +200,11 @@ export class AppCommandsBridge extends CommandBridge {
parameters: any,
message: IMessage,
preview: ISlashCommandPreviewItem,
+ userId: string,
triggerId: string,
): Promise {
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
- const uid = Meteor.userId() as string;
- const user: IAppsUser | undefined = await this.orch.getConverters()?.get('users').convertById(uid);
+ const user: IAppsUser | undefined = await this.orch.getConverters()?.get('users').convertById(userId);
const room: IAppsRoom | undefined = await this.orch.getConverters()?.get('rooms').convertById(message.rid);
const threadId = message.tmid;
const params = parseParameters(parameters);
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/executeSlashCommandPreview.ts b/apps/meteor/app/lib/server/methods/executeSlashCommandPreview.ts
index 7eabfdb04ac79..ad0f856e218a8 100644
--- a/apps/meteor/app/lib/server/methods/executeSlashCommandPreview.ts
+++ b/apps/meteor/app/lib/server/methods/executeSlashCommandPreview.ts
@@ -27,6 +27,7 @@ export const executeSlashCommandPreview = async (
triggerId?: string;
},
preview: SlashCommandPreviewItem,
+ userId: string,
): Promise => {
if (!command?.cmd || !slashCommands.commands[command.cmd]) {
throw new Meteor.Error('error-invalid-command', 'Invalid Command Provided', {
@@ -47,17 +48,18 @@ export const executeSlashCommandPreview = async (
});
}
- return slashCommands.executePreview(command.cmd, command.params, command.msg, preview, command.triggerId);
+ return slashCommands.executePreview(command.cmd, command.params, command.msg, preview, userId, command.triggerId);
};
Meteor.methods({
executeSlashCommandPreview(command, preview) {
- if (!Meteor.userId()) {
+ const userId = Meteor.userId();
+ if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'getSlashCommandPreview',
});
}
- return executeSlashCommandPreview(command, preview);
+ return executeSlashCommandPreview(command, preview, userId);
},
});
diff --git a/apps/meteor/app/lib/server/methods/getSlashCommandPreviews.ts b/apps/meteor/app/lib/server/methods/getSlashCommandPreviews.ts
index 772f58b0b2931..7bffc20292de7 100644
--- a/apps/meteor/app/lib/server/methods/getSlashCommandPreviews.ts
+++ b/apps/meteor/app/lib/server/methods/getSlashCommandPreviews.ts
@@ -19,6 +19,7 @@ export const getSlashCommandPreviews = async (command: {
cmd: string;
params: string;
msg: RequiredField, 'rid'>;
+ userId: string;
}): Promise => {
if (!command?.cmd || !slashCommands.commands[command.cmd]) {
throw new Meteor.Error('error-invalid-command', 'Invalid Command Provided', {
@@ -33,17 +34,18 @@ export const getSlashCommandPreviews = async (command: {
});
}
- return slashCommands.getPreviews(command.cmd, command.params, command.msg);
+ return slashCommands.getPreviews(command.cmd, command.params, command.msg, command.userId);
};
Meteor.methods({
async getSlashCommandPreviews(command) {
- if (!Meteor.userId()) {
+ const userId = Meteor.userId();
+ if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'getSlashCommandPreview',
});
}
- return getSlashCommandPreviews(command);
+ return getSlashCommandPreviews({ ...command, userId });
},
});
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/client/slashCommand.ts b/apps/meteor/app/utils/client/slashCommand.ts
index 66e793012facd..3c8d57ce26974 100644
--- a/apps/meteor/app/utils/client/slashCommand.ts
+++ b/apps/meteor/app/utils/client/slashCommand.ts
@@ -79,6 +79,7 @@ export const slashCommands = {
command: string,
params: string,
message: RequiredField, 'rid'>,
+ userId: string,
): Promise {
const cmd = this.commands[command];
if (typeof cmd?.previewer !== 'function') {
@@ -89,7 +90,7 @@ export const slashCommands = {
throw new InvalidCommandUsage();
}
- const previewInfo = await cmd.previewer(command, params, message);
+ const previewInfo = await cmd.previewer(command, params, message, userId);
if (!previewInfo?.items?.length) {
return;
@@ -107,6 +108,7 @@ export const slashCommands = {
params: string,
message: Pick & Partial>,
preview: SlashCommandPreviewItem,
+ userId: string,
triggerId?: string,
) {
const cmd = this.commands[command];
@@ -123,7 +125,7 @@ export const slashCommands = {
throw new InvalidPreview();
}
- return cmd.previewCallback(command, params, message, preview, triggerId);
+ return cmd.previewCallback(command, params, message, preview, userId, triggerId);
},
};
diff --git a/apps/meteor/app/utils/rocketchat.info b/apps/meteor/app/utils/rocketchat.info
index b64ed0c59605b..7a43c14204ceb 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.2"
}
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/utils/server/slashCommand.ts b/apps/meteor/app/utils/server/slashCommand.ts
index 27b3c81735f9e..42eb86f5f9b19 100644
--- a/apps/meteor/app/utils/server/slashCommand.ts
+++ b/apps/meteor/app/utils/server/slashCommand.ts
@@ -80,6 +80,7 @@ export const slashCommands = {
command: string,
params: string,
message: RequiredField, 'rid'>,
+ userId: string,
): Promise {
const cmd = this.commands[command];
if (typeof cmd?.previewer !== 'function') {
@@ -90,7 +91,7 @@ export const slashCommands = {
throw new MeteorError('invalid-command-usage', 'Executing a command requires at least a message with a room id.');
}
- const previewInfo = await cmd.previewer(command, params, message);
+ const previewInfo = await cmd.previewer(command, params, message, userId);
if (!previewInfo?.items?.length) {
return;
@@ -108,6 +109,7 @@ export const slashCommands = {
params: string,
message: Pick & Partial>,
preview: SlashCommandPreviewItem,
+ userId: string,
triggerId?: string,
) {
const cmd = this.commands[command];
@@ -124,7 +126,7 @@ export const slashCommands = {
throw new MeteorError('error-invalid-preview', 'Preview Item must have an id, type, and value.');
}
- return cmd.previewCallback(command, params, message, preview, triggerId);
+ return cmd.previewCallback(command, params, message, preview, userId, triggerId);
},
};
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`] = `