diff --git a/.changeset/angry-baboons-trade.md b/.changeset/angry-baboons-trade.md new file mode 100644 index 0000000000000..6b78acc776e24 --- /dev/null +++ b/.changeset/angry-baboons-trade.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/ui-kit': patch +'@rocket.chat/meteor': patch +--- + +Fixes an issue that prevented BlockAction interactions from having room information when triggered in a ContextualBar surface diff --git a/.changeset/beige-parrots-lead.md b/.changeset/beige-parrots-lead.md new file mode 100644 index 0000000000000..fdb02b3ec9647 --- /dev/null +++ b/.changeset/beige-parrots-lead.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Swap usage of internal @rocket.chat/apps-engine internal APIs to @rocket.chat/apps package diff --git a/.changeset/big-corners-tie.md b/.changeset/big-corners-tie.md new file mode 100644 index 0000000000000..d94dbbfb5daca --- /dev/null +++ b/.changeset/big-corners-tie.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/model-typings': patch +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Ensures OAuth tokens are cleaned up after user deactivation diff --git a/.changeset/brave-fans-tie.md b/.changeset/brave-fans-tie.md new file mode 100644 index 0000000000000..3d2fa6b1b02af --- /dev/null +++ b/.changeset/brave-fans-tie.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/i18n": minor +--- + +Adds 4 new permissions (assigned to admins by default) to control the visibility of each tab inside the ABAC Administration panel diff --git a/.changeset/brown-files-work.md b/.changeset/brown-files-work.md new file mode 100644 index 0000000000000..9459cee560b56 --- /dev/null +++ b/.changeset/brown-files-work.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/apps-engine': minor +--- + +Remove files that weren't accessible to apps at development time diff --git a/.changeset/bump-patch-1779457640608.md b/.changeset/bump-patch-1779457640608.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1779457640608.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1780029980454.md b/.changeset/bump-patch-1780029980454.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1780029980454.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1780545712119.md b/.changeset/bump-patch-1780545712119.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1780545712119.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1780685193955.md b/.changeset/bump-patch-1780685193955.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1780685193955.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1781098424278.md b/.changeset/bump-patch-1781098424278.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1781098424278.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1781123233262.md b/.changeset/bump-patch-1781123233262.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1781123233262.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/calm-seas-run.md b/.changeset/calm-seas-run.md new file mode 100644 index 0000000000000..fe74a2e4fc672 --- /dev/null +++ b/.changeset/calm-seas-run.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes an issue that allowed a room converted from private to public (while abac is disabled) to retain its abac attributes (if any) diff --git a/.changeset/clever-zebras-melt.md b/.changeset/clever-zebras-melt.md new file mode 100644 index 0000000000000..8d02ede87a233 --- /dev/null +++ b/.changeset/clever-zebras-melt.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue where thread content would disappear after clicking "Jump to recent messages". diff --git a/.changeset/ddp-client-idempotent-reconnect.md b/.changeset/ddp-client-idempotent-reconnect.md new file mode 100644 index 0000000000000..207644c5211ad --- /dev/null +++ b/.changeset/ddp-client-idempotent-reconnect.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/ddp-client": patch +--- + +Make `Connection.connect()` and `Connection.reconnect()` idempotent. Previously they rejected with `Error('Connection in progress')` when called while a connection was already in flight or established. Because the internal retry timer (`ws.onclose` → `setTimeout(() => void this.reconnect(), …)`) fires with no `.catch`, that rejection surfaced as an unhandled rejection at the page level whenever an external caller (e.g. an SDK consumer's bootstrap path) won the race against the timer. While `status === 'connecting'`, both methods now return the in-flight handshake promise so a later `failed` payload still propagates to every caller instead of being masked by a synthesized success; while `status === 'connected'` they resolve with `true`. The timer also no-ops when the connection has already been re-established, and a stale `ws.onclose` from a replaced socket no longer clobbers the new socket's status or schedules a redundant retry. diff --git a/.changeset/ddp-client-reset-retry-count.md b/.changeset/ddp-client-reset-retry-count.md new file mode 100644 index 0000000000000..90730285d3967 --- /dev/null +++ b/.changeset/ddp-client-reset-retry-count.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/ddp-client": patch +--- + +Reset `Connection.retryCount` to zero on a successful (re)connection. The counter was only ever incremented (in `ws.onclose`), never zeroed, so the retry budget was monotonically consumed across the connection's lifetime. With the default budget of `1`, any chain of two close events — for example the SDK reconnecting after a server force-logout, then the client running a follow-up `Meteor.logout()` whose server handler closes the WS again — drained the budget; the second close handler bailed at `retryCount >= retryOptions.retryCount` and the SDK stayed permanently disconnected. Method frames already in the dispatcher queue (e.g. a fresh `login` retry from the consumer) stayed queued forever. diff --git a/.changeset/ddp-dispatcher-non-method-frames.md b/.changeset/ddp-dispatcher-non-method-frames.md new file mode 100644 index 0000000000000..c54d34e3b5164 --- /dev/null +++ b/.changeset/ddp-dispatcher-non-method-frames.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/ddp-client": patch +--- + +Fix `DDPDispatcher` dropping non-method frames (connect, sub, unsub, ping, pong) when a `wait` block is at the head of the queue. Previously every payload flowed through the same wait-serialization path: a `connect` frame dispatched after a `wait: true` method (e.g. `login`) would be queued in a new non-wait block but never actually sent, wedging the DDP handshake — the socket stayed open, the server never replied `connected`, and any caller awaiting the connection hung. Non-method payloads now bypass the queue and emit immediately; wait-method serialization between methods is unchanged. diff --git a/.changeset/dry-squids-wonder.md b/.changeset/dry-squids-wonder.md new file mode 100644 index 0000000000000..7fa1417da93cc --- /dev/null +++ b/.changeset/dry-squids-wonder.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes the missing edited indicator for the main parent message in the thread panel to ensure visual consistency with the main channel view. diff --git a/.changeset/edit-room-info-multiselect-aria-label.md b/.changeset/edit-room-info-multiselect-aria-label.md new file mode 100644 index 0000000000000..0c5b39192087d --- /dev/null +++ b/.changeset/edit-room-info-multiselect-aria-label.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Adds an accessible label to the system-messages multi-select in the channel edit panel so screen readers announce its purpose. diff --git a/.changeset/eight-clouds-count.md b/.changeset/eight-clouds-count.md new file mode 100644 index 0000000000000..25ecce7dc886a --- /dev/null +++ b/.changeset/eight-clouds-count.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes intermittent "Channel Not Joined" screen when opening rooms in embedded mode. diff --git a/.changeset/fix-presence-comma-ids.md b/.changeset/fix-presence-comma-ids.md new file mode 100644 index 0000000000000..c85d20785e3a6 --- /dev/null +++ b/.changeset/fix-presence-comma-ids.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/meteor': patch +'@rocket.chat/rest-typings': patch +--- + +Fixes the `users.presence` endpoint returning an empty array when called with multiple comma-separated IDs, caused by `ajvQuery` coercing the string into a single-element array after the OpenAPI migration \ No newline at end of file diff --git a/.changeset/flat-islands-nail.md b/.changeset/flat-islands-nail.md new file mode 100644 index 0000000000000..10e5f4be74d88 --- /dev/null +++ b/.changeset/flat-islands-nail.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/ui-voip": patch +--- + +Fixes an issue where intentionally cancelling a screen share request would show an error toast by identifying error types and counting consecutive failures. diff --git a/.changeset/good-rules-lie.md b/.changeset/good-rules-lie.md new file mode 100644 index 0000000000000..75542e75aa078 --- /dev/null +++ b/.changeset/good-rules-lie.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/model-typings': patch +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Ensures that deactivated users have their login tokens cleaned up in users.deactivateidle diff --git a/.changeset/hot-impalas-vanish.md b/.changeset/hot-impalas-vanish.md new file mode 100644 index 0000000000000..30c32de851fd5 --- /dev/null +++ b/.changeset/hot-impalas-vanish.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": minor +--- + +Hides the room announcement, topic and description from the Administration > Rooms panel for ABAC managed rooms. In the channel sidebar Edit Channel form those fields stay visible to room members but are disabled, and the API rejects edits to them. diff --git a/.changeset/hot-spoons-heal.md b/.changeset/hot-spoons-heal.md new file mode 100644 index 0000000000000..6bcdabec83e39 --- /dev/null +++ b/.changeset/hot-spoons-heal.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Disables SAML login when it is set to validate signatures without the proper configuration for it diff --git a/.changeset/little-cars-love.md b/.changeset/little-cars-love.md new file mode 100644 index 0000000000000..7b67fa9c679be --- /dev/null +++ b/.changeset/little-cars-love.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/i18n": minor +"@rocket.chat/ui-voip": minor +--- + +Adds "Open in room" button to shared screen card on voice call widget diff --git a/.changeset/lovely-laws-wait.md b/.changeset/lovely-laws-wait.md new file mode 100644 index 0000000000000..306cfc7793926 --- /dev/null +++ b/.changeset/lovely-laws-wait.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Allows users to search for attribute values when assigning them to rooms diff --git a/.changeset/major-coats-smash.md b/.changeset/major-coats-smash.md new file mode 100644 index 0000000000000..387bc777e041f --- /dev/null +++ b/.changeset/major-coats-smash.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes test button not playing default sound in Notifications Preferences diff --git a/.changeset/mighty-icons-kiss.md b/.changeset/mighty-icons-kiss.md new file mode 100644 index 0000000000000..5d62298c8e0c7 --- /dev/null +++ b/.changeset/mighty-icons-kiss.md @@ -0,0 +1,9 @@ +--- +'@rocket.chat/model-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/models': minor +'@rocket.chat/meteor': minor +'@rocket.chat/i18n': minor +--- + +Adds new API endpoints `custom-sounds.create` and `custom-sounds.update` to manage custom sounds with strict file validation for size and specific MIME types to ensure system compatibility. diff --git a/.changeset/neat-trams-juggle.md b/.changeset/neat-trams-juggle.md new file mode 100644 index 0000000000000..56f327f68cc0c --- /dev/null +++ b/.changeset/neat-trams-juggle.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Ensures the Meteor method for translateMessage validates access and types diff --git a/.changeset/ninety-lilies-kick.md b/.changeset/ninety-lilies-kick.md new file mode 100644 index 0000000000000..f507a7fbfe4e0 --- /dev/null +++ b/.changeset/ninety-lilies-kick.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/gazzodown': patch +'@rocket.chat/i18n': patch +'@rocket.chat/meteor': patch +--- + +Fixes Insert Timestamp relative time preview not updating on input changes and losing the user's locale after the first refresh tick. diff --git a/.changeset/orange-bottles-glow.md b/.changeset/orange-bottles-glow.md new file mode 100644 index 0000000000000..ffc78906db762 --- /dev/null +++ b/.changeset/orange-bottles-glow.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/i18n': patch +'@rocket.chat/meteor': patch +--- + +Fixes signed URL generation for S3 and Google Cloud Storage when the expiry setting is below 5 seconds, which previously caused expired or invalid preview URLs. Adds a dedicated URL expiry setting for Google Cloud Storage since it was incorrectly reusing the AWS S3 setting. diff --git a/.changeset/perky-tires-invite.md b/.changeset/perky-tires-invite.md new file mode 100644 index 0000000000000..d3824c95cfca4 --- /dev/null +++ b/.changeset/perky-tires-invite.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Ensures the visitor token is not present in the visitors.info response diff --git a/.changeset/pink-moons-cheer.md b/.changeset/pink-moons-cheer.md new file mode 100644 index 0000000000000..e753031cdb868 --- /dev/null +++ b/.changeset/pink-moons-cheer.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/apps-engine": minor +"@rocket.chat/apps": minor +--- + +Allows apps with the right permission to read room's ABAC attributes. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000000000..43339a932fd18 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,131 @@ +{ + "mode": "pre", + "tag": "rc", + "initialVersions": { + "@rocket.chat/meteor": "8.5.0-develop", + "rocketchat-services": "2.0.47", + "@rocket.chat/uikit-playground": "0.7.12", + "@rocket.chat/account-service": "0.4.56", + "@rocket.chat/authorization-service": "0.6.0", + "@rocket.chat/ddp-streamer": "0.3.56", + "@rocket.chat/omnichannel-transcript": "0.4.56", + "@rocket.chat/presence-service": "0.4.56", + "@rocket.chat/queue-worker": "0.4.56", + "@rocket.chat/abac": "0.2.0", + "@rocket.chat/federation-matrix": "0.1.3", + "@rocket.chat/license": "1.1.16", + "@rocket.chat/media-calls": "0.4.0", + "@rocket.chat/network-broker": "0.2.35", + "@rocket.chat/omni-core-ee": "0.0.21", + "@rocket.chat/omnichannel-services": "0.3.53", + "@rocket.chat/pdf-worker": "0.3.35", + "@rocket.chat/presence": "0.2.56", + "@rocket.chat/account-utils": "0.0.3", + "@rocket.chat/agenda": "0.1.1", + "@rocket.chat/api-client": "0.2.56", + "@rocket.chat/apps": "0.6.9", + "@rocket.chat/apps-engine": "1.62.0", + "@rocket.chat/base64": "1.0.14", + "@rocket.chat/cas-validate": "0.0.4", + "@rocket.chat/core-services": "0.14.0", + "@rocket.chat/core-typings": "8.5.0-develop", + "@rocket.chat/cron": "0.1.56", + "@rocket.chat/ddp-client": "1.0.9", + "@rocket.chat/desktop-api": "1.1.1", + "@rocket.chat/eslint-config": "0.8.0", + "@rocket.chat/favicon": "0.0.5", + "@rocket.chat/fuselage-ui-kit": "30.0.0", + "@rocket.chat/gazzodown": "30.0.0", + "@rocket.chat/http-router": "7.9.23", + "@rocket.chat/i18n": "3.0.0", + "@rocket.chat/instance-status": "0.1.56", + "@rocket.chat/jest-presets": "0.0.2", + "@rocket.chat/jwt": "0.2.1", + "@rocket.chat/livechat": "2.1.3", + "@rocket.chat/log-format": "0.0.3", + "@rocket.chat/logger": "1.0.1", + "@rocket.chat/media-signaling": "1.0.0", + "@rocket.chat/message-parser": "0.31.36", + "@rocket.chat/message-types": "0.1.1", + "@rocket.chat/mock-providers": "0.4.16", + "@rocket.chat/model-typings": "2.2.0", + "@rocket.chat/models": "2.2.0", + "@rocket.chat/mongo-adapter": "0.0.3", + "@rocket.chat/poplib": "0.0.3", + "@rocket.chat/omni-core": "0.1.0", + "@rocket.chat/password-policies": "0.1.1", + "@rocket.chat/patch-injection": "0.0.2", + "@rocket.chat/peggy-loader": "0.31.28", + "@rocket.chat/random": "1.2.3", + "@rocket.chat/release-action": "2.2.4", + "@rocket.chat/release-changelog": "0.1.1", + "@rocket.chat/rest-typings": "8.5.0-develop", + "@rocket.chat/server-cloud-communication": "0.0.3", + "@rocket.chat/server-fetch": "0.2.0", + "@rocket.chat/sha256": "1.0.13", + "@rocket.chat/storybook-config": "0.0.3", + "@rocket.chat/tools": "0.3.0", + "@rocket.chat/tracing": "0.0.2", + "@rocket.chat/tsconfig": "0.0.0", + "@rocket.chat/ui-avatar": "26.0.0", + "@rocket.chat/ui-client": "30.0.0", + "@rocket.chat/ui-composer": "1.0.0", + "@rocket.chat/ui-contexts": "30.0.0", + "@rocket.chat/ui-kit": "1.0.0", + "@rocket.chat/ui-video-conf": "30.0.0", + "@rocket.chat/ui-voip": "20.0.0", + "@rocket.chat/web-ui-registration": "30.0.0" + }, + "changesets": [ + "angry-baboons-trade", + "beige-parrots-lead", + "big-corners-tie", + "brave-fans-tie", + "brown-files-work", + "bump-patch-1779457640608", + "bump-patch-1780029980454", + "bump-patch-1780545712119", + "bump-patch-1780685193955", + "bump-patch-1781098424278", + "bump-patch-1781123233262", + "calm-seas-run", + "clever-zebras-melt", + "ddp-client-idempotent-reconnect", + "ddp-client-reset-retry-count", + "ddp-dispatcher-non-method-frames", + "dry-squids-wonder", + "edit-room-info-multiselect-aria-label", + "eight-clouds-count", + "fix-presence-comma-ids", + "flat-islands-nail", + "good-rules-lie", + "hot-impalas-vanish", + "hot-spoons-heal", + "little-cars-love", + "lovely-laws-wait", + "major-coats-smash", + "mighty-icons-kiss", + "neat-trams-juggle", + "ninety-lilies-kick", + "orange-bottles-glow", + "perky-tires-invite", + "pink-moons-cheer", + "public-ravens-like", + "red-maps-wink", + "rich-doodles-grow", + "room-search-index", + "sanitize-image-element-url", + "sdk-account-event-handlers", + "shaggy-kiwis-burn", + "sharp-planets-throw", + "silent-coats-open", + "spicy-phones-breathe", + "stale-needles-care", + "ten-lizards-divide", + "tidy-donuts-check", + "tricky-kiwis-sit", + "use-format-date-fallback", + "use-rc-sdk-transport-setting", + "witty-ravens-end" + ] +} diff --git a/.changeset/public-ravens-like.md b/.changeset/public-ravens-like.md new file mode 100644 index 0000000000000..95604fc9c8fcd --- /dev/null +++ b/.changeset/public-ravens-like.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/apps-engine': minor +'@rocket.chat/meteor': minor +--- + +Adds the capability for fetching a user by their sip extension to the apps diff --git a/.changeset/red-maps-wink.md b/.changeset/red-maps-wink.md new file mode 100644 index 0000000000000..c6f130650b185 --- /dev/null +++ b/.changeset/red-maps-wink.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/ui-client': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Adds a new "Drafts" group to the sidebar, providing quick access to all rooms with unfinished messages. +> This feature is available under the `Drafts in sidebar` feature preview and needs to be enabled in settings to be tested. \ No newline at end of file diff --git a/.changeset/rich-doodles-grow.md b/.changeset/rich-doodles-grow.md new file mode 100644 index 0000000000000..3699a28550a66 --- /dev/null +++ b/.changeset/rich-doodles-grow.md @@ -0,0 +1,9 @@ +--- +'@rocket.chat/model-typings': patch +'@rocket.chat/core-typings': patch +'@rocket.chat/models': patch +'@rocket.chat/i18n': patch +'@rocket.chat/meteor': patch +--- + +Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates) diff --git a/.changeset/room-search-index.md b/.changeset/room-search-index.md new file mode 100644 index 0000000000000..b7832034ba5ba --- /dev/null +++ b/.changeset/room-search-index.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/meteor': minor +'@rocket.chat/models': minor +--- + +Adds the `USE_ROOM_SEARCH_INDEX` environment variable. When set to `true`, the messages collection's text index is created as `{ rid: 1, msg: 'text' }` instead of the default `{ msg: 'text' }`. The compound shape lets per-room `$text` searches use `rid` as a prefix, dramatically reducing the portion of the index scanned on workspaces where global search is disabled. + +The index is reconciled on every startup: if the existing text index already matches the desired shape, nothing happens; otherwise the stale text index is dropped and the desired one is recreated. Unsetting the variable on a later boot reverts to the default shape. diff --git a/.changeset/sanitize-image-element-url.md b/.changeset/sanitize-image-element-url.md new file mode 100644 index 0000000000000..c2a3a18af8027 --- /dev/null +++ b/.changeset/sanitize-image-element-url.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/gazzodown': patch +'@rocket.chat/meteor': patch +--- + +Sanitizes image URLs in rendered messages to block `javascript:`, `data:`, and `vbscript:` schemes — matching the protection already applied to markdown links. Defense-in-depth against XSS via crafted markdown like ``. diff --git a/.changeset/sdk-account-event-handlers.md b/.changeset/sdk-account-event-handlers.md new file mode 100644 index 0000000000000..ed7bb38fa2d46 --- /dev/null +++ b/.changeset/sdk-account-event-handlers.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/ddp-client': minor +--- + +Add lifecycle event handlers to `Account`: `onLogin`, `onLogout`, `onEmailVerificationLink`, and `onPageLoadLogin`. `onLogin` / `onLogout` fire on `uid` transitions (the setter now emits only when the value changes, so a single login or logout produces exactly one callback). `onEmailVerificationLink` and `onPageLoadLogin` are convenience wrappers around new `Emitter` events of the same names — fire them externally with `account.emit('emailVerificationLink', token)` / `account.emit('pageLoadLogin', loginAttempt)`. The bridge from Meteor's accounts-base lives in the consumer (`apps/meteor/client`) so the package stays Meteor-independent. All four handlers return an unsubscribe function. diff --git a/.changeset/shaggy-kiwis-burn.md b/.changeset/shaggy-kiwis-burn.md new file mode 100644 index 0000000000000..6a5834f65d4cf --- /dev/null +++ b/.changeset/shaggy-kiwis-burn.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Ensures the autotranslate.translateMessage endpoint checks for room access diff --git a/.changeset/sharp-planets-throw.md b/.changeset/sharp-planets-throw.md new file mode 100644 index 0000000000000..19e01af823bcd --- /dev/null +++ b/.changeset/sharp-planets-throw.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes action buttons added by apps being rendered in the Marketplace Menu rather than the User Menu diff --git a/.changeset/silent-coats-open.md b/.changeset/silent-coats-open.md new file mode 100644 index 0000000000000..e8e88a7966104 --- /dev/null +++ b/.changeset/silent-coats-open.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes the Chat Limits locking mechanism to allow bot agents to skip the lock as they aren't limited diff --git a/.changeset/spicy-phones-breathe.md b/.changeset/spicy-phones-breathe.md new file mode 100644 index 0000000000000..71bb6affc744b --- /dev/null +++ b/.changeset/spicy-phones-breathe.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/abac": patch +--- + +Fixes an issue where some actions made by the abac service were not broadcasting to clients, which affected reactivity diff --git a/.changeset/stale-needles-care.md b/.changeset/stale-needles-care.md new file mode 100644 index 0000000000000..5f9e5b4c893bd --- /dev/null +++ b/.changeset/stale-needles-care.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/apps': minor +--- + +Receives the migration of internal files from @rocket.chat/apps-engine diff --git a/.changeset/ten-lizards-divide.md b/.changeset/ten-lizards-divide.md new file mode 100644 index 0000000000000..e272a03e1fe04 --- /dev/null +++ b/.changeset/ten-lizards-divide.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes issue that displayed the 'Delete all closed chats' button when user lacks `remove-closed-livechat-rooms` permission diff --git a/.changeset/tidy-donuts-check.md b/.changeset/tidy-donuts-check.md new file mode 100644 index 0000000000000..5aeb2717a85bc --- /dev/null +++ b/.changeset/tidy-donuts-check.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Adds `freeSwitchExtension` as a query parameter for `api/v1/users.info` diff --git a/.changeset/tricky-kiwis-sit.md b/.changeset/tricky-kiwis-sit.md new file mode 100644 index 0000000000000..eac1db7222c36 --- /dev/null +++ b/.changeset/tricky-kiwis-sit.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/ui-kit': minor +'@rocket.chat/meteor': minor +--- + +Adds support to room information on ViewSubmit and ViewClose events for ContextualBar surface diff --git a/.changeset/use-format-date-fallback.md b/.changeset/use-format-date-fallback.md new file mode 100644 index 0000000000000..6cd6197f2e739 --- /dev/null +++ b/.changeset/use-format-date-fallback.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes a `date-fns` crash on routes that mount before the public settings stream finishes loading. `useFormatDate` was passing `String(undefined)` (the literal `"undefined"`) to `formatDate` while `Message_DateFormat` was momentarily unloaded — `date-fns` rejects that token because it contains an unescaped `n`. The hook now uses `'LL'` as the default token via `useSetting`'s second argument, so the formatter always receives a valid format string. diff --git a/.changeset/use-rc-sdk-transport-setting.md b/.changeset/use-rc-sdk-transport-setting.md new file mode 100644 index 0000000000000..7e43aa7437fda --- /dev/null +++ b/.changeset/use-rc-sdk-transport-setting.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Adds a new admin setting `Use_RC_SDK` (General → Use Rocket.Chat SDK) that opts the workspace into the experimental SDK-over-DDP transport. When enabled, the client routes Meteor DDP traffic through `@rocket.chat/ddp-client` over a single WebSocket instead of the legacy Meteor stream. The flag is dormant by default; the server surfaces the value via a `` tag, and the client also honors a per-tab `?sdk_transport=on|off` URL parameter and a `rc-config-sdk_transport` localStorage key (URL > localStorage > meta tag). diff --git a/.changeset/witty-ravens-end.md b/.changeset/witty-ravens-end.md new file mode 100644 index 0000000000000..975623f6a1d8e --- /dev/null +++ b/.changeset/witty-ravens-end.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/media-calls': minor +--- + +Fixes a server error when Drachtio connection is lost while a SIP call is active diff --git a/.github/actions/build-docker/action.yml b/.github/actions/build-docker/action.yml index 77ebf4aa3ad03..2d7b1fa736a18 100644 --- a/.github/actions/build-docker/action.yml +++ b/.github/actions/build-docker/action.yml @@ -99,7 +99,7 @@ runs: find /tmp/build/bundle/programs/server/npm/node_modules/@esbuild -type d -name 'linux-*' -not -name "*-${swc_arch}" -exec rm -rf {} + - find /tmp/build/bundle/programs/server/npm/node_modules/@rocket.chat/apps-engine/node_modules/@esbuild -type d -name 'linux-*' -not -name "*-${swc_arch}" -exec rm -rf {} + + find /tmp/build/bundle/programs/server/npm/node_modules/@rocket.chat/apps/node_modules/@esbuild -type d -name 'linux-*' -not -name "*-${swc_arch}" -exec rm -rf {} + fi if [[ "$INPUT_PUBLISH_IMAGE" == 'true' ]]; then diff --git a/.github/actions/update-version-durability/action.yml b/.github/actions/update-version-durability/action.yml index 07375ac383b87..85e4ec2f8fd64 100644 --- a/.github/actions/update-version-durability/action.yml +++ b/.github/actions/update-version-durability/action.yml @@ -14,6 +14,9 @@ inputs: D360_ARTICLE_ID: required: true description: Document360 Article ID + D360_USER_ID: + required: true + description: Document360 User ID used to fork and publish the article PUBLISH: required: true description: Publish Draft diff --git a/.github/actions/update-version-durability/index.js b/.github/actions/update-version-durability/index.js index d5fbeffb5de49..062f13e834d7d 100644 --- a/.github/actions/update-version-durability/index.js +++ b/.github/actions/update-version-durability/index.js @@ -11,6 +11,7 @@ import { Octokit } from '@octokit/rest'; const D360_TOKEN = core.getInput('D360_TOKEN'); const D360_ARTICLE_ID = core.getInput('D360_ARTICLE_ID'); +const D360_USER_ID = core.getInput('D360_USER_ID'); const PUBLISH = core.getInput('PUBLISH') === 'true'; const LTS_VERSIONS = (core.getInput('LTS_VERSIONS') || '7.10').split(',').map((v) => v.trim()); @@ -193,11 +194,15 @@ async function generateTable({ owner, repo } = {}) { const forkResponse = await requestDocument360('put', `Articles/${D360_ARTICLE_ID}/fork`, { lang_code: "en", - user_id: "2511fd00-9558-4826-8d8c-4cc0c110f89c", + user_id: D360_USER_ID, version_number: response.data.data.version_number, }); console.log(forkResponse.data); + + if (!forkResponse.data.success) { + throw new Error(`Failed to fork article: ${JSON.stringify(forkResponse.data.errors)}`); + } } console.log('Updating article'); @@ -211,7 +216,7 @@ async function generateTable({ owner, repo } = {}) { console.log('publishing article', updateResponse.data.data.version_number); const forkResponse = await requestDocument360('post', `Articles/${D360_ARTICLE_ID}/en/publish`, { - user_id: "2511fd00-9558-4826-8d8c-4cc0c110f89c", + user_id: D360_USER_ID, version_number: updateResponse.data.data.version_number, publish_message: 'Update support versions table via GitHub Action', }); diff --git a/.github/actions/update-version-durability/package-lock.json b/.github/actions/update-version-durability/package-lock.json index 5646f81db24e4..cd65c8d5e37b2 100644 --- a/.github/actions/update-version-durability/package-lock.json +++ b/.github/actions/update-version-durability/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@actions/core": "^1.10.1", "@octokit/rest": "^21.0.0", - "@xmldom/xmldom": "^0.8.10", + "@xmldom/xmldom": "^0.8.13", "axios": "^1.7.2", "beauty-html": "^1.3.1", "colors": "^1.4.0", @@ -194,9 +194,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.12", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", - "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -209,9 +209,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -272,9 +272,10 @@ } }, "node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml deleted file mode 100644 index 587d4c2b3c36f..0000000000000 --- a/.github/workflows/actionlint.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Lint GitHub Actions workflows -on: [push, pull_request] - -jobs: - actionlint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Check workflow files - run: | - echo "::add-matcher::.github/actionlint-matcher.json" - ACTIONLINT_VERSION=1.7.8 - curl -fsSLO "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" - curl -fsSLO "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_checksums.txt" - grep " actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz$" "actionlint_${ACTIONLINT_VERSION}_checksums.txt" | sha256sum -c - - tar -xzf "actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" actionlint - ./actionlint -color - shell: bash diff --git a/.github/workflows/ci-code-check.yml b/.github/workflows/ci-code-check.yml index 916c0ccb6954e..4416a9f8ada5a 100644 --- a/.github/workflows/ci-code-check.yml +++ b/.github/workflows/ci-code-check.yml @@ -41,7 +41,7 @@ jobs: install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13 + - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 - uses: ./.github/actions/restore-packages diff --git a/.github/workflows/ci-deploy-gh-pages.yml b/.github/workflows/ci-deploy-gh-pages.yml index cd777648842a4..dc6b044dbd19b 100644 --- a/.github/workflows/ci-deploy-gh-pages.yml +++ b/.github/workflows/ci-deploy-gh-pages.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13 + - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 - name: Setup NodeJS uses: ./.github/actions/setup-node @@ -30,7 +30,7 @@ jobs: mv ${{ github.ref_name }} .preview - name: Deploy - uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0 + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: .preview diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index 8917f2e90325c..f90122800df9c 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -125,7 +125,7 @@ jobs: install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13 + - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 - uses: ./.github/actions/restore-packages diff --git a/.github/workflows/ci-test-storybook.yml b/.github/workflows/ci-test-storybook.yml index 91a39aecc3c5b..144b5895ffc18 100644 --- a/.github/workflows/ci-test-storybook.yml +++ b/.github/workflows/ci-test-storybook.yml @@ -37,7 +37,7 @@ jobs: install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13 + - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 - uses: ./.github/actions/restore-packages @@ -48,7 +48,7 @@ jobs: env: STORYBOOK_DISABLE_TELEMETRY: 1 - - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: flags: unit verbose: true diff --git a/.github/workflows/ci-test-unit.yml b/.github/workflows/ci-test-unit.yml index f7c902bcb553d..13ea1f31d6490 100644 --- a/.github/workflows/ci-test-unit.yml +++ b/.github/workflows/ci-test-unit.yml @@ -41,14 +41,14 @@ jobs: install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13 + - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 - uses: ./.github/actions/restore-packages - name: Unit Test run: yarn testunit --concurrency=1 - - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: flags: unit verbose: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56a124952b9c8..cc8e1c7f9018c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,7 @@ jobs: source-hash: ${{ steps.source.outputs.hash }} packages-build-cache-key: ${{ steps.ci-cache-keys.outputs.packages-build-cache-key }} meteor-rc-cache-key: ${{ steps.ci-cache-keys.outputs.meteor-rc-cache-key }} + github-actions-changed: ${{ steps.diff.outputs.actions-changed }} # this is 100% intentional, secrets are not available for forks, so ee-tests will always fail # to avoid this, we are using a dummy license, expiring at 2026-07-01 enterprise-license: Uo7Jcr6WW0XYA8ydHd+Sk6pZ9/0V6dIASnyTwvUrNym/zJg2Ma3eYNKkC8osXLCc72y1ahohnWY7/+7IYkvono3GYXQR+IGvYbbrVgNR6OjMahd9P/odHZL1GFTm2qHrEL5Hh/XEOG+YluFeRdWPzCizQlp4zGGOi0+PkQo096TR9NVCLrsErVl2MW1WM6ZM1W5EUJG9pKly4BQnaOTUAlor1im6i8qPTDCKrISZfLiZEWuQKaPW/GE3mRKjQNjDh0CabX1N2S880pRRGoozBYAnp2NmFfrQW0+5ihKisBTIeMbMZ7K5NE5PkYU1nhQDcc+rpDHtwG9Ceg5X0J+oea3UfrPTmDON2aSI0iO22kvL6G7QI3fyrEIvJrMbxcNKxAFeQYgnjisw/b06+chWSG4jG686Fx58XrVS87dFhWL9WoGltsk1dJCntUQvI1sX6zOfpvyg1iWRnHfYDOrwoWlX57XMm29fWineEoqnOOTOVnA/uP+DKEhercQ9Xuo7Cr6zJxpQpwd03e7ODVjiEbTDqlkZE687rmxRCD4Wmu8L86WIl2xSEIajKLX301Ww5mz/FdLqk+Mg32lkW66W3azQKvJ1440NBrYxhpJ+dl9vSFMb3s1+xnz1cYUbjUcq9mARvORcgy5mLwKulmqT6Sq0Uvbv10YCO0TW0beXYW8= @@ -133,6 +134,16 @@ jobs: echo "DOCKER_TAG: ${DOCKER_TAG}" echo "gh-docker-tag=${DOCKER_TAG}" >> "$GITHUB_OUTPUT" + - id: diff + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_PR_NUM: ${{ github.event.number }} + run: | + if gh pr diff --name-only --repo "${GITHUB_REPOSITORY}" "${GH_PR_NUM}" | grep -q -e .github/workflows -e .github/actions; then + echo "Workflow changes detected" + echo "actions-changed=true" >> "$GITHUB_OUTPUT" + fi + notify-draft-services: name: 🚀 Notify external services - draft runs-on: ubuntu-24.04-arm @@ -235,7 +246,7 @@ jobs: restore-keys: | vite-local-cache-${{ runner.arch }}-${{ runner.os }}- - - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13 + - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 if: steps.packages-cache-build.outputs.cache-hit != 'true' - name: Build Rocket.Chat Packages @@ -479,6 +490,32 @@ jobs: baseline-tag: develop size-thresholds: '{"rocketchat":{"mb":11}}' + actionlint: + name: 🔎 Code Check - Action Lint + needs: [release-versions] + if: needs.release-versions.outputs.github-actions-changed + runs-on: ubuntu-24.04-arm + env: + ACTIONLINT_VERSION: 1.7.12 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Get actionlint + shell: bash + run: | + TAR_NAME="actionlint_${ACTIONLINT_VERSION}_$(echo "${RUNNER_OS}_${RUNNER_ARCH}" | tr '[:upper:]' '[:lower:]').tar.gz" + echo "Downloading ${TAR_NAME}..." + curl -fsSLO "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${TAR_NAME}" + curl -fsSLo checksums.txt "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_checksums.txt" + grep " ${TAR_NAME}$" "checksums.txt" | sha256sum -c - + tar -xzf "${TAR_NAME}" actionlint + + - name: Run actionlint + shell: bash + run: | + ./actionlint -color + + checks: needs: [release-versions, packages-build] @@ -658,7 +695,7 @@ jobs: cache-modules: true install: true - - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13 + - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 - name: Restore turbo build uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -772,7 +809,7 @@ jobs: include-hidden-files: true - name: Report API coverage - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: files: /tmp/coverage_report/api/lcov.info working-directory: . @@ -781,7 +818,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Report UI coverage - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: files: /tmp/coverage_report/ui/lcov.info working-directory: . @@ -1063,3 +1100,4 @@ jobs: secrets: CI_PAT: ${{ secrets.CI_PAT }} D360_TOKEN: ${{ secrets.D360_TOKEN }} + D360_USER_ID: ${{ secrets.D360_USER_ID }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 0671ab9084bd1..b6ccbed4870e1 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -28,7 +28,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 # Override language selection by uncommenting this and choosing your languages with: languages: javascript @@ -36,7 +36,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -50,4 +50,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 diff --git a/.github/workflows/new-release.yml b/.github/workflows/new-release.yml index 1c19f6bf6b018..577a1453eb1c3 100644 --- a/.github/workflows/new-release.yml +++ b/.github/workflows/new-release.yml @@ -38,7 +38,7 @@ jobs: install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13 + - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 - name: Build packages run: yarn build diff --git a/.github/workflows/pr-update-description.yml b/.github/workflows/pr-update-description.yml index af1c0902a99c1..89e29fc3505a5 100644 --- a/.github/workflows/pr-update-description.yml +++ b/.github/workflows/pr-update-description.yml @@ -25,7 +25,7 @@ jobs: install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13 + - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 - name: Build packages run: yarn build diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 407891b1897b2..699e234554101 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -28,7 +28,7 @@ jobs: install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13 + - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 - name: Build packages run: yarn build diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 8647d15591f52..376120c91d383 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -19,7 +19,7 @@ jobs: install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13 + - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 - name: Build packages run: yarn build diff --git a/.github/workflows/update-version-durability.yml b/.github/workflows/update-version-durability.yml index 5bd2b83c02079..11fa82711ae1e 100644 --- a/.github/workflows/update-version-durability.yml +++ b/.github/workflows/update-version-durability.yml @@ -12,6 +12,8 @@ on: required: true D360_TOKEN: required: true + D360_USER_ID: + required: true jobs: update-versions: @@ -23,7 +25,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: 22.22.2 + node-version: 22.22.3 - name: Install dependencies run: | @@ -37,4 +39,5 @@ jobs: GH_TOKEN: ${{ secrets.CI_PAT }} D360_TOKEN: ${{ secrets.D360_TOKEN }} D360_ARTICLE_ID: 800f8d52-409d-478d-b560-f82a2c0eb7fb + D360_USER_ID: ${{ secrets.D360_USER_ID }} PUBLISH: true diff --git a/.yarn/patches/drachtio-srf-npm-5.0.12-b0b1afaad6.patch b/.yarn/patches/drachtio-srf-npm-5.0.12-b0b1afaad6.patch index e8fdaef6e084e..833b21fc29fff 100644 --- a/.yarn/patches/drachtio-srf-npm-5.0.12-b0b1afaad6.patch +++ b/.yarn/patches/drachtio-srf-npm-5.0.12-b0b1afaad6.patch @@ -1,7 +1,16 @@ diff --git a/lib/@types/index.d.ts b/lib/@types/index.d.ts -index f71a82f458c1432202be8d4585fc70ba94bee4a4..b874ee9e1fa8a051f06d4824fc12161acfadb78a 100644 +index f71a82f458c1432202be8d4585fc70ba94bee4a4..2ad273d1efcaaa54b0a1326ff1c15b6cfc199be3 100644 --- a/lib/@types/index.d.ts +++ b/lib/@types/index.d.ts +@@ -101,7 +101,7 @@ declare namespace Srf { + local: { uri: string; sdp: string; }; + remote: { uri: string; sdp: string; }; + req: SrfRequest; +- destroy(opts?: { headers: Record; }, callback?: (err: any, msg: SrfRequest) => void): void; ++ destroy(opts?: { headers: Record; }, callback?: (err: any, msg: SrfRequest) => void): Promise; + modify(sdp: string, opts?: { noAck: boolean }): Promise; + modify(opts: { noAck: boolean }): Promise; + modify(sdp: string, opts?: { noAck: boolean }, callback?: (err: any, msg: SrfResponse) => void): void; @@ -119,12 +119,12 @@ declare namespace Srf { on(messageType: "modify", callback: (req: SrfRequest, res: SrfResponse) => void): void; once(messageType: string, callback: (msg: SrfResponse) => void): void; diff --git a/apps/meteor/.docker-mongo/Dockerfile b/apps/meteor/.docker-mongo/Dockerfile deleted file mode 100644 index f9f899be75d18..0000000000000 --- a/apps/meteor/.docker-mongo/Dockerfile +++ /dev/null @@ -1,66 +0,0 @@ -FROM node:22.22.2-bullseye-slim - -LABEL maintainer="buildmaster@rocket.chat" - -# Install MongoDB and dependencies -ENV MONGO_MAJOR=5.0 \ - MONGO_VERSION=5.0.5 - -RUN set -x \ - && apt-get update \ - && apt-get install -y wget gnupg dirmngr pwgen \ - && wget -qO - "https://www.mongodb.org/static/pgp/server-$MONGO_MAJOR.asc" | apt-key add - \ - && echo "deb http://repo.mongodb.org/apt/debian buster/mongodb-org/$MONGO_MAJOR main" | tee "/etc/apt/sources.list.d/mongodb-org-$MONGO_MAJOR.list" \ - && apt-get update \ - && apt-get install -y \ - mongodb-org=$MONGO_VERSION \ - mongodb-org-server=$MONGO_VERSION \ - mongodb-org-shell=$MONGO_VERSION \ - mongodb-org-mongos=$MONGO_VERSION \ - mongodb-org-tools=$MONGO_VERSION \ - fontconfig \ - && apt-get clean my room \ - && groupadd -g 65533 -r rocketchat \ - && useradd -u 65533 -r -g rocketchat rocketchat \ - && mkdir -p /app/uploads \ - && chown rocketchat:rocketchat /app/uploads - -# --chown requires Docker 17.12 and works only on Linux -ADD --chown=rocketchat:rocketchat . /app -ADD --chown=rocketchat:rocketchat entrypoint.sh /app/bundle/ - -RUN aptMark="$(apt-mark showmanual)" \ - && apt-get install -y --no-install-recommends g++ make python ca-certificates \ - && cd /app/bundle/programs/server \ - && npm install \ - && apt-mark auto '.*' > /dev/null \ - && apt-mark manual $aptMark > /dev/null \ - && find /usr/local -type f -executable -exec ldd '{}' ';' \ - | awk '/=>/ { print $(NF-1) }' \ - | sort -u \ - | xargs -r dpkg-query --search \ - | cut -d: -f1 \ - | sort -u \ - | xargs -r apt-mark manual \ - && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ - && npm cache clear --force - -VOLUME /app/uploads - -WORKDIR /app/bundle - -# needs a mongoinstance - defaults to container linking with alias 'mongo' -ENV DEPLOY_METHOD=docker-preview \ - NODE_ENV=production \ - MONGO_URL=mongodb://localhost:27017/rocketchat \ - MONGO_OPLOG_URL=mongodb://localhost:27017/local \ - HOME=/tmp \ - PORT=3000 \ - ROOT_URL=http://localhost:3000 \ - Accounts_AvatarStorePath=/app/uploads - -EXPOSE 3000 - -RUN chmod +x /app/bundle/entrypoint.sh - -ENTRYPOINT /app/bundle/entrypoint.sh diff --git a/apps/meteor/.docker-mongo/entrypoint.sh b/apps/meteor/.docker-mongo/entrypoint.sh deleted file mode 100644 index 0568da462bf32..0000000000000 --- a/apps/meteor/.docker-mongo/entrypoint.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/bin/bash - -echo """ -[49m[K[0m -[22C[48;5;203m [49m -[20C[48;5;203m [49m -[20C[48;5;203m [49m -[15C[48;5;203m [48;5;231m [48;5;203m [49m -[10C[48;5;203m [48;5;231m [48;5;16m [48;5;231m [48;5;203m [5C[48;5;16m [49m -[2C[48;5;203m [48;5;231m [48;5;16m [48;5;188m [48;5;16m [48;5;231m [48;5;203m [48;5;16m [49m -[5C[48;5;203m [48;5;231m [48;5;16m [48;5;188m [48;5;231m [48;5;16m [48;5;231m [48;5;203m [48;5;16m [48;5;231m [48;5;16m [49m -[5C[48;5;203m [48;5;231m [48;5;16m [48;5;188m [48;5;231m [48;5;16m [48;5;231m [48;5;203m [48;5;16m [48;5;231m [48;5;16m [49m -[10C[48;5;203m [48;5;231m [48;5;203m [48;5;231m [48;5;203m [48;5;16m [48;5;188m [48;5;231m [48;5;16m [48;5;231m [48;5;16m [49m -[10C[48;5;203m [48;5;231m [48;5;203m [48;5;231m [48;5;203m [48;5;16m [48;5;188m [48;5;231m [48;5;16m [49m -[10C[48;5;203m [48;5;231m [48;5;203m [48;5;231m [48;5;203m [48;5;16m [48;5;188m [48;5;231m [48;5;16m [49m -[5C[48;5;203m [48;5;231m [48;5;16m [48;5;188m [48;5;231m [48;5;16m [48;5;231m [48;5;16m [48;5;231m [48;5;16m [49m -[2C[48;5;203m [48;5;231m [48;5;16m [48;5;188m [48;5;231m [48;5;16m [48;5;231m [48;5;16m [48;5;231m [48;5;16m [49m -[2C[48;5;203m [48;5;231m [48;5;16m [48;5;188m [48;5;231m [48;5;16m [48;5;231m [48;5;16m [48;5;231m [48;5;16m [49m -[7C[48;5;16m [48;5;203m [48;5;231m [48;5;16m [48;5;188m [48;5;231m [48;5;16m [49m -[5C[48;5;16m [48;5;231m [48;5;203m [48;5;16m [48;5;188m [48;5;231m [48;5;16m [48;5;231m [48;5;16m [48;5;231m [48;5;16m [48;5;231m [48;5;16m [49m -[5C[48;5;16m [48;5;231m [48;5;16m [48;5;231m [48;5;203m [48;5;16m [48;5;188m [48;5;231m [48;5;16m [48;5;231m [48;5;16m [49m -[5C[48;5;16m [5C [48;5;231m [48;5;16m [7C [48;5;188m [48;5;231m [48;5;16m [49m -[5C[48;5;16m [5C [48;5;231m [48;5;16m [7C [48;5;188m [48;5;231m [48;5;16m [49m -[15C[48;5;16m [48;5;231m [48;5;16m [10C [49m -[15C[48;5;16m [13C [48;5;231m [48;5;16m [2C [48;5;231m [48;5;16m [49m -[37C[48;5;16m [2C [49m - - - -[0m -""" - -echo """ -██████╗ ██████╗ ██████╗██╗ ██╗███████╗████████╗ ██████╗██╗ ██╗ █████╗ ████████╗ ██████╗ ██████╗ ███████╗██╗ ██╗██╗███████╗██╗ ██╗ -██╔══██╗██╔═══██╗██╔════╝██║ ██╔╝██╔════╝╚══██╔══╝██╔════╝██║ ██║██╔══██╗╚══██╔══╝ ██╔══██╗██╔══██╗██╔════╝██║ ██║██║██╔════╝██║ ██║ -██████╔╝██║ ██║██║ █████╔╝ █████╗ ██║ ██║ ███████║███████║ ██║ ██████╔╝██████╔╝█████╗ ██║ ██║██║█████╗ ██║ █╗ ██║ -██╔══██╗██║ ██║██║ ██╔═██╗ ██╔══╝ ██║ ██║ ██╔══██║██╔══██║ ██║ ██╔═══╝ ██╔══██╗██╔══╝ ╚██╗ ██╔╝██║██╔══╝ ██║███╗██║ -██║ ██║╚██████╔╝╚██████╗██║ ██╗███████╗ ██║██╗╚██████╗██║ ██║██║ ██║ ██║ ██║ ██║ ██║███████╗ ╚████╔╝ ██║███████╗╚███╔███╔╝ -╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚═╝ ╚═╝╚══════╝ ╚═╝╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝ ╚═══╝ ╚═╝╚══════╝ ╚══╝╚══╝ -""" - -mongod --fork --replSet rs0 --config /etc/mongod.conf - -until mongo --eval "db" &> /dev/null; do - echo "MongoDB still not ready, sleeping" - sleep 1 -done - -sleep 2 - -# initiate mongo replica set -for i in `seq 1 30`; do - mongo rocketchat --eval " - rs.initiate({ - _id: 'rs0', - members: [ { _id: 0, host: 'localhost:27017' } ]})" && - s=$? && break || s=$?; - echo "Tried $i times. Waiting 5 secs..."; - sleep 5; -done; - -sleep 2 - -# try multiple times until replica set is ready -for i in `seq 1 30`; do - node main.js && - s=$? && break || s=$?; - echo "Tried $i times. Waiting 5 secs..."; - sleep 5; -done; diff --git a/apps/meteor/.docker-mongo/licenses/LICENSE b/apps/meteor/.docker-mongo/licenses/LICENSE deleted file mode 100644 index 96cd938114191..0000000000000 --- a/apps/meteor/.docker-mongo/licenses/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2015-2022 Rocket.Chat Technologies Corp. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - diff --git a/apps/meteor/.docker/Dockerfile.alpine b/apps/meteor/.docker/Dockerfile.alpine index dcfed47e1c5f7..2bee7b45f36e3 100644 --- a/apps/meteor/.docker/Dockerfile.alpine +++ b/apps/meteor/.docker/Dockerfile.alpine @@ -1,4 +1,4 @@ -FROM node:22.22.2-alpine3.22 AS builder +FROM node:22.22.3-alpine3.23 AS builder ENV LANG=C.UTF-8 @@ -20,7 +20,7 @@ RUN cd /app/bundle/programs/server \ && find /app/bundle/programs/server/npm/node_modules -type f -name '*.map' -delete \ && find /app/bundle/programs/web.browser -type f -name '*.map' -delete -FROM node:22.22.2-alpine3.22 +FROM node:22.22.3-alpine3.23 LABEL maintainer="buildmaster@rocket.chat" diff --git a/apps/meteor/.docker/Dockerfile.debian b/apps/meteor/.docker/Dockerfile.debian index 8938efd52db18..4279fd4a9196e 100644 --- a/apps/meteor/.docker/Dockerfile.debian +++ b/apps/meteor/.docker/Dockerfile.debian @@ -2,7 +2,7 @@ ARG DENO_VERSION="1.37.1" FROM denoland/deno:bin-${DENO_VERSION} as deno -FROM node:22.22.2-bullseye-slim +FROM node:22.22.3-bullseye-slim LABEL maintainer="buildmaster@rocket.chat" diff --git a/apps/meteor/.meteor/packages b/apps/meteor/.meteor/packages index 5b78dccdc9eb1..27218ad49a49f 100644 --- a/apps/meteor/.meteor/packages +++ b/apps/meteor/.meteor/packages @@ -7,12 +7,12 @@ rocketchat:mongo-config rocketchat:livechat rocketchat:version -accounts-base@3.2.0 +accounts-base@3.2.1 accounts-facebook@1.3.4 accounts-google@1.4.1 accounts-meteor-developer@1.5.1 accounts-oauth@1.4.6 -accounts-password@3.2.2 +accounts-password@3.2.3 accounts-twitter@1.5.2 google-oauth@1.4.5 @@ -26,9 +26,9 @@ email@3.1.2 meteor-base@1.5.2 ddp-common@1.4.4 -webapp@2.1.0 +webapp@2.1.2 -mongo@2.2.0 +mongo@2.3.0 reload@1.3.2 service-configuration@1.3.5 @@ -50,14 +50,14 @@ tracker@1.3.4 reactive-dict@1.3.2 reactive-var@1.0.13 -standard-minifier-css@1.10.0 +standard-minifier-css@1.10.1 dynamic-import@0.7.4 -ecmascript@0.17.0 -typescript@5.9.3 +ecmascript@0.18.0 +typescript@5.10.0 autoupdate@2.0.1 # photoswipe zodern:types -standard-minifier-js +standard-minifier-js@3.2.0 diff --git a/apps/meteor/.meteor/release b/apps/meteor/.meteor/release index 703a72252657a..49a715aec2326 100644 --- a/apps/meteor/.meteor/release +++ b/apps/meteor/.meteor/release @@ -1 +1 @@ -METEOR@3.4 +METEOR@3.4.1 diff --git a/apps/meteor/.meteor/versions b/apps/meteor/.meteor/versions index 7ea703c812dd1..17ce8c19c40d7 100644 --- a/apps/meteor/.meteor/versions +++ b/apps/meteor/.meteor/versions @@ -1,31 +1,31 @@ -accounts-base@3.2.0 +accounts-base@3.2.1 accounts-facebook@1.3.4 accounts-google@1.4.1 accounts-meteor-developer@1.5.1 accounts-oauth@1.4.6 -accounts-password@3.2.2 +accounts-password@3.2.3 accounts-twitter@1.5.2 allow-deny@2.1.0 autoupdate@2.0.1 -babel-compiler@7.13.0 +babel-compiler@7.14.0 babel-runtime@1.5.2 base64@1.0.13 binary-heap@1.0.12 boilerplate-generator@2.1.0 -callback-hook@1.6.1 +callback-hook@1.7.0 check@1.5.0 core-runtime@1.0.0 ddp@1.4.2 -ddp-client@3.1.1 +ddp-client@3.2.0 ddp-common@1.4.4 ddp-rate-limiter@1.2.2 -ddp-server@3.1.2 +ddp-server@3.2.0 diff-sequence@1.1.3 dispatch:run-as-user@1.1.1 dynamic-import@0.7.4 -ecmascript@0.17.0 +ecmascript@0.18.0 ecmascript-runtime@0.8.3 -ecmascript-runtime-client@0.12.3 +ecmascript-runtime-client@0.13.0 ecmascript-runtime-server@0.11.1 ejson@1.1.5 email@3.1.2 @@ -41,17 +41,17 @@ id-map@1.2.0 inter-process-messaging@0.1.2 localstorage@1.2.1 logging@1.3.6 -meteor@2.2.0 +meteor@2.3.0 meteor-base@1.5.2 meteor-developer-oauth@1.3.3 meteorhacks:inject-initial@1.0.5 minifier-css@2.0.1 -minifier-js@3.1.0 -minimongo@2.0.5 +minifier-js@3.2.0 +minimongo@2.1.0 modern-browsers@0.2.3 modules@0.20.3 modules-runtime@0.13.2 -mongo@2.2.0 +mongo@2.3.0 mongo-decimal@0.2.0 mongo-dev-server@1.1.1 mongo-id@1.0.9 @@ -78,13 +78,13 @@ session@1.2.2 sha@1.0.10 shell-server@0.7.0 socket-stream-client@0.6.1 -standard-minifier-css@1.10.0 +standard-minifier-css@1.10.1 +standard-minifier-js@3.2.0 tracker@1.3.4 twitter-oauth@1.3.4 -typescript@5.9.3 +typescript@5.10.0 underscore@1.6.4 url@1.3.5 -webapp@2.1.0 +webapp@2.1.2 webapp-hashing@1.1.2 -standard-minifier-js@3.2.0 zodern:types@1.0.13 diff --git a/apps/meteor/.mocharc.api.js b/apps/meteor/.mocharc.api.js index ca2657fa8a9a2..94abb12f7beea 100644 --- a/apps/meteor/.mocharc.api.js +++ b/apps/meteor/.mocharc.api.js @@ -10,5 +10,6 @@ module.exports = /** @satisfies {import('mocha').MochaOptions} */ ({ bail: false, retries: 0, file: 'tests/end-to-end/teardown.ts', + reporter: 'tests/end-to-end/reporter.ts', spec: ['tests/end-to-end/api/*.ts', 'tests/end-to-end/api/helpers/**/*', 'tests/end-to-end/api/methods/**/*', 'tests/end-to-end/apps/*'], }); diff --git a/apps/meteor/CHANGELOG.md b/apps/meteor/CHANGELOG.md index c31b5c6cc79dc..a9eb8493aa394 100644 --- a/apps/meteor/CHANGELOG.md +++ b/apps/meteor/CHANGELOG.md @@ -1,11 +1,208 @@ # @rocket.chat/meteor -## 8.4.3 +## 8.5.0-rc.6 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +- Updated dependencies []: + + - @rocket.chat/core-typings@8.5.0-rc.6 + - @rocket.chat/rest-typings@8.5.0-rc.6 + + +## 8.5.0-rc.5 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +- Updated dependencies []: + + - @rocket.chat/core-typings@8.5.0-rc.5 + - @rocket.chat/rest-typings@8.5.0-rc.5 + + +## 8.5.0-rc.4 + +### Patch Changes + +- Bump @rocket.chat/meteor version. +- Updated dependencies []: + + - @rocket.chat/core-typings@8.5.0-rc.4 + - @rocket.chat/rest-typings@8.5.0-rc.4 + + +## 8.5.0-rc.3 + +### Patch Changes + +- Bump @rocket.chat/meteor version. +- ([#40635](https://github.com/RocketChat/Rocket.Chat/pull/40635) by [@copilot-swe-agent](https://github.com/copilot-swe-agent)) Fixes the Chat Limits locking mechanism to allow bot agents to skip the lock as they aren't limited + +- Updated dependencies []: + + - @rocket.chat/core-typings@8.5.0-rc.3 + - @rocket.chat/rest-typings@8.5.0-rc.3 + + +## 8.5.0-rc.2 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +- Updated dependencies []: + + - @rocket.chat/core-typings@8.5.0-rc.2 + - @rocket.chat/rest-typings@8.5.0-rc.2 + + +## 8.5.0-rc.1 ### Patch Changes - Bump @rocket.chat/meteor version. +- Updated dependencies []: + + - @rocket.chat/core-typings@8.5.0-rc.1 + - @rocket.chat/rest-typings@8.5.0-rc.1 + + +## 8.5.0-rc.0 + +### Minor Changes + +- ([#40343](https://github.com/RocketChat/Rocket.Chat/pull/40343)) Swap usage of internal @rocket.chat/apps-engine internal APIs to @rocket.chat/apps package + +- ([#40408](https://github.com/RocketChat/Rocket.Chat/pull/40408)) Adds 4 new permissions (assigned to admins by default) to control the visibility of each tab inside the ABAC Administration panel + +- ([#39760](https://github.com/RocketChat/Rocket.Chat/pull/39760)) ## Phishing-Resistant Multi-Factor Authentication + + Introduces a more secure and reliable server-side OAuth authentication flow. + + ### What’s New + + - **Improved OAuth login security** + OAuth authentication now happens fully on the server, reducing the risk of token theft, phishing attacks, and client-side credential interception. + - **Built-in CSRF, state validation, and PKCE protection** + OAuth logins now include stronger protection against CSRF attacks, request tampering, and authorization code interception through secure state validation and PKCE support. + - **Improved two-step verification with OAuth logins** + Users with email or TOTP two-factor authentication enabled will now be asked to complete 2FA even when signing in with providers like Google, GitHub, GitLab, and others. + - **Improved mobile & desktop app login** + Mobile and desktop apps now support a smoother and more secure deep-link OAuth login flow. + +- ([#40341](https://github.com/RocketChat/Rocket.Chat/pull/40341)) Hides the room announcement, topic and description from the Administration > Rooms panel for ABAC managed rooms. In the channel sidebar Edit Channel form those fields stay visible to room members but are disabled, and the API rejects edits to them. + +- ([#39617](https://github.com/RocketChat/Rocket.Chat/pull/39617)) Adds new API endpoints `custom-sounds.create` and `custom-sounds.update` to manage custom sounds with strict file validation for size and specific MIME types to ensure system compatibility. + +- ([#40463](https://github.com/RocketChat/Rocket.Chat/pull/40463)) Allows apps with the right permission to read room's ABAC attributes. + +- ([#40604](https://github.com/RocketChat/Rocket.Chat/pull/40604)) Adds the capability for fetching a user by their sip extension to the apps + +- ([#38225](https://github.com/RocketChat/Rocket.Chat/pull/38225)) Adds a new "Drafts" group to the sidebar, providing quick access to all rooms with unfinished messages. + > This feature is available under the `Drafts in sidebar` feature preview and needs to be enabled in settings to be tested. +- ([#40397](https://github.com/RocketChat/Rocket.Chat/pull/40397)) Adds the `USE_ROOM_SEARCH_INDEX` environment variable. When set to `true`, the messages collection's text index is created as `{ rid: 1, msg: 'text' }` instead of the default `{ msg: 'text' }`. The compound shape lets per-room `$text` searches use `rid` as a prefix, dramatically reducing the portion of the index scanned on workspaces where global search is disabled. + + The index is reconciled on every startup: if the existing text index already matches the desired shape, nothing happens; otherwise the stale text index is dropped and the desired one is recreated. Unsetting the variable on a later boot reverts to the default shape. + +- ([#40612](https://github.com/RocketChat/Rocket.Chat/pull/40612)) Adds `freeSwitchExtension` as a query parameter for `api/v1/users.info` + +- ([#39858](https://github.com/RocketChat/Rocket.Chat/pull/39858)) Adds support to room information on ViewSubmit and ViewClose events for ContextualBar surface + +- ([#40430](https://github.com/RocketChat/Rocket.Chat/pull/40430)) Adds a new admin setting `Use_RC_SDK` (General → Use Rocket.Chat SDK) that opts the workspace into the experimental SDK-over-DDP transport. When enabled, the client routes Meteor DDP traffic through `@rocket.chat/ddp-client` over a single WebSocket instead of the legacy Meteor stream. The flag is dormant by default; the server surfaces the value via a `` tag, and the client also honors a per-tab `?sdk_transport=on|off` URL parameter and a `rc-config-sdk_transport` localStorage key (URL > localStorage > meta tag). + +### Patch Changes + +- ([#39858](https://github.com/RocketChat/Rocket.Chat/pull/39858)) Fixes an issue that prevented BlockAction interactions from having room information when triggered in a ContextualBar surface + +- ([#40524](https://github.com/RocketChat/Rocket.Chat/pull/40524)) Ensures OAuth tokens are cleaned up after user deactivation + +- ([#40537](https://github.com/RocketChat/Rocket.Chat/pull/40537)) Fixes an issue that allowed a room converted from private to public (while abac is disabled) to retain its abac attributes (if any) + +- ([#39859](https://github.com/RocketChat/Rocket.Chat/pull/39859)) Fixes an issue where thread content would disappear after clicking "Jump to recent messages". + +- ([#40063](https://github.com/RocketChat/Rocket.Chat/pull/40063)) Fixes the missing edited indicator for the main parent message in the thread panel to ensure visual consistency with the main channel view. + +- ([#40357](https://github.com/RocketChat/Rocket.Chat/pull/40357)) Adds an accessible label to the system-messages multi-select in the channel edit panel so screen readers announce its purpose. + +- ([#40100](https://github.com/RocketChat/Rocket.Chat/pull/40100)) Fixes intermittent "Channel Not Joined" screen when opening rooms in embedded mode. + +- ([#40513](https://github.com/RocketChat/Rocket.Chat/pull/40513)) Fixes the `users.presence` endpoint returning an empty array when called with multiple comma-separated IDs, caused by `ajvQuery` coercing the string into a single-element array after the OpenAPI migration + +- ([#40496](https://github.com/RocketChat/Rocket.Chat/pull/40496)) Ensures that deactivated users have their login tokens cleaned up in users.deactivateidle + +- ([#40405](https://github.com/RocketChat/Rocket.Chat/pull/40405)) Disables SAML login when it is set to validate signatures without the proper configuration for it + +- ([#40423](https://github.com/RocketChat/Rocket.Chat/pull/40423)) Allows users to search for attribute values when assigning them to rooms + +- ([#40335](https://github.com/RocketChat/Rocket.Chat/pull/40335)) Fixes test button not playing default sound in Notifications Preferences + +- ([#40528](https://github.com/RocketChat/Rocket.Chat/pull/40528)) Ensures the Meteor method for translateMessage validates access and types + +- ([#40420](https://github.com/RocketChat/Rocket.Chat/pull/40420)) Fixes Insert Timestamp relative time preview not updating on input changes and losing the user's locale after the first refresh tick. + +- ([#40456](https://github.com/RocketChat/Rocket.Chat/pull/40456)) Fixes signed URL generation for S3 and Google Cloud Storage when the expiry setting is below 5 seconds, which previously caused expired or invalid preview URLs. Adds a dedicated URL expiry setting for Google Cloud Storage since it was incorrectly reusing the AWS S3 setting. + +- ([#40501](https://github.com/RocketChat/Rocket.Chat/pull/40501)) Ensures the visitor token is not present in the visitors.info response + +- ([#40405](https://github.com/RocketChat/Rocket.Chat/pull/40405)) Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates) + +- ([#40613](https://github.com/RocketChat/Rocket.Chat/pull/40613)) Sanitizes image URLs in rendered messages to block `javascript:`, `data:`, and `vbscript:` schemes — matching the protection already applied to markdown links. Defense-in-depth against XSS via crafted markdown like ``. + +- ([#40508](https://github.com/RocketChat/Rocket.Chat/pull/40508)) Ensures the autotranslate.translateMessage endpoint checks for room access + +- ([#40448](https://github.com/RocketChat/Rocket.Chat/pull/40448)) Fixes action buttons added by apps being rendered in the Marketplace Menu rather than the User Menu + +- ([#40499](https://github.com/RocketChat/Rocket.Chat/pull/40499)) Fixes an issue where some actions made by the abac service were not broadcasting to clients, which affected reactivity + +- ([#40492](https://github.com/RocketChat/Rocket.Chat/pull/40492)) Fixes issue that displayed the 'Delete all closed chats' button when user lacks `remove-closed-livechat-rooms` permission + +- ([#40393](https://github.com/RocketChat/Rocket.Chat/pull/40393)) Fixes a `date-fns` crash on routes that mount before the public settings stream finishes loading. `useFormatDate` was passing `String(undefined)` (the literal `"undefined"`) to `formatDate` while `Message_DateFormat` was momentarily unloaded — `date-fns` rejects that token because it contains an unescaped `n`. The hook now uses `'LL'` as the default token via `useSetting`'s second argument, so the formatter always receives a valid format string. + +- Updated dependencies [90f15e32ae843ed146ccf711ee3201408d1e8731, f7d47dd3517ec14ca2ec5c3c95fcdf9e1e2fb8b0, cdb264fec803e234a6ad2000018b31d4b2074e99, 2a927fa1362c9d4bb04bb8e26f23a6e3753d9cea, bede0e2528bb053cb913e93ccf30d78a1c84bc76, bede0e2528bb053cb913e93ccf30d78a1c84bc76, bede0e2528bb053cb913e93ccf30d78a1c84bc76, 4c3984593017d59edd631bf8ae4f35f9d3c3db36, 7f2bdf1809804de7a95c54c3892da30f058ee13d, ae9f740d6af20557eac61b4af902c868b4132b49, b6b04aadfcc8558f888b334e37c46a77e5816237, ad7d42400ea36f1eb0aaf7cc3361c77fdabf9ebc, 4704bf81ca370f120af32185a7c55407a26f8514, d427b808c1f79d9d1baa05bb5b5ef805b6ef5f6d, ebc9babf55dd26613027c28dcacf77909116b342, f392d5cc8d956c199f557dcd6beb52094232499d, 2198d9ea565b06f92e3dec29891890086f62f9df, fac64728505b312d5da786e92d3134450ce4a7c1, 12897e25d0dc25b7373f5264d38f38a5a7444257, e45585b70a3a7b75434c88e4b2ea9af0a0764a76, 0b7a76367d650793c271160e01798ebbb5fe0d26, 51833064591b91140d17e403389e1abbc5d9ef7a, 2d32e52073dd1a68bd12a093b3a673ae297cb4ee, 2a927fa1362c9d4bb04bb8e26f23a6e3753d9cea, b1c2668b74bfb49ebaefe2f581b2f8be5d4d1dd6, 90f15e32ae843ed146ccf711ee3201408d1e8731, 22c8d3283f0ea3004fe94c51f8bb32dfb40a0f4f]: + + - @rocket.chat/ui-kit@1.1.0-rc.0 + - @rocket.chat/model-typings@2.3.0-rc.0 + - @rocket.chat/models@2.3.0-rc.0 + - @rocket.chat/i18n@3.1.0-rc.0 + - @rocket.chat/apps-engine@1.63.0-rc.0 + - @rocket.chat/ddp-client@1.1.0-rc.0 + - @rocket.chat/rest-typings@8.5.0-rc.0 + - @rocket.chat/ui-voip@21.0.0-rc.0 + - @rocket.chat/web-ui-registration@31.0.0-rc.0 + - @rocket.chat/core-typings@8.5.0-rc.0 + - @rocket.chat/gazzodown@31.0.0-rc.0 + - @rocket.chat/apps@0.7.0-rc.0 + - @rocket.chat/ui-client@31.0.0-rc.0 + - @rocket.chat/abac@0.2.1-rc.0 + - @rocket.chat/media-calls@0.5.0-rc.0 + - @rocket.chat/core-services@0.14.1-rc.0 + - @rocket.chat/fuselage-ui-kit@31.0.0-rc.0 + - @rocket.chat/omnichannel-services@0.3.54-rc.0 + - @rocket.chat/federation-matrix@0.1.4-rc.0 + - @rocket.chat/omni-core-ee@0.0.22-rc.0 + - @rocket.chat/presence@0.2.57-rc.0 + - @rocket.chat/cron@0.1.57-rc.0 + - @rocket.chat/instance-status@0.1.57-rc.0 + - @rocket.chat/omni-core@0.1.1-rc.0 + - @rocket.chat/server-fetch@0.2.1-rc.0 + - @rocket.chat/ui-contexts@31.0.0-rc.0 + - @rocket.chat/ui-composer@2.0.0-rc.0 + - @rocket.chat/network-broker@0.2.36-rc.0 + - @rocket.chat/ui-avatar@27.0.0-rc.0 + - @rocket.chat/ui-video-conf@31.0.0-rc.0 + + + +## 8.4.3 + +### Patch Changes + +- Bump @rocket.chat/meteor version. - Bump @rocket.chat/meteor version. - ([#40771](https://github.com/RocketChat/Rocket.Chat/pull/40771) by [@dionisio-bot](https://github.com/dionisio-bot)) Fixes the Chat Limits locking mechanism to allow bot agents to skip the lock as they aren't limited @@ -50,6 +247,9 @@ - Bump @rocket.chat/meteor version. +### Patch Changes + +- Bump @rocket.chat/meteor version. - Bump @rocket.chat/meteor version. - ([#40410](https://github.com/RocketChat/Rocket.Chat/pull/40410) by [@dionisio-bot](https://github.com/dionisio-bot)) Disables SAML login when it is set to validate signatures without the proper configuration for it diff --git a/apps/meteor/app/2fa/server/code/index.ts b/apps/meteor/app/2fa/server/code/index.ts index cfe7b8ab7d4d4..8242b1c66185e 100644 --- a/apps/meteor/app/2fa/server/code/index.ts +++ b/apps/meteor/app/2fa/server/code/index.ts @@ -1,4 +1,4 @@ -import crypto from 'crypto'; +import crypto from 'node:crypto'; import type { IUser, IMethodConnection } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; diff --git a/apps/meteor/app/api/server/api.ts b/apps/meteor/app/api/server/api.ts index dbafa020706f8..1b5bac10a65df 100644 --- a/apps/meteor/app/api/server/api.ts +++ b/apps/meteor/app/api/server/api.ts @@ -102,11 +102,21 @@ settings.watch('API_Enable_Rate_Limiter_Limit_Calls_Default', (value) => export const startRestAPI = () => { (WebApp.rawConnectHandlers as unknown as ReturnType).use( API.api + .use( + metricsMiddleware({ + basePathRegex: new RegExp(/^\/api\/v1\//), + api: API.v1, + settings, + endpointTimeSummary: metrics.rocketchatRestApi, + endpointTimeHistogram: metrics.rocketchatRestApiSeconds, + responseSizeHistogram: metrics.rocketchatRestApiResponseSizeBytes, + activeRequestsGauge: metrics.rocketchatRestApiActiveRequests, + }), + ) + .use(tracerSpanMiddleware) .use(remoteAddressMiddleware) .use(cors(settings)) .use(loggerMiddleware(logger)) - .use(metricsMiddleware({ basePathRegex: new RegExp(/^\/api\/v1\//), api: API.v1, settings, summary: metrics.rocketchatRestApi })) - .use(tracerSpanMiddleware) .use(API.v1.router) .use(API.default.router).router, ); diff --git a/apps/meteor/app/api/server/definition.ts b/apps/meteor/app/api/server/definition.ts index 9898a33ae27af..c728de5b16be5 100644 --- a/apps/meteor/app/api/server/definition.ts +++ b/apps/meteor/app/api/server/definition.ts @@ -1,4 +1,4 @@ -import type { IncomingMessage } from 'http'; +import type { IncomingMessage } from 'node:http'; import type { IUser, LicenseModule, RequiredField } from '@rocket.chat/core-typings'; import type { Logger } from '@rocket.chat/logger'; diff --git a/apps/meteor/app/api/server/lib/MultipartUploadHandler.ts b/apps/meteor/app/api/server/lib/MultipartUploadHandler.ts index 467cf016d0d68..3f375fc4cd053 100644 --- a/apps/meteor/app/api/server/lib/MultipartUploadHandler.ts +++ b/apps/meteor/app/api/server/lib/MultipartUploadHandler.ts @@ -1,8 +1,8 @@ -import fs from 'fs'; -import { IncomingMessage } from 'http'; -import type { Stream, Transform } from 'stream'; -import { Readable } from 'stream'; -import { pipeline } from 'stream/promises'; +import fs from 'node:fs'; +import { IncomingMessage } from 'node:http'; +import type { Stream, Transform } from 'node:stream'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; import { MeteorError } from '@rocket.chat/core-services'; import { Random } from '@rocket.chat/random'; diff --git a/apps/meteor/app/api/server/lib/getUploadFormData.ts b/apps/meteor/app/api/server/lib/getUploadFormData.ts index 5841a5b58c32b..5e5ae85416d65 100644 --- a/apps/meteor/app/api/server/lib/getUploadFormData.ts +++ b/apps/meteor/app/api/server/lib/getUploadFormData.ts @@ -1,5 +1,5 @@ -import { Readable } from 'stream'; -import { ReadableStream } from 'stream/web'; +import { Readable } from 'node:stream'; +import { ReadableStream } from 'node:stream/web'; import { MeteorError } from '@rocket.chat/core-services'; import type { ValidateFunction } from 'ajv'; @@ -102,7 +102,9 @@ export async function getUploadFormData< return reject(new MeteorError('No file uploaded')); } if (options.validate !== undefined && !options.validate(fields)) { - return reject(new MeteorError(`Invalid fields ${options.validate.errors?.join(', ')}`)); + return reject( + new MeteorError(`Invalid fields ${(options.validate.errors ?? []).map((e) => `${e.instancePath} ${e.message}`).join(', ')}`), + ); } return resolve(uploadedFile); } diff --git a/apps/meteor/app/api/server/lib/rooms.ts b/apps/meteor/app/api/server/lib/rooms.ts index d2bac79d6f790..216e17a42422e 100644 --- a/apps/meteor/app/api/server/lib/rooms.ts +++ b/apps/meteor/app/api/server/lib/rooms.ts @@ -4,6 +4,7 @@ import type { FindOptions, Sort } from 'mongodb'; import { adminFields } from '../../../../lib/rooms/adminFields'; import { hasAtLeastOnePermissionAsync, hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { stripABACManagedFieldsForAdmin } from '../../../authorization/server/lib/isABACManagedRoom'; export async function findAdminRooms({ uid, @@ -39,7 +40,13 @@ export async function findAdminRooms({ const { cursor, totalCount } = result; - const [rooms, total] = await Promise.all([cursor.sort(sort || { default: -1, name: 1 }).toArray(), totalCount]); + const [rooms, total] = await Promise.all([ + cursor + .sort(sort || { default: -1, name: 1 }) + .map(stripABACManagedFieldsForAdmin) + .toArray(), + totalCount, + ]); return { rooms, @@ -54,7 +61,11 @@ export async function findAdminRoom({ uid, rid }: { uid: string; rid: string }): throw new Error('error-not-authorized'); } - return Rooms.findOneById(rid, { projection: adminFields }); + const room = await Rooms.findOneById(rid, { projection: adminFields }); + if (!room) { + return null; + } + return stripABACManagedFieldsForAdmin(room); } export async function findChannelAndPrivateAutocomplete({ uid, selector }: { uid: string; selector: { name: string } }): Promise<{ diff --git a/apps/meteor/app/api/server/middlewares/metrics.spec.ts b/apps/meteor/app/api/server/middlewares/metrics.spec.ts index 6f53a489c6fb5..3fdc94cda826a 100644 --- a/apps/meteor/app/api/server/middlewares/metrics.spec.ts +++ b/apps/meteor/app/api/server/middlewares/metrics.spec.ts @@ -25,27 +25,42 @@ describe('Metrics middleware', () => { const mockEndTimer = jest.fn(); summary.startTimer.mockReturnValue(mockEndTimer); - api.use(metricsMiddleware({ api: { version: 1 } as any, settings, summary: summary as any })).get( - '/test', - { - response: { - 200: ajv.compile({ - type: 'object', - properties: { - message: { type: 'string' }, + const histogram = { startTimer: jest.fn().mockReturnValue(jest.fn()) }; + const responseSizeHistogram = { observe: jest.fn() }; + const activeRequestsGauge = { inc: jest.fn(), dec: jest.fn() }; + + api + .use( + metricsMiddleware({ + api: { version: 1 } as any, + settings, + endpointTimeSummary: summary as any, + endpointTimeHistogram: histogram as any, + responseSizeHistogram: responseSizeHistogram as any, + activeRequestsGauge: activeRequestsGauge as any, + }), + ) + .get( + '/test', + { + response: { + 200: ajv.compile({ + type: 'object', + properties: { + message: { type: 'string' }, + }, + }), + }, + }, + async () => { + return { + statusCode: 200, + body: { + message: 'Metrics test successful', }, - }), + }; }, - }, - async () => { - return { - statusCode: 200, - body: { - message: 'Metrics test successful', - }, - }; - }, - ); + ); app.use(api.router); const response = await request(app).get('/api/test').set('user-agent', 'test'); expect(response.statusCode).toBe(200); @@ -73,8 +88,22 @@ describe('Metrics middleware', () => { const mockEndTimer = jest.fn(); summary.startTimer.mockReturnValue(mockEndTimer); + const histogram = { startTimer: jest.fn().mockReturnValue(jest.fn()) }; + const responseSizeHistogram = { observe: jest.fn() }; + const activeRequestsGauge = { inc: jest.fn(), dec: jest.fn() }; + api - .use(metricsMiddleware({ basePathRegex: new RegExp(/^\/api\//), api: { version: 1 } as any, settings, summary: summary as any })) + .use( + metricsMiddleware({ + basePathRegex: new RegExp(/^\/api\//), + api: { version: 1 } as any, + settings, + endpointTimeSummary: summary as any, + endpointTimeHistogram: histogram as any, + responseSizeHistogram: responseSizeHistogram as any, + activeRequestsGauge: activeRequestsGauge as any, + }), + ) .get( '/test', { @@ -120,8 +149,22 @@ describe('Metrics middleware', () => { const mockEndTimer = jest.fn(); summary.startTimer.mockReturnValue(mockEndTimer); + const histogram = { startTimer: jest.fn().mockReturnValue(jest.fn()) }; + const responseSizeHistogram = { observe: jest.fn() }; + const activeRequestsGauge = { inc: jest.fn(), dec: jest.fn() }; + api - .use(metricsMiddleware({ basePathRegex: new RegExp(/^\/api\//), api: { version: 1 } as any, settings, summary: summary as any })) + .use( + metricsMiddleware({ + basePathRegex: new RegExp(/^\/api\//), + api: { version: 1 } as any, + settings, + endpointTimeSummary: summary as any, + endpointTimeHistogram: histogram as any, + responseSizeHistogram: responseSizeHistogram as any, + activeRequestsGauge: activeRequestsGauge as any, + }), + ) .get( '/method.call/:id', { diff --git a/apps/meteor/app/api/server/middlewares/metrics.ts b/apps/meteor/app/api/server/middlewares/metrics.ts index bedc2cc823a93..75774c495dbaf 100644 --- a/apps/meteor/app/api/server/middlewares/metrics.ts +++ b/apps/meteor/app/api/server/middlewares/metrics.ts @@ -1,5 +1,5 @@ import type { MiddlewareHandler } from 'hono'; -import type { Summary } from 'prom-client'; +import type { Gauge, Histogram, Summary } from 'prom-client'; import type { CachedSettings } from '../../../settings/server/CachedSettings'; import type { APIClass } from '../ApiClass'; @@ -9,28 +9,51 @@ export const metricsMiddleware = basePathRegex, api, settings, - summary, + endpointTimeSummary, + endpointTimeHistogram, + responseSizeHistogram, + activeRequestsGauge, }: { basePathRegex?: RegExp; api: APIClass; settings: CachedSettings; - summary: Summary; + endpointTimeSummary: Summary; + endpointTimeHistogram: Histogram; + responseSizeHistogram: Histogram; + activeRequestsGauge: Gauge; }): MiddlewareHandler => async (c, next) => { - const rocketchatRestApiEnd = summary.startTimer(); + const rocketchatRestApiEnd = endpointTimeSummary.startTimer(); + const rocketchatRestApiHistEnd = endpointTimeHistogram.startTimer(); + + const methodLabel = { method: c.req.method.toLowerCase() }; + activeRequestsGauge.inc(methodLabel); await next(); + activeRequestsGauge.dec(methodLabel); + const { method, path, routePath } = c.req; // get rid of the base path (i.e.: /api/v1/) const entrypoint = basePathRegex ? routePath.replace(basePathRegex, '') : routePath; - rocketchatRestApiEnd({ + const histogramLabels = { status: c.res.status, method: method.toLowerCase(), version: api.version, - ...(settings.get('Prometheus_API_User_Agent') && { user_agent: c.req.header('user-agent') }), entrypoint: basePathRegex && entrypoint.startsWith('method.call') ? decodeURIComponent(path.replace(basePathRegex, '')) : entrypoint, + }; + + rocketchatRestApiEnd({ + ...histogramLabels, + ...(settings.get('Prometheus_API_User_Agent') && { user_agent: c.req.header('user-agent') }), }); + + rocketchatRestApiHistEnd(histogramLabels); + + const contentLength = parseInt(c.res.headers.get('content-length') || '0', 10); + if (contentLength > 0) { + responseSizeHistogram.observe(histogramLabels, contentLength); + } }; diff --git a/apps/meteor/app/api/server/middlewares/remoteAddressMiddleware.ts b/apps/meteor/app/api/server/middlewares/remoteAddressMiddleware.ts index c8fff45bae33f..a91bbafbbeb65 100644 --- a/apps/meteor/app/api/server/middlewares/remoteAddressMiddleware.ts +++ b/apps/meteor/app/api/server/middlewares/remoteAddressMiddleware.ts @@ -1,4 +1,4 @@ -import type { IncomingMessage } from 'http'; +import type { IncomingMessage } from 'node:http'; import type { Context, MiddlewareHandler } from 'hono'; diff --git a/apps/meteor/app/api/server/v1/custom-sounds.ts b/apps/meteor/app/api/server/v1/custom-sounds.ts index 149a8a20a79e0..b74a396c5ee58 100644 --- a/apps/meteor/app/api/server/v1/custom-sounds.ts +++ b/apps/meteor/app/api/server/v1/custom-sounds.ts @@ -4,6 +4,8 @@ import type { PaginatedResult } from '@rocket.chat/rest-typings'; import { isCustomSoundsGetOneProps, isCustomSoundsListProps, + isCustomSoundsCreateProps, + isCustomSoundsUpdateProps, ajv, validateBadRequestErrorResponse, validateNotFoundErrorResponse, @@ -12,9 +14,49 @@ import { } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; +import { MAX_CUSTOM_SOUND_SIZE_BYTES, CUSTOM_SOUND_ALLOWED_MIME_TYPES } from '../../../../lib/constants'; +import { SystemLogger } from '../../../../server/lib/logger/system'; +import { insertOrUpdateSound } from '../../../custom-sounds/server/lib/insertOrUpdateSound'; +import { uploadCustomSound } from '../../../custom-sounds/server/lib/uploadCustomSound'; +import { getExtension, getMimeTypeFromFileName } from '../../../utils/lib/mimeTypes'; import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; +import { getUploadFormData } from '../lib/getUploadFormData'; + +const createCustomSoundsResponse = ajv.compile<{ sound: Pick; success: boolean }>({ + additionalProperties: false, + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + sound: { + type: 'object', + properties: { + _id: { + type: 'string', + description: 'The ID of the sound.', + }, + }, + required: ['_id'], + }, + }, + required: ['success', 'sound'], +}); + +const updateCustomSoundsResponse = ajv.compile<{ success: boolean }>({ + additionalProperties: false, + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['success'], +}); const customSoundsEndpoints = API.v1 .get( @@ -124,6 +166,120 @@ const customSoundsEndpoints = API.v1 return API.v1.success({ sound }); }, + ) + .post( + 'custom-sounds.create', + { + response: { + 200: createCustomSoundsResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + authRequired: true, + permissionsRequired: ['manage-sounds'], + }, + async function action() { + const { fields, fileBuffer, filename } = await getUploadFormData( + { + request: this.request, + }, + { + field: 'sound', + sizeLimit: MAX_CUSTOM_SOUND_SIZE_BYTES, + validate: isCustomSoundsCreateProps, + }, + ); + + const computedMimeType = getMimeTypeFromFileName(filename); + if (!CUSTOM_SOUND_ALLOWED_MIME_TYPES.includes(computedMimeType)) { + return API.v1.failure('MIME type not allowed'); + } + + // We extract its extension from the name or use getExtension() as fallback. + const soundExtension = + (filename.includes('.') ? filename.split('.').pop()?.toLowerCase() : undefined) || getExtension(computedMimeType); + + try { + const _id = await insertOrUpdateSound({ + name: fields.name, + extension: soundExtension, + }); + await uploadCustomSound(fileBuffer, computedMimeType, { _id, name: fields.name, extension: soundExtension }); + return API.v1.success({ sound: { _id } }); + } catch (error) { + SystemLogger.error({ error }); + return API.v1.failure(error instanceof Error ? error.message : 'Unknown error'); + } + }, + ) + .post( + 'custom-sounds.update', + { + response: { + 200: updateCustomSoundsResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + authRequired: true, + permissionsRequired: ['manage-sounds'], + }, + async function action() { + const { fields, fileBuffer, filename } = await getUploadFormData( + { + request: this.request, + }, + { + field: 'sound', + fileOptional: true, + sizeLimit: MAX_CUSTOM_SOUND_SIZE_BYTES, + validate: isCustomSoundsUpdateProps, + }, + ); + + let computedMimeType = ''; + if (fileBuffer) { + computedMimeType = getMimeTypeFromFileName(filename); + if (!CUSTOM_SOUND_ALLOWED_MIME_TYPES.includes(computedMimeType)) { + return API.v1.failure('MIME type not allowed'); + } + } + + const soundToUpdate = await CustomSounds.findOneById>(fields._id, { + projection: { _id: 1, name: 1, extension: 1 }, + }); + if (!soundToUpdate) { + return API.v1.failure('Custom Sound not found.'); + } + + // If new file, we'll extract its extension or use getExtension() as fallback. If there's no new file, we will keep the current extension. + const nextExtension = fileBuffer + ? (filename.includes('.') ? filename.split('.').pop()?.toLowerCase() : undefined) || getExtension(computedMimeType) + : soundToUpdate.extension; + + try { + await insertOrUpdateSound({ + _id: fields._id, + name: fields.name, + extension: nextExtension, + previousName: soundToUpdate.name, + previousExtension: soundToUpdate.extension, + }); + if (fileBuffer) { + await uploadCustomSound(fileBuffer, computedMimeType, { + _id: fields._id, + name: fields.name, + previousExtension: soundToUpdate.extension, + extension: nextExtension, + }); + } + return API.v1.success({}); + } catch (error) { + SystemLogger.error({ error }); + return API.v1.failure(error instanceof Error ? error.message : 'Unknown error'); + } + }, ); export type CustomSoundEndpoints = ExtractRoutesFromAPI; diff --git a/apps/meteor/app/api/server/v1/invites.ts b/apps/meteor/app/api/server/v1/invites.ts index b5a6fbd12c36c..0e0ea055160ee 100644 --- a/apps/meteor/app/api/server/v1/invites.ts +++ b/apps/meteor/app/api/server/v1/invites.ts @@ -70,7 +70,6 @@ const invites = API.v1 authRequired: true, response: { 200: ajv.compile({ - additionalProperties: false, type: 'array', items: { $ref: '#/components/schemas/IInvite', diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index c93450d937cd0..cf7a9a2d3d1bf 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -1,4 +1,4 @@ -import crypto from 'crypto'; +import crypto from 'node:crypto'; import type { IDirectoryChannelResult, IDirectoryUserResult, IRoom, IUser } from '@rocket.chat/core-typings'; import { Settings, Users, WorkspaceCredentials } from '@rocket.chat/models'; diff --git a/apps/meteor/app/api/server/v1/roles.ts b/apps/meteor/app/api/server/v1/roles.ts index 3972a97ee906a..e809baa3d7a19 100644 --- a/apps/meteor/app/api/server/v1/roles.ts +++ b/apps/meteor/app/api/server/v1/roles.ts @@ -38,10 +38,24 @@ const rolesRoutes = API.v1 { authRequired: true, response: { - 200: ajv.compile<{ roles: IRole[] }>({ + 200: ajv.compile<{ roles: Omit[] }>({ type: 'object', properties: { - roles: { type: 'array', items: { $ref: '#/components/schemas/IRole' } }, + roles: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + description: { type: 'string' }, + mandatory2fa: { type: 'boolean' }, + name: { type: 'string' }, + protected: { type: 'boolean' }, + scope: { type: 'string', enum: ['Users', 'Subscriptions'] }, + }, + required: ['_id', 'description', 'name', 'protected', 'scope'], + }, + }, success: { type: 'boolean', enum: [true] }, }, required: ['roles', 'success'], @@ -187,9 +201,9 @@ const rolesRoutes = API.v1 } const { cursor, totalCount } = await getUsersInRolePaginated(roleData._id, roomId, { - limit: count as number, + limit: count, sort: { username: 1 }, - skip: offset as number, + skip: offset, projection, }); diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index 8904d97dfbc93..d927487d9b868 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -59,11 +59,13 @@ import { unmuteUserInRoom } from '../../../../server/methods/unmuteUserInRoom'; import { roomsGetMethod } from '../../../../server/publications/room'; import { canAccessRoomAsync, canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { stripABACManagedFieldsForAdmin } from '../../../authorization/server/lib/isABACManagedRoom'; import { saveRoomSettings } from '../../../channel-settings/server/methods/saveRoomSettings'; import { createDiscussion } from '../../../discussion/server/methods/createDiscussion'; import { FileUpload } from '../../../file-upload/server'; import { sendFileMessage } from '../../../file-upload/server/methods/sendFileMessage'; import { syncRolePrioritiesForRoomIfRequired } from '../../../lib/server/functions/syncRolePrioritiesForRoomIfRequired'; +import { notifyOnSubscriptionChanged } from '../../../lib/server/lib/notifyListener'; import { executeArchiveRoom } from '../../../lib/server/methods/archiveRoom'; import { cleanRoomHistoryMethod } from '../../../lib/server/methods/cleanRoomHistory'; import { executeGetRoomRoles } from '../../../lib/server/methods/getRoomRoles'; @@ -414,6 +416,54 @@ const roomsSaveNotificationEndpoint = API.v1.post( }, ); +const saveDraftBodySchema = ajv.compile<{ rid: IRoom['_id']; draft: string }>({ + type: 'object', + properties: { + rid: { type: 'string', minLength: 1 }, + draft: { type: 'string' }, + }, + required: ['rid', 'draft'], + additionalProperties: false, +}); + +const saveDraftResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +const roomsSaveDraftEndpoint = API.v1.post( + 'rooms.saveDraft', + { + authRequired: true, + body: saveDraftBodySchema, + response: { + 200: saveDraftResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid, draft } = this.bodyParams; + + if (draft.length > (settings.get('Message_MaxAllowedSize') ?? 0)) { + return API.v1.failure('error-message-size-exceeded'); + } + + const subscription = await Subscriptions.updateDraftByRoomIdAndUserId(rid, this.userId, draft || undefined); + if (!subscription) { + throw new Meteor.Error('error-invalid-subscription', 'Invalid subscription'); + } + + void notifyOnSubscriptionChanged(subscription); + + return API.v1.success(); + }, +); + API.v1.post( 'rooms.cleanHistory', { @@ -1432,7 +1482,7 @@ export const roomEndpoints = API.v1 projection: adminFields, }); - const [rooms, total] = await Promise.all([cursor.toArray(), totalCount]); + const [rooms, total] = await Promise.all([cursor.map(stripABACManagedFieldsForAdmin).toArray(), totalCount]); return API.v1.success({ rooms, @@ -1630,7 +1680,8 @@ export const roomEndpoints = API.v1 ); type RoomEndpoints = ExtractRoutesFromAPI & ExtractRoutesFromAPI & - ExtractRoutesFromAPI; + ExtractRoutesFromAPI & + ExtractRoutesFromAPI; declare module '@rocket.chat/rest-typings' { // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 2873bc36fc47b..46ec48b96833b 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -59,7 +59,7 @@ import { } from '../../../lib/server/functions/checkUsernameAvailability'; import { deleteUser } from '../../../lib/server/functions/deleteUser'; import { getAvatarSuggestionForUser } from '../../../lib/server/functions/getAvatarSuggestionForUser'; -import { getFullUserDataByIdOrUsernameOrImportIdOrEmail, defaultFields, fullFields } from '../../../lib/server/functions/getFullUserData'; +import { getFullUserDataByUniqueSearchTerm, defaultFields, fullFields } from '../../../lib/server/functions/getFullUserData'; import { generateUsernameSuggestion } from '../../../lib/server/functions/getUsernameSuggestion'; import { saveCustomFields } from '../../../lib/server/functions/saveCustomFields'; import { saveCustomFieldsWithoutValidation } from '../../../lib/server/functions/saveCustomFieldsWithoutValidation'; @@ -587,22 +587,26 @@ API.v1.get( }, }, async function action() { - const searchTerms: [string, 'id' | 'username' | 'importId' | 'email'] | false = + const searchTerms: [string, 'id' | 'username' | 'importId' | 'email' | 'freeSwitchExtension'] | false = ('userId' in this.queryParams && !!this.queryParams.userId && [this.queryParams.userId, 'id']) || ('username' in this.queryParams && !!this.queryParams.username && [this.queryParams.username, 'username']) || ('importId' in this.queryParams && !!this.queryParams.importId && [this.queryParams.importId, 'importId']) || - ('email' in this.queryParams && !!this.queryParams.email && [this.queryParams.email, 'email']); + ('email' in this.queryParams && !!this.queryParams.email && [this.queryParams.email, 'email']) || + ('freeSwitchExtension' in this.queryParams && + !!this.queryParams.freeSwitchExtension && [this.queryParams.freeSwitchExtension, 'freeSwitchExtension']); if (!searchTerms) { return API.v1.failure('Invalid search query.'); } - const user = await getFullUserDataByIdOrUsernameOrImportIdOrEmail(this.userId, ...searchTerms); + const user = await getFullUserDataByUniqueSearchTerm(this.userId, ...searchTerms); if (!user) { return API.v1.failure('User not found.'); } + const myself = user._id === this.userId; + if (this.queryParams.includeUserRooms === 'true' && (myself || (await hasPermissionAsync(this.userId, 'view-other-user-channels')))) { return API.v1.success({ user: { diff --git a/apps/meteor/app/apps/server/bridges/activation.ts b/apps/meteor/app/apps/server/bridges/activation.ts index 399dfd285e65e..b22aeac49b220 100644 --- a/apps/meteor/app/apps/server/bridges/activation.ts +++ b/apps/meteor/app/apps/server/bridges/activation.ts @@ -1,11 +1,10 @@ import type { IAppServerOrchestrator, AppStatus } from '@rocket.chat/apps'; -import type { ProxiedApp } from '@rocket.chat/apps-engine/server/ProxiedApp'; -import { AppActivationBridge as ActivationBridge } from '@rocket.chat/apps-engine/server/bridges/AppActivationBridge'; +import type { ProxiedApp } from '@rocket.chat/apps/dist/server/ProxiedApp'; +import { AppActivationBridge as ActivationBridge } from '@rocket.chat/apps/dist/server/bridges/AppActivationBridge'; import { UserStatus } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; export class AppActivationBridge extends ActivationBridge { - // eslint-disable-next-line no-empty-function constructor(private readonly orch: IAppServerOrchestrator) { super(); } diff --git a/apps/meteor/app/apps/server/bridges/api.ts b/apps/meteor/app/apps/server/bridges/api.ts index f115897c82d82..4e117855615ed 100644 --- a/apps/meteor/app/apps/server/bridges/api.ts +++ b/apps/meteor/app/apps/server/bridges/api.ts @@ -1,8 +1,8 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { ApiBridge } from '@rocket.chat/apps/dist/server/bridges/ApiBridge'; +import type { AppApi } from '@rocket.chat/apps/dist/server/managers/AppApi'; import type { RequestMethod } from '@rocket.chat/apps-engine/definition/accessors'; import type { IApiRequest, IApiEndpoint, IApi } from '@rocket.chat/apps-engine/definition/api'; -import { ApiBridge } from '@rocket.chat/apps-engine/server/bridges/ApiBridge'; -import type { AppApi } from '@rocket.chat/apps-engine/server/managers/AppApi'; import type { Response, Request, IRouter, RequestHandler } from 'express'; import express from 'express'; import { Meteor } from 'meteor/meteor'; diff --git a/apps/meteor/app/apps/server/bridges/bridges.js b/apps/meteor/app/apps/server/bridges/bridges.js index 3b49cd91394e9..4205c2afa91bc 100644 --- a/apps/meteor/app/apps/server/bridges/bridges.js +++ b/apps/meteor/app/apps/server/bridges/bridges.js @@ -1,4 +1,4 @@ -import { AppBridges } from '@rocket.chat/apps-engine/server/bridges'; +import { AppBridges } from '@rocket.chat/apps/dist/server/bridges/AppBridges'; import { AppActivationBridge } from './activation'; import { AppApisBridge } from './api'; diff --git a/apps/meteor/app/apps/server/bridges/cloud.ts b/apps/meteor/app/apps/server/bridges/cloud.ts index 0f908ccfe0a39..83d9f98311c1b 100644 --- a/apps/meteor/app/apps/server/bridges/cloud.ts +++ b/apps/meteor/app/apps/server/bridges/cloud.ts @@ -1,6 +1,6 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { CloudWorkspaceBridge } from '@rocket.chat/apps/dist/server/bridges/CloudWorkspaceBridge'; import type { IWorkspaceToken } from '@rocket.chat/apps-engine/definition/cloud/IWorkspaceToken'; -import { CloudWorkspaceBridge } from '@rocket.chat/apps-engine/server/bridges/CloudWorkspaceBridge'; import { getWorkspaceAccessTokenWithScope } from '../../../cloud/server'; diff --git a/apps/meteor/app/apps/server/bridges/commands.ts b/apps/meteor/app/apps/server/bridges/commands.ts index 0f378a4a7abc4..36eaaed5f047d 100644 --- a/apps/meteor/app/apps/server/bridges/commands.ts +++ b/apps/meteor/app/apps/server/bridges/commands.ts @@ -1,7 +1,7 @@ import type { IAppServerOrchestrator, IAppsRoom, IAppsUser } from '@rocket.chat/apps'; +import { CommandBridge } from '@rocket.chat/apps/dist/server/bridges/CommandBridge'; import type { ISlashCommand, ISlashCommandPreview, ISlashCommandPreviewItem } from '@rocket.chat/apps-engine/definition/slashcommands'; 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 { Utilities } from '../../../../ee/lib/misc/Utilities'; diff --git a/apps/meteor/app/apps/server/bridges/contact.ts b/apps/meteor/app/apps/server/bridges/contact.ts index 802b0bb3ec16b..fa00a839c6a4e 100644 --- a/apps/meteor/app/apps/server/bridges/contact.ts +++ b/apps/meteor/app/apps/server/bridges/contact.ts @@ -1,6 +1,6 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { ContactBridge } from '@rocket.chat/apps/dist/server/bridges/ContactBridge'; import type { ILivechatContact } from '@rocket.chat/apps-engine/definition/livechat'; -import { ContactBridge } from '@rocket.chat/apps-engine/server/bridges'; import { addContactEmail } from '../../../livechat/server/lib/contacts/addContactEmail'; import { verifyContactChannel } from '../../../livechat/server/lib/contacts/verifyContactChannel'; diff --git a/apps/meteor/app/apps/server/bridges/details.ts b/apps/meteor/app/apps/server/bridges/details.ts index 3930cdd451cc7..03f25d9f6d05b 100644 --- a/apps/meteor/app/apps/server/bridges/details.ts +++ b/apps/meteor/app/apps/server/bridges/details.ts @@ -1,6 +1,6 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { AppDetailChangesBridge as DetailChangesBridge } from '@rocket.chat/apps/dist/server/bridges/AppDetailChangesBridge'; import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; -import { AppDetailChangesBridge as DetailChangesBridge } from '@rocket.chat/apps-engine/server/bridges/AppDetailChangesBridge'; export class AppDetailChangesBridge extends DetailChangesBridge { constructor(private readonly orch: IAppServerOrchestrator) { diff --git a/apps/meteor/app/apps/server/bridges/email.ts b/apps/meteor/app/apps/server/bridges/email.ts index 6d75a45044832..8f984b7bb6ce2 100644 --- a/apps/meteor/app/apps/server/bridges/email.ts +++ b/apps/meteor/app/apps/server/bridges/email.ts @@ -1,6 +1,6 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { EmailBridge } from '@rocket.chat/apps/dist/server/bridges/EmailBridge'; import type { IEmail } from '@rocket.chat/apps-engine/definition/email'; -import { EmailBridge } from '@rocket.chat/apps-engine/server/bridges'; import * as Mailer from '../../../mailer/server/api'; import { settings } from '../../../settings/server'; diff --git a/apps/meteor/app/apps/server/bridges/environmental.ts b/apps/meteor/app/apps/server/bridges/environmental.ts index 705a27186dee7..c79ffbe9f6cf5 100644 --- a/apps/meteor/app/apps/server/bridges/environmental.ts +++ b/apps/meteor/app/apps/server/bridges/environmental.ts @@ -1,5 +1,5 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; -import { EnvironmentalVariableBridge } from '@rocket.chat/apps-engine/server/bridges/EnvironmentalVariableBridge'; +import { EnvironmentalVariableBridge } from '@rocket.chat/apps/dist/server/bridges/EnvironmentalVariableBridge'; export class AppEnvironmentalVariableBridge extends EnvironmentalVariableBridge { allowed: Array; diff --git a/apps/meteor/app/apps/server/bridges/experimental.ts b/apps/meteor/app/apps/server/bridges/experimental.ts index d505a54d5665d..fea16f9cb8a82 100644 --- a/apps/meteor/app/apps/server/bridges/experimental.ts +++ b/apps/meteor/app/apps/server/bridges/experimental.ts @@ -1,5 +1,5 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; -import { ExperimentalBridge } from '@rocket.chat/apps-engine/server/bridges'; +import { ExperimentalBridge } from '@rocket.chat/apps/dist/server/bridges/ExperimentalBridge'; export class AppExperimentalBridge extends ExperimentalBridge { constructor(protected readonly orch: IAppServerOrchestrator) { diff --git a/apps/meteor/app/apps/server/bridges/http.ts b/apps/meteor/app/apps/server/bridges/http.ts index cd297009c4afc..f3bb6c502a426 100644 --- a/apps/meteor/app/apps/server/bridges/http.ts +++ b/apps/meteor/app/apps/server/bridges/http.ts @@ -1,7 +1,7 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import type { IHttpBridgeRequestInfo } from '@rocket.chat/apps/dist/server/bridges/HttpBridge'; +import { HttpBridge } from '@rocket.chat/apps/dist/server/bridges/HttpBridge'; 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, type ExtendedFetchOptions } from '@rocket.chat/server-fetch'; import { censorUrl } from '@rocket.chat/tools'; diff --git a/apps/meteor/app/apps/server/bridges/index.ts b/apps/meteor/app/apps/server/bridges/index.ts index 7705ea45498f0..7ac397768f74d 100644 --- a/apps/meteor/app/apps/server/bridges/index.ts +++ b/apps/meteor/app/apps/server/bridges/index.ts @@ -1,3 +1 @@ -import { RealAppBridges } from './bridges'; - -export { RealAppBridges }; +export * from './bridges'; diff --git a/apps/meteor/app/apps/server/bridges/internal.ts b/apps/meteor/app/apps/server/bridges/internal.ts index 22e44a2bb2d0b..8f5ab68e68787 100644 --- a/apps/meteor/app/apps/server/bridges/internal.ts +++ b/apps/meteor/app/apps/server/bridges/internal.ts @@ -1,5 +1,5 @@ import type { IAppServerOrchestrator, IAppsSetting } from '@rocket.chat/apps'; -import { InternalBridge } from '@rocket.chat/apps-engine/server/bridges/InternalBridge'; +import { InternalBridge } from '@rocket.chat/apps/dist/server/bridges/InternalBridge'; import type { ISetting, ISubscription } from '@rocket.chat/core-typings'; import { Settings, Subscriptions } from '@rocket.chat/models'; import { isTruthy } from '@rocket.chat/tools'; diff --git a/apps/meteor/app/apps/server/bridges/internalFederation.ts b/apps/meteor/app/apps/server/bridges/internalFederation.ts index 9bf3b34439ce5..1b221844488e8 100644 --- a/apps/meteor/app/apps/server/bridges/internalFederation.ts +++ b/apps/meteor/app/apps/server/bridges/internalFederation.ts @@ -1,4 +1,4 @@ -import type { IInternalFederationBridge } from '@rocket.chat/apps-engine/server/bridges/IInternalFederationBridge'; +import type { IInternalFederationBridge } from '@rocket.chat/apps/dist/server/bridges/IInternalFederationBridge'; import { FederationKeys } from '@rocket.chat/models'; export class AppInternalFederationBridge implements IInternalFederationBridge { diff --git a/apps/meteor/app/apps/server/bridges/listeners.ts b/apps/meteor/app/apps/server/bridges/listeners.ts index 6c3b97e81f0a4..be8ccf3339205 100644 --- a/apps/meteor/app/apps/server/bridges/listeners.ts +++ b/apps/meteor/app/apps/server/bridges/listeners.ts @@ -1,6 +1,6 @@ -import crypto from 'crypto'; -import fs from 'fs'; -import path from 'path'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; import type { IAppServerOrchestrator, IAppsRoom, IAppsLivechatRoom, IAppsMessage } from '@rocket.chat/apps'; import type { IPreEmailSentContext } from '@rocket.chat/apps-engine/definition/email'; diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index c73bbda1d3580..81a6d8551f82e 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -1,4 +1,5 @@ -import type { IAppServerOrchestrator, IAppsLivechatMessage, IAppsMessage } from '@rocket.chat/apps'; +import type { IAppServerOrchestrator, IAppsLivechatMessage } from '@rocket.chat/apps'; +import { LivechatBridge } from '@rocket.chat/apps/dist/server/bridges/LivechatBridge'; import type { IExtraRoomParams } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator'; import type { IVisitorExternalIdentifier, @@ -10,8 +11,7 @@ import type { } from '@rocket.chat/apps-engine/definition/livechat'; import type { IMessage as IAppsEngineMessage } from '@rocket.chat/apps-engine/definition/messages'; import type { IUser } from '@rocket.chat/apps-engine/definition/users'; -import { LivechatBridge } from '@rocket.chat/apps-engine/server/bridges/LivechatBridge'; -import type { ILivechatDepartment, IOmnichannelRoom, SelectedAgent, IMessage, ILivechatVisitor } from '@rocket.chat/core-typings'; +import type { ILivechatDepartment, IOmnichannelRoom, SelectedAgent, ILivechatVisitor } from '@rocket.chat/core-typings'; import { OmnichannelSourceType } from '@rocket.chat/core-typings'; import { LivechatVisitors, LivechatRooms, LivechatDepartment, Users } from '@rocket.chat/models'; import { registerGuest } from '@rocket.chat/omni-core'; @@ -29,12 +29,6 @@ import { online } from '../../../livechat/server/lib/service-status'; import { transfer } from '../../../livechat/server/lib/transfer'; import { settings } from '../../../settings/server'; -declare module '@rocket.chat/apps/dist/converters/IAppMessagesConverter' { - export interface IAppMessagesConverter { - convertMessage(message: IMessage, cacheObj?: object): Promise; - } -} - declare module '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator' { interface IExtraRoomParams { customFields?: Record; @@ -65,7 +59,7 @@ export class AppLivechatBridge extends LivechatBridge { // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. const guest = this.orch.getConverters().get('visitors').convertAppVisitor(message.visitor); - const appMessage = (await this.orch.getConverters().get('messages').convertAppMessage(message)) as IMessage | undefined; + const appMessage = await this.orch.getConverters().get('messages').convertAppMessage(message); const livechatMessage = appMessage as ILivechatMessage | undefined; const msg = await sendMessage({ @@ -129,13 +123,12 @@ export class AppLivechatBridge extends LivechatBridge { type: OmnichannelSourceType.APP, id: appId, alias: this.orch.getManager()?.getOneById(appId)?.getName(), - ...(source && - source.type === 'app' && { - sidebarIcon: source.sidebarIcon, - defaultIcon: source.defaultIcon, - label: source.label, - destination: source.destination, - }), + ...(source?.type === 'app' && { + sidebarIcon: source.sidebarIcon, + defaultIcon: source.defaultIcon, + label: source.label, + destination: source.destination, + }), }, }, agent: agentRoom, diff --git a/apps/meteor/app/apps/server/bridges/messages.ts b/apps/meteor/app/apps/server/bridges/messages.ts index 16366626c07c8..b5be15ae7ca92 100644 --- a/apps/meteor/app/apps/server/bridges/messages.ts +++ b/apps/meteor/app/apps/server/bridges/messages.ts @@ -1,8 +1,8 @@ import type { IAppServerOrchestrator, IAppsMessage, IAppsUser } from '@rocket.chat/apps'; +import type { ITypingDescriptor } from '@rocket.chat/apps/dist/server/bridges/MessageBridge'; +import { MessageBridge } from '@rocket.chat/apps/dist/server/bridges/MessageBridge'; import type { Reaction } from '@rocket.chat/apps-engine/definition/messages'; import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; -import type { ITypingDescriptor } from '@rocket.chat/apps-engine/server/bridges/MessageBridge'; -import { MessageBridge } from '@rocket.chat/apps-engine/server/bridges/MessageBridge'; import { api } from '@rocket.chat/core-services'; import type { IMessage } from '@rocket.chat/core-typings'; import { Users, Subscriptions } from '@rocket.chat/models'; diff --git a/apps/meteor/app/apps/server/bridges/moderation.ts b/apps/meteor/app/apps/server/bridges/moderation.ts index 0f1e56bbdec35..42d521b102f83 100644 --- a/apps/meteor/app/apps/server/bridges/moderation.ts +++ b/apps/meteor/app/apps/server/bridges/moderation.ts @@ -1,7 +1,7 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { ModerationBridge } from '@rocket.chat/apps/dist/server/bridges/ModerationBridge'; import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; import type { IUser } from '@rocket.chat/apps-engine/definition/users'; -import { ModerationBridge } from '@rocket.chat/apps-engine/server/bridges/ModerationBridge'; import { ModerationReports } from '@rocket.chat/models'; import { reportMessage } from '../../../../server/lib/moderation/reportMessage'; diff --git a/apps/meteor/app/apps/server/bridges/oauthApps.ts b/apps/meteor/app/apps/server/bridges/oauthApps.ts index bfd72917a367c..d16087cfb5c61 100644 --- a/apps/meteor/app/apps/server/bridges/oauthApps.ts +++ b/apps/meteor/app/apps/server/bridges/oauthApps.ts @@ -1,8 +1,8 @@ -import { randomUUID } from 'crypto'; +import { randomUUID } from 'node:crypto'; import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { OAuthAppsBridge } from '@rocket.chat/apps/dist/server/bridges/OAuthAppsBridge'; 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'; diff --git a/apps/meteor/app/apps/server/bridges/outboundCommunication.ts b/apps/meteor/app/apps/server/bridges/outboundCommunication.ts index 146ae18b38ead..3ba7218a601c0 100644 --- a/apps/meteor/app/apps/server/bridges/outboundCommunication.ts +++ b/apps/meteor/app/apps/server/bridges/outboundCommunication.ts @@ -1,10 +1,10 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { OutboundMessageBridge } from '@rocket.chat/apps/dist/server/bridges/OutboundMessagesBridge'; import type { IOutboundEmailMessageProvider, IOutboundMessageProviders, IOutboundPhoneMessageProvider, } from '@rocket.chat/apps-engine/definition/outboundCommunication'; -import { OutboundMessageBridge } from '@rocket.chat/apps-engine/server/bridges'; import { getOutboundService } from '../../../livechat/server/lib/outboundcommunication'; diff --git a/apps/meteor/app/apps/server/bridges/persistence.ts b/apps/meteor/app/apps/server/bridges/persistence.ts index e1dedd270ac6e..ce85b801a9808 100644 --- a/apps/meteor/app/apps/server/bridges/persistence.ts +++ b/apps/meteor/app/apps/server/bridges/persistence.ts @@ -1,6 +1,6 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { PersistenceBridge } from '@rocket.chat/apps/dist/server/bridges/PersistenceBridge'; import type { RocketChatAssociationRecord } from '@rocket.chat/apps-engine/definition/metadata'; -import { PersistenceBridge } from '@rocket.chat/apps-engine/server/bridges/PersistenceBridge'; import type { InsertOneResult } from 'mongodb'; export class AppPersistenceBridge extends PersistenceBridge { diff --git a/apps/meteor/app/apps/server/bridges/roles.ts b/apps/meteor/app/apps/server/bridges/roles.ts index aa0fcdc7b80b7..7f7daddd328de 100644 --- a/apps/meteor/app/apps/server/bridges/roles.ts +++ b/apps/meteor/app/apps/server/bridges/roles.ts @@ -1,5 +1,5 @@ import type { IAppServerOrchestrator, IAppsRole } from '@rocket.chat/apps'; -import { RoleBridge } from '@rocket.chat/apps-engine/server/bridges'; +import { RoleBridge } from '@rocket.chat/apps/dist/server/bridges/RoleBridge'; import type { IRole } from '@rocket.chat/core-typings'; import { Roles } from '@rocket.chat/models'; diff --git a/apps/meteor/app/apps/server/bridges/rooms.ts b/apps/meteor/app/apps/server/bridges/rooms.ts index 473614edfe25c..11b2b405fb2e6 100644 --- a/apps/meteor/app/apps/server/bridges/rooms.ts +++ b/apps/meteor/app/apps/server/bridges/rooms.ts @@ -1,10 +1,10 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import type { GetMessagesOptions, GetRoomsFilters, GetRoomsOptions } from '@rocket.chat/apps/dist/server/bridges/RoomBridge'; +import { RoomBridge } from '@rocket.chat/apps/dist/server/bridges/RoomBridge'; import type { IMessage, IMessageRaw } from '@rocket.chat/apps-engine/definition/messages'; import type { IRoom, IRoomRaw } from '@rocket.chat/apps-engine/definition/rooms'; import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; import type { IUser } from '@rocket.chat/apps-engine/definition/users'; -import type { GetMessagesOptions, GetRoomsFilters, GetRoomsOptions } from '@rocket.chat/apps-engine/server/bridges/RoomBridge'; -import { RoomBridge } from '@rocket.chat/apps-engine/server/bridges/RoomBridge'; import type { ISubscription, IUser as ICoreUser, IRoom as ICoreRoom, IMessage as ICoreMessage } from '@rocket.chat/core-typings'; import { Subscriptions, Users, Rooms, Messages } from '@rocket.chat/models'; import type { FindOptions, Sort } from 'mongodb'; diff --git a/apps/meteor/app/apps/server/bridges/router.ts b/apps/meteor/app/apps/server/bridges/router.ts index 993162b63c16f..3578b311f1c73 100644 --- a/apps/meteor/app/apps/server/bridges/router.ts +++ b/apps/meteor/app/apps/server/bridges/router.ts @@ -5,5 +5,5 @@ export const apiServer = express(); apiServer.disable('x-powered-by'); -apiServer.use('/api/apps/private/:appId/:hash', bodyParser.urlencoded(), bodyParser.json()); -apiServer.use('/api/apps/public/:appId', bodyParser.urlencoded(), bodyParser.json()); +apiServer.use('/api/apps/private/:appId/:hash', bodyParser.urlencoded({ extended: true }), bodyParser.json()); +apiServer.use('/api/apps/public/:appId', bodyParser.urlencoded({ extended: true }), bodyParser.json()); diff --git a/apps/meteor/app/apps/server/bridges/scheduler.ts b/apps/meteor/app/apps/server/bridges/scheduler.ts index b08d49182c9bc..8f044cf1832f9 100644 --- a/apps/meteor/app/apps/server/bridges/scheduler.ts +++ b/apps/meteor/app/apps/server/bridges/scheduler.ts @@ -1,9 +1,9 @@ import type { Job } from '@rocket.chat/agenda'; import { Agenda } from '@rocket.chat/agenda'; import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { SchedulerBridge } from '@rocket.chat/apps/dist/server/bridges/SchedulerBridge'; import type { IProcessor, IOnetimeSchedule, IRecurringSchedule, IJobContext } from '@rocket.chat/apps-engine/definition/scheduler'; import { StartupType } from '@rocket.chat/apps-engine/definition/scheduler'; -import { SchedulerBridge } from '@rocket.chat/apps-engine/server/bridges/SchedulerBridge'; import { ObjectId } from 'bson'; import { MongoInternals } from 'meteor/mongo'; @@ -90,7 +90,7 @@ export class AppSchedulerBridge extends SchedulerBridge { }); if (runAfterRegister.length) { - return Promise.all(runAfterRegister) as Promise>; + return Promise.all(runAfterRegister); } } diff --git a/apps/meteor/app/apps/server/bridges/settings.ts b/apps/meteor/app/apps/server/bridges/settings.ts index 7c569a3f12fd7..145f81fc129ae 100644 --- a/apps/meteor/app/apps/server/bridges/settings.ts +++ b/apps/meteor/app/apps/server/bridges/settings.ts @@ -1,7 +1,7 @@ import { Apps, type IAppServerOrchestrator } from '@rocket.chat/apps'; +import { ServerSettingBridge } from '@rocket.chat/apps/dist/server/bridges/ServerSettingBridge'; import type { IReadSettingPermission } from '@rocket.chat/apps-engine/definition/permissions/IPermission'; import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; -import { ServerSettingBridge } from '@rocket.chat/apps-engine/server/bridges/ServerSettingBridge'; import { Settings } from '@rocket.chat/models'; import { updateAuditedByApp } from '../../../../server/settings/lib/auditedSettingUpdates'; diff --git a/apps/meteor/app/apps/server/bridges/thread.ts b/apps/meteor/app/apps/server/bridges/thread.ts index 099fe9184e004..7857005eaea23 100644 --- a/apps/meteor/app/apps/server/bridges/thread.ts +++ b/apps/meteor/app/apps/server/bridges/thread.ts @@ -1,6 +1,6 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { ThreadBridge } from '@rocket.chat/apps/dist/server/bridges/ThreadBridge'; import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; -import { ThreadBridge } from '@rocket.chat/apps-engine/server/bridges/ThreadBridge'; export class AppThreadBridge extends ThreadBridge { constructor(private readonly orch: IAppServerOrchestrator) { diff --git a/apps/meteor/app/apps/server/bridges/uiInteraction.ts b/apps/meteor/app/apps/server/bridges/uiInteraction.ts index fc68e4e30d3ff..69172f9cf5d59 100644 --- a/apps/meteor/app/apps/server/bridges/uiInteraction.ts +++ b/apps/meteor/app/apps/server/bridges/uiInteraction.ts @@ -1,7 +1,7 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { UiInteractionBridge as AppsEngineUiInteractionBridge } from '@rocket.chat/apps/dist/server/bridges/UiInteractionBridge'; import type { IUIKitInteraction } from '@rocket.chat/apps-engine/definition/uikit'; import type { IUser } from '@rocket.chat/apps-engine/definition/users'; -import { UiInteractionBridge as AppsEngineUiInteractionBridge } from '@rocket.chat/apps-engine/server/bridges/UiInteractionBridge'; import { api } from '@rocket.chat/core-services'; import type * as UiKit from '@rocket.chat/ui-kit'; diff --git a/apps/meteor/app/apps/server/bridges/uploads.ts b/apps/meteor/app/apps/server/bridges/uploads.ts index b9d0ff67de58e..929e70743c557 100644 --- a/apps/meteor/app/apps/server/bridges/uploads.ts +++ b/apps/meteor/app/apps/server/bridges/uploads.ts @@ -1,7 +1,7 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { UploadBridge } from '@rocket.chat/apps/dist/server/bridges/UploadBridge'; import type { IUpload } from '@rocket.chat/apps-engine/definition/uploads'; import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails'; -import { UploadBridge } from '@rocket.chat/apps-engine/server/bridges/UploadBridge'; import { determineFileType } from '../../../../ee/lib/misc/determineFileType'; import { FileUpload } from '../../../file-upload/server'; diff --git a/apps/meteor/app/apps/server/bridges/users.ts b/apps/meteor/app/apps/server/bridges/users.ts index 43cd7f9ea89e1..816416fa0002c 100644 --- a/apps/meteor/app/apps/server/bridges/users.ts +++ b/apps/meteor/app/apps/server/bridges/users.ts @@ -1,6 +1,6 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { UserBridge } from '@rocket.chat/apps/dist/server/bridges/UserBridge'; import type { IUserCreationOptions, IUser, UserType } from '@rocket.chat/apps-engine/definition/users'; -import { UserBridge } from '@rocket.chat/apps-engine/server/bridges/UserBridge'; import { Presence } from '@rocket.chat/core-services'; import type { UserStatus } from '@rocket.chat/core-typings'; import { Subscriptions, Users } from '@rocket.chat/models'; @@ -41,7 +41,13 @@ export class AppUserBridge extends UserBridge { return; } - const user = await Users.findOneByAppId(appId, {}); + const user = await Users.findOneByAppId(appId); + + return this.orch.getConverters()?.get('users').convertToApp(user); + } + + protected async getBySipExtension(extension: string, _appId: string): Promise { + const user = await Users.findOneByFreeSwitchExtension(extension); return this.orch.getConverters()?.get('users').convertToApp(user); } diff --git a/apps/meteor/app/apps/server/bridges/videoConferences.ts b/apps/meteor/app/apps/server/bridges/videoConferences.ts index 8986c15a3c6dd..56f70e3e416e4 100644 --- a/apps/meteor/app/apps/server/bridges/videoConferences.ts +++ b/apps/meteor/app/apps/server/bridges/videoConferences.ts @@ -1,7 +1,7 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { VideoConferenceBridge } from '@rocket.chat/apps/dist/server/bridges/VideoConferenceBridge'; import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders'; import type { AppVideoConference, VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences'; -import { VideoConferenceBridge } from '@rocket.chat/apps-engine/server/bridges/VideoConferenceBridge'; import { VideoConf } from '@rocket.chat/core-services'; import { videoConfProviders } from '../../../../server/lib/videoConfProviders'; diff --git a/apps/meteor/app/apps/server/converters/rooms.js b/apps/meteor/app/apps/server/converters/rooms.js index c81792a0bb390..2c699399f1b5e 100644 --- a/apps/meteor/app/apps/server/converters/rooms.js +++ b/apps/meteor/app/apps/server/converters/rooms.js @@ -1,3 +1,4 @@ +import { secureFieldsMapper } from '@rocket.chat/apps/dist/lib/SecureFields'; import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; import { LivechatVisitors, Rooms, LivechatDepartment, Users, LivechatContacts } from '@rocket.chat/models'; @@ -435,6 +436,22 @@ export class AppRoomsConverter { return this.orch.getConverters().get('rooms').convertById(prid); }, + ...secureFieldsMapper((room) => { + if (!room.abacAttributes) { + return undefined; + } + + const value = [ + { + permission: 'abac.read', + name: 'abacAttributes', + value: room.abacAttributes, + }, + ]; + + delete room.abacAttributes; + return value; + }), }; return transformMappedData(originalRoom, map); diff --git a/apps/meteor/app/apps/server/converters/users.js b/apps/meteor/app/apps/server/converters/users.js index fc560185fc8d5..7ce44b28f8355 100644 --- a/apps/meteor/app/apps/server/converters/users.js +++ b/apps/meteor/app/apps/server/converters/users.js @@ -45,6 +45,7 @@ export class AppUsersConverter { lastLoginAt: user.lastLogin, appId: user.appId, customFields: user.customFields, + sipExtension: user.freeSwitchExtension, settings: { preferences: { ...(user?.settings?.preferences?.language && { language: user.settings.preferences.language }), @@ -74,6 +75,7 @@ export class AppUsersConverter { _updatedAt: user.updatedAt, lastLogin: user.lastLoginAt, appId: user.appId, + freeSwitchExtension: user.sipExtension, }); } diff --git a/apps/meteor/app/assets/server/assets.ts b/apps/meteor/app/assets/server/assets.ts index 9266f21cc0218..c2008c6b8cbe6 100644 --- a/apps/meteor/app/assets/server/assets.ts +++ b/apps/meteor/app/assets/server/assets.ts @@ -1,5 +1,5 @@ -import crypto from 'crypto'; -import type { ServerResponse, IncomingMessage } from 'http'; +import crypto from 'node:crypto'; +import type { ServerResponse, IncomingMessage } from 'node:http'; import type { IRocketChatAssets, IRocketChatAsset, ISetting } from '@rocket.chat/core-typings'; import { Settings } from '@rocket.chat/models'; diff --git a/apps/meteor/app/authorization/client/hasPermission.ts b/apps/meteor/app/authorization/client/hasPermission.ts index dd48ce48216c2..0a89b1f9403aa 100644 --- a/apps/meteor/app/authorization/client/hasPermission.ts +++ b/apps/meteor/app/authorization/client/hasPermission.ts @@ -1,81 +1,3 @@ -import type { IUser, IPermission } from '@rocket.chat/core-typings'; +import { liveAuthorizationFunctions } from './liveAuthorizationFunctions'; -import { hasRole } from './hasRole'; -import { PermissionsCachedStore } from '../../../client/cachedStores'; -import { watchUserId } from '../../../client/meteor/user'; -import { watch } from '../../../client/meteor/watch'; -import { Permissions, Users } from '../../../client/stores'; -import { AuthorizationUtils } from '../lib/AuthorizationUtils'; - -const createPermissionValidator = - (quantifier: (predicate: (permissionId: IPermission['_id']) => boolean) => boolean) => - (permissionIds: IPermission['_id'][], scope: string | undefined, userId: IUser['_id'], scopedRoles?: IPermission['_id'][]): boolean => { - const userRoles = watch(Users.use, (state) => state.get(userId)?.roles); - - const checkEachPermission = quantifier.bind(permissionIds); - - return checkEachPermission((permissionId) => { - if (userRoles) { - if (AuthorizationUtils.isPermissionRestrictedForRoleList(permissionId, userRoles)) { - return false; - } - } - - const permission = watch(Permissions.use, (state) => state.get(permissionId)); - const roles = permission?.roles ?? []; - - return roles.some((roleId) => { - if (scopedRoles?.includes(roleId)) { - return true; - } - - return hasRole(userId, roleId, scope); - }); - }); - }; - -const atLeastOne = createPermissionValidator(Array.prototype.some); - -const all = createPermissionValidator(Array.prototype.every); - -const validatePermissions = ( - permissions: IPermission['_id'] | IPermission['_id'][], - scope: string | undefined, - predicate: ( - permissionIds: IPermission['_id'][], - scope: string | undefined, - userId: IUser['_id'], - scopedRoles?: IPermission['_id'][], - ) => boolean, - userId?: IUser['_id'], - scopedRoles?: IPermission['_id'][], -): boolean => { - userId = userId ?? watchUserId() ?? undefined; - - if (!userId) { - return false; - } - - if (!watch(PermissionsCachedStore.useReady, (state) => state)) { - return false; - } - - return predicate(([] as IPermission['_id'][]).concat(permissions), scope, userId, scopedRoles); -}; - -export const hasAllPermission = ( - permissions: IPermission['_id'] | IPermission['_id'][], - scope?: string, - scopedRoles?: IPermission['_id'][], -): boolean => validatePermissions(permissions, scope, all, undefined, scopedRoles); - -export const hasAtLeastOnePermission = (permissions: IPermission['_id'] | IPermission['_id'][], scope?: string): boolean => - validatePermissions(permissions, scope, atLeastOne); - -export const userHasAllPermission = ( - permissions: IPermission['_id'] | IPermission['_id'][], - scope?: string, - userId?: IUser['_id'], -): boolean => validatePermissions(permissions, scope, all, userId); - -export const hasPermission = hasAllPermission; +export const { hasAllPermission, hasAtLeastOnePermission, hasPermission, userHasAllPermission } = liveAuthorizationFunctions; diff --git a/apps/meteor/app/authorization/client/hasRole.ts b/apps/meteor/app/authorization/client/hasRole.ts index a2e1ea5adc229..2c0879d504163 100644 --- a/apps/meteor/app/authorization/client/hasRole.ts +++ b/apps/meteor/app/authorization/client/hasRole.ts @@ -1,21 +1,3 @@ -import type { IUser, IRole, IRoom } from '@rocket.chat/core-typings'; +import { liveAuthorizationFunctions } from './liveAuthorizationFunctions'; -import { watch } from '../../../client/meteor/watch'; -import { Roles, Subscriptions, Users } from '../../../client/stores'; - -export const hasRole = (userId: IUser['_id'], roleId: IRole['_id'], scope?: IRoom['_id']): boolean => { - const roleScope = watch(Roles.use, (state) => state.get(roleId)?.scope ?? 'Users'); - - switch (roleScope) { - case 'Subscriptions': - if (!scope) return false; - - return watch(Subscriptions.use, (state) => state.find((record) => record.rid === scope)?.roles?.includes(roleId) ?? false); - - case 'Users': - return watch(Users.use, (state) => state.get(userId)?.roles?.includes(roleId) ?? false); - - default: - return false; - } -}; +export const { hasRole } = liveAuthorizationFunctions; diff --git a/apps/meteor/app/authorization/client/liveAuthorizationFunctions.ts b/apps/meteor/app/authorization/client/liveAuthorizationFunctions.ts new file mode 100644 index 0000000000000..db1981a884a9f --- /dev/null +++ b/apps/meteor/app/authorization/client/liveAuthorizationFunctions.ts @@ -0,0 +1,25 @@ +import { PermissionsCachedStore } from '../../../client/cachedStores'; +import { userIdStore } from '../../../client/lib/user'; +import { Permissions, Roles, Subscriptions, Users } from '../../../client/stores'; +import type { AuthorizationDeps } from '../lib/createAuthorizationFunctions'; +import { createAuthorizationFunctions } from '../lib/createAuthorizationFunctions'; + +// Bind the pure factory to live zustand store accessors. Each accessor reads +// fresh state on every call, so non-React callers (services, lib code, startup +// scripts) keep their previous "always reflects the current store" contract +// without going through Meteor's Tracker. React consumers should use the +// AuthorizationContext instead, which injects React-reactive snapshots. +const liveDeps: AuthorizationDeps = { + getCurrentUserId: () => userIdStore.getState(), + getUserRoles: (userId) => Users.use.getState().get(userId)?.roles, + getPermission: (permissionId) => Permissions.use.getState().get(permissionId), + getRoleScope: (roleId) => Roles.use.getState().get(roleId)?.scope, + hasSubscriptionRole: (rid, roleId) => + Subscriptions.use + .getState() + .find((s) => s.rid === rid) + ?.roles?.includes(roleId) ?? false, + isReady: () => PermissionsCachedStore.useReady.getState(), +}; + +export const liveAuthorizationFunctions = createAuthorizationFunctions(liveDeps); diff --git a/apps/meteor/app/authorization/lib/createAuthorizationFunctions.ts b/apps/meteor/app/authorization/lib/createAuthorizationFunctions.ts new file mode 100644 index 0000000000000..1b689e176aa1b --- /dev/null +++ b/apps/meteor/app/authorization/lib/createAuthorizationFunctions.ts @@ -0,0 +1,101 @@ +import type { IPermission, IRole, IUser } from '@rocket.chat/core-typings'; + +import { AuthorizationUtils } from './AuthorizationUtils'; + +export type AuthorizationDeps = { + /** The currently logged-in user id, or undefined. */ + getCurrentUserId: () => IUser['_id'] | undefined; + /** The role ids assigned to a given user (Users scope). */ + getUserRoles: (userId: IUser['_id']) => IRole['_id'][] | undefined; + /** Lookup a permission by id. */ + getPermission: (permissionId: IPermission['_id']) => IPermission | undefined; + /** The scope of a role; defaults to 'Users' when the role is unknown. */ + getRoleScope: (roleId: IRole['_id']) => IRole['scope'] | undefined; + /** Whether a subscription scoped to `rid` grants `roleId`. */ + hasSubscriptionRole: (rid: string, roleId: IRole['_id']) => boolean; + /** Whether the permissions cache is hydrated; otherwise checks short-circuit to false. */ + isReady: () => boolean; +}; + +export type AuthorizationFunctions = { + hasRole: (userId: IUser['_id'], roleId: IRole['_id'], scope?: string) => boolean; + hasAllPermission: (permissions: IPermission['_id'] | IPermission['_id'][], scope?: string, scopedRoles?: IRole['_id'][]) => boolean; + hasAtLeastOnePermission: (permissions: IPermission['_id'] | IPermission['_id'][], scope?: string) => boolean; + /** Alias of hasAllPermission, kept for parity with the previous API. */ + hasPermission: (permissions: IPermission['_id'] | IPermission['_id'][], scope?: string, scopedRoles?: IRole['_id'][]) => boolean; + userHasAllPermission: ( + permissions: IPermission['_id'] | IPermission['_id'][], + scope: string | undefined, + userId: IUser['_id'], + ) => boolean; +}; + +/** + * Pure factory for the client-side authorization helpers. All store access is + * threaded through the {@link AuthorizationDeps} accessors, so the returned + * functions are testable in isolation and reusable across any state backend. + */ +export const createAuthorizationFunctions = (deps: AuthorizationDeps): AuthorizationFunctions => { + const hasRole = (userId: IUser['_id'], roleId: IRole['_id'], scope?: string): boolean => { + const roleScope = deps.getRoleScope(roleId) ?? 'Users'; + switch (roleScope) { + case 'Subscriptions': + if (!scope) return false; + return deps.hasSubscriptionRole(scope, roleId); + case 'Users': + return deps.getUserRoles(userId)?.includes(roleId) ?? false; + default: + return false; + } + }; + + const checkPermissions = ( + permissionIds: IPermission['_id'][], + scope: string | undefined, + userId: IUser['_id'], + scopedRoles: IRole['_id'][] | undefined, + quantifier: (this: IPermission['_id'][], predicate: (id: IPermission['_id']) => boolean) => boolean, + ): boolean => { + const userRoles = deps.getUserRoles(userId); + return quantifier.call(permissionIds, (permissionId) => { + if (userRoles && AuthorizationUtils.isPermissionRestrictedForRoleList(permissionId, userRoles)) { + return false; + } + const roles = deps.getPermission(permissionId)?.roles ?? []; + return roles.some((roleId) => { + if (scopedRoles?.includes(roleId)) return true; + return hasRole(userId, roleId, scope); + }); + }); + }; + + const validatePermissions = ( + permissions: IPermission['_id'] | IPermission['_id'][], + scope: string | undefined, + quantifier: (this: IPermission['_id'][], predicate: (id: IPermission['_id']) => boolean) => boolean, + userId: IUser['_id'] | undefined, + scopedRoles?: IRole['_id'][], + ): boolean => { + if (!userId) return false; + if (!deps.isReady()) return false; + const ids = ([] as IPermission['_id'][]).concat(permissions); + return checkPermissions(ids, scope, userId, scopedRoles, quantifier); + }; + + const hasAllPermission: AuthorizationFunctions['hasAllPermission'] = (permissions, scope, scopedRoles) => + validatePermissions(permissions, scope, Array.prototype.every, deps.getCurrentUserId(), scopedRoles); + + const hasAtLeastOnePermission: AuthorizationFunctions['hasAtLeastOnePermission'] = (permissions, scope) => + validatePermissions(permissions, scope, Array.prototype.some, deps.getCurrentUserId()); + + const userHasAllPermission: AuthorizationFunctions['userHasAllPermission'] = (permissions, scope, userId) => + validatePermissions(permissions, scope, Array.prototype.every, userId); + + return { + hasRole, + hasAllPermission, + hasAtLeastOnePermission, + hasPermission: hasAllPermission, + userHasAllPermission, + }; +}; diff --git a/apps/meteor/app/authorization/server/lib/isABACManagedRoom.ts b/apps/meteor/app/authorization/server/lib/isABACManagedRoom.ts new file mode 100644 index 0000000000000..70a5b61f0a375 --- /dev/null +++ b/apps/meteor/app/authorization/server/lib/isABACManagedRoom.ts @@ -0,0 +1,14 @@ +import type { IRoom } from '@rocket.chat/core-typings'; + +import { settings } from '../../../settings/server'; + +export const isABACManagedRoom = (room: Pick): boolean => + room.t === 'p' && settings.get('ABAC_Enabled') && Array.isArray(room.abacAttributes) && room.abacAttributes.length > 0; + +export const stripABACManagedFieldsForAdmin = >(room: T): T => { + if (!isABACManagedRoom(room)) { + return room; + } + const { announcement, topic, description, ...rest } = room as T & Pick; + return rest as T; +}; diff --git a/apps/meteor/app/autotranslate/client/lib/actionButton.ts b/apps/meteor/app/autotranslate/client/lib/actionButton.ts index 742ea4c9a5b79..4b12d7ec0017e 100644 --- a/apps/meteor/app/autotranslate/client/lib/actionButton.ts +++ b/apps/meteor/app/autotranslate/client/lib/actionButton.ts @@ -1,7 +1,3 @@ -import { Meteor } from 'meteor/meteor'; - import { AutoTranslate } from './autotranslate'; -Meteor.startup(() => { - AutoTranslate.init(); -}); +AutoTranslate.init(); diff --git a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts index 1c294c2023137..9ab66e6086efc 100644 --- a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts +++ b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts @@ -1,13 +1,11 @@ import type { IRoom, ISubscription, ISupportedLanguage, ITranslatedMessage, MessageAttachmentDefault } from '@rocket.chat/core-typings'; import { isTranslatedMessageAttachment } from '@rocket.chat/core-typings'; import mem from 'mem'; -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; +import { PermissionsCachedStore } from '../../../../client/cachedStores'; import { settings } from '../../../../client/lib/settings'; -import { getUserId } from '../../../../client/lib/user'; -import { watchUser, watchUserId } from '../../../../client/meteor/user'; -import { Messages, Subscriptions } from '../../../../client/stores'; +import { getUserId, userIdStore } from '../../../../client/lib/user'; +import { Messages, Subscriptions, Users } from '../../../../client/stores'; import { hasTranslationLanguageInAttachments, hasTranslationLanguageInMessage, @@ -18,15 +16,16 @@ import { sdk } from '../../../utils/client/lib/SDKClient'; let userLanguage = 'en'; let username = ''; -Meteor.startup(() => { - Tracker.autorun(() => { - const user = watchUser(); - if (!user) return; - - userLanguage = user.language || 'en'; - username = user.username || ''; - }); -}); +const refreshUserCache = () => { + const uid = userIdStore.getState(); + const user = uid ? Users.use.getState().get(uid) : undefined; + if (!user) return; + userLanguage = user.language || 'en'; + username = user.username || ''; +}; +refreshUserCache(); +userIdStore.subscribe(refreshUserCache); +Users.use.subscribe(refreshUserCache); export const AutoTranslate = { initialized: false, @@ -84,14 +83,7 @@ export const AutoTranslate = { return; } - Tracker.autorun(async (c) => { - const uid = watchUserId(); - if (!settings.watch('AutoTranslate_Enabled') || !uid || !hasPermission('auto-translate')) { - return; - } - - c.stop(); - + const loadProviders = async () => { try { [this.providersMetadata, this.supportedLanguages] = await Promise.all([ sdk.call('autoTranslate.getProviderUiMetadata'), @@ -101,7 +93,25 @@ export const AutoTranslate = { // Avoid unwanted error message on UI when autotranslate is disabled while fetching data console.error((e as Error).message); } - }); + }; + + let loaded = false; + const unsubs: Array<() => void> = []; + const tryLoad = async () => { + if (loaded) return; + if (!settings.peek('AutoTranslate_Enabled') || !userIdStore.getState() || !hasPermission('auto-translate')) { + return; + } + loaded = true; + unsubs.splice(0).forEach((unsubscribe) => unsubscribe()); + await loadProviders(); + }; + + unsubs.push(userIdStore.subscribe(() => void tryLoad())); + unsubs.push(settings.observe('AutoTranslate_Enabled', () => void tryLoad())); + unsubs.push(PermissionsCachedStore.useReady.subscribe(() => void tryLoad())); + + void tryLoad(); Subscriptions.use.subscribe(() => { mem.clear(this.findSubscriptionByRid); diff --git a/apps/meteor/app/channel-settings/server/functions/saveRoomType.ts b/apps/meteor/app/channel-settings/server/functions/saveRoomType.ts index 4600d1d46a805..1f0ad3f29192c 100644 --- a/apps/meteor/app/channel-settings/server/functions/saveRoomType.ts +++ b/apps/meteor/app/channel-settings/server/functions/saveRoomType.ts @@ -48,6 +48,10 @@ export const saveRoomType = async function ( return result; } + if (roomType === 'c' && room.abacAttributes?.length) { + await Rooms.unsetAbacAttributesById(rid); + } + if (result[1]?.modifiedCount) { void notifyOnSubscriptionChangedByRoomId(rid); } diff --git a/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts b/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts index 0a9b282160619..158be6b8b946e 100644 --- a/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts +++ b/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts @@ -9,6 +9,7 @@ import { Meteor } from 'meteor/meteor'; import { RoomSettingsEnum } from '../../../../definition/IRoomTypeConfig'; import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { isABACManagedRoom } from '../../../authorization/server/lib/isABACManagedRoom'; import { setRoomAvatar } from '../../../lib/server/functions/setRoomAvatar'; import { notifyOnRoomChangedById } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; @@ -62,10 +63,6 @@ type RoomSettingsValidators = { const hasRetentionPolicy = (room: IRoom & { retention?: any }): room is IRoomWithRetentionPolicy => 'retention' in room && room.retention !== undefined; -const isAbacManagedRoom = (room: IRoom): boolean => { - return room.t === 'p' && settings.get('ABAC_Enabled') && Array.isArray(room?.abacAttributes) && room.abacAttributes.length > 0; -}; - const isAbacManagedTeam = (team: Partial | null, teamRoom: IRoom): boolean => { return ( team?.type === TeamType.PRIVATE && @@ -75,6 +72,21 @@ const isAbacManagedTeam = (team: Partial | null, teamRoom: IRoom): boolea ); }; +const guardABACManagedField = (room: IRoom, value: string | undefined, current: string | undefined, fieldName: string): void => { + if (!value && !current) { + return; + } + if (value === current) { + return; + } + if (isABACManagedRoom(room)) { + throw new Meteor.Error('error-action-not-allowed', `Editing an ABAC managed room's ${fieldName} is not allowed`, { + method: 'saveRoomSettings', + action: 'Editing_room', + }); + } +}; + const validators: RoomSettingsValidators = { async default({ userId, room, value }) { if (!(await hasPermissionAsync(userId, 'view-room-administration'))) { @@ -83,7 +95,7 @@ const validators: RoomSettingsValidators = { action: 'Viewing_room_administration', }); } - if (isAbacManagedRoom(room) && value) { + if (isABACManagedRoom(room) && value) { throw new Meteor.Error('error-action-not-allowed', 'Setting an ABAC managed room as default is not allowed', { method: 'saveRoomSettings', action: 'Viewing_room_administration', @@ -118,7 +130,7 @@ const validators: RoomSettingsValidators = { }); } - if (isAbacManagedRoom(room) && value !== 'p') { + if (isABACManagedRoom(room) && value !== 'p') { throw new Meteor.Error('error-action-not-allowed', 'Changing an ABAC managed private room to public is not allowed', { method: 'saveRoomSettings', action: 'Change_Room_Type', @@ -151,6 +163,15 @@ const validators: RoomSettingsValidators = { }); } }, + async roomAnnouncement({ room, value }) { + guardABACManagedField(room, value, room.announcement, 'announcement'); + }, + async roomTopic({ room, value }) { + guardABACManagedField(room, value, room.topic, 'topic'); + }, + async roomDescription({ room, value }) { + guardABACManagedField(room, value, room.description, 'description'); + }, async encrypted({ userId, value, room, rid }) { if (value !== room.encrypted) { if (!(await roomCoordinator.getRoomDirectives(room.t).allowRoomSettingChange(room, RoomSettingsEnum.E2E))) { diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/handleCommsSync.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/handleCommsSync.ts index 4354be6d5bba0..a7617a5465fde 100644 --- a/apps/meteor/app/cloud/server/functions/syncWorkspace/handleCommsSync.ts +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/handleCommsSync.ts @@ -1,5 +1,5 @@ import { NPS, Banner } from '@rocket.chat/core-services'; -import type { Cloud, IBanner } from '@rocket.chat/core-typings'; +import type { Cloud, IBanner, Optional } from '@rocket.chat/core-typings'; import { getAndCreateNpsSurvey } from '../../../../../server/services/nps/getAndCreateNpsSurvey'; @@ -23,7 +23,7 @@ export const handleNpsOnWorkspaceSync = async (nps: Cloud.NpsSurveyAnnouncement) } }; -export const handleBannerOnWorkspaceSync = async (banners: IBanner[]) => { +export const handleBannerOnWorkspaceSync = async (banners: Optional[]) => { for (const banner of banners) { await Banner.create(banner); } diff --git a/apps/meteor/app/cors/server/cors.ts b/apps/meteor/app/cors/server/cors.ts index 5e7909c525dfb..9464762d57d04 100644 --- a/apps/meteor/app/cors/server/cors.ts +++ b/apps/meteor/app/cors/server/cors.ts @@ -1,6 +1,6 @@ -import type http from 'http'; -import type { UrlWithParsedQuery } from 'url'; -import url from 'url'; +import type http from 'node:http'; +import type { UrlWithParsedQuery } from 'node:url'; +import url from 'node:url'; import { Logger } from '@rocket.chat/logger'; import { Meteor } from 'meteor/meteor'; diff --git a/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts b/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts new file mode 100644 index 0000000000000..856852106eba6 --- /dev/null +++ b/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts @@ -0,0 +1,61 @@ +import { api } from '@rocket.chat/core-services'; +import { CustomSounds } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import type { ICustomSoundData } from '../methods/insertOrUpdateSound'; +import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds'; + +export const insertOrUpdateSound = async (soundData: ICustomSoundData): Promise => { + // silently strip colon; this allows for uploading :soundname: as soundname + soundData.name = (soundData.name || '').replace(/:/g, ''); + + if (!soundData.name.trim()) { + throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', { + method: 'insertOrUpdateSound', + field: 'Name', + }); + } + + // allow all characters except colon, whitespace, comma, >, <, &, ", ', /, \, (, ) + // more practical than allowing specific sets of characters; also allows foreign languages + const nameValidation = /[\s,:><&"'\/\\\(\)]/; + + if (nameValidation.test(soundData.name)) { + throw new Meteor.Error('error-input-is-not-a-valid-field', `${soundData.name} is not a valid name`, { + method: 'insertOrUpdateSound', + input: soundData.name, + field: 'Name', + }); + } + + const matchingResults = await CustomSounds.findByName(soundData.name, soundData._id).toArray(); + + if (matchingResults.length > 0) { + throw new Meteor.Error('Custom_Sound_Error_Name_Already_In_Use', 'The custom sound name is already in use', { + method: 'insertOrUpdateSound', + }); + } + + if (!soundData._id) { + return ( + await CustomSounds.create({ + name: soundData.name, + extension: soundData.extension, + }) + ).insertedId; + } + + if (soundData.newFile) { + await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.previousExtension}`); + } + + if (soundData.name !== soundData.previousName || soundData.extension !== soundData.previousExtension) { + await CustomSounds.updateById(soundData._id, { name: soundData.name, extension: soundData.extension }); + const updatedSound = await CustomSounds.findOneById(soundData._id); + if (updatedSound) { + void api.broadcast('notify.updateCustomSound', { soundData: updatedSound }); + } + } + + return soundData._id; +}; diff --git a/apps/meteor/app/custom-sounds/server/lib/uploadCustomSound.ts b/apps/meteor/app/custom-sounds/server/lib/uploadCustomSound.ts new file mode 100644 index 0000000000000..926b5a2fed540 --- /dev/null +++ b/apps/meteor/app/custom-sounds/server/lib/uploadCustomSound.ts @@ -0,0 +1,44 @@ +import { api } from '@rocket.chat/core-services'; +import type { RequiredField } from '@rocket.chat/core-typings'; +import { CustomSounds } from '@rocket.chat/models'; + +import { RocketChatFile } from '../../../file/server'; +import type { ICustomSoundData } from '../methods/insertOrUpdateSound'; +import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds'; + +export const uploadCustomSound = async ( + buffer: Buffer, + contentType: string, + soundData: RequiredField, +): Promise => { + const rs = RocketChatFile.bufferToStream(buffer); + + if (soundData.previousExtension) { + await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.previousExtension}`); + } + + return new Promise((resolve, reject) => { + const ws = RocketChatFileCustomSoundsInstance.createWriteStream(`${soundData._id}.${soundData.extension}`, contentType); + + ws.on('error', (err: Error) => { + reject(err); + }); + + rs.on('error', (err: Error) => { + ws.destroy(); + reject(err); + }); + + ws.on('end', () => { + setTimeout(async () => { + const sound = await CustomSounds.findOneById(soundData._id); + if (sound) { + void api.broadcast('notify.updateCustomSound', { soundData: sound }); + } + }, 500); + resolve(); + }); + + rs.pipe(ws); + }); +}; diff --git a/apps/meteor/app/custom-sounds/server/methods/insertOrUpdateSound.ts b/apps/meteor/app/custom-sounds/server/methods/insertOrUpdateSound.ts index 1b922c6b162ed..a2881ad13ef2d 100644 --- a/apps/meteor/app/custom-sounds/server/methods/insertOrUpdateSound.ts +++ b/apps/meteor/app/custom-sounds/server/methods/insertOrUpdateSound.ts @@ -1,11 +1,10 @@ -import { api } from '@rocket.chat/core-services'; import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { CustomSounds } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; +import { insertOrUpdateSound } from '../lib/insertOrUpdateSound'; export type ICustomSoundData = { _id?: string; @@ -29,74 +28,13 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async insertOrUpdateSound(soundData) { + methodDeprecationLogger.method('insertOrUpdateSound', '9.0.0', ['/v1/custom-sounds.create', '/v1/custom-sounds.update']); if (!this.userId || !(await hasPermissionAsync(this.userId, 'manage-sounds'))) { throw new Meteor.Error('not_authorized'); } - - if (!soundData.name?.trim()) { - throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', { - method: 'insertOrUpdateSound', - field: 'Name', - }); - } - - // let nameValidation = new RegExp('^[0-9a-zA-Z-_+;.]+$'); - - // allow all characters except colon, whitespace, comma, >, <, &, ", ', /, \, (, ) - // more practical than allowing specific sets of characters; also allows foreign languages - const nameValidation = /[\s,:><&"'\/\\\(\)]/; - - // silently strip colon; this allows for uploading :soundname: as soundname - soundData.name = soundData.name.replace(/:/g, ''); - - if (nameValidation.test(soundData.name)) { - throw new Meteor.Error('error-input-is-not-a-valid-field', `${soundData.name} is not a valid name`, { - method: 'insertOrUpdateSound', - input: soundData.name, - field: 'Name', - }); - } - - let matchingResults = []; - if (soundData._id) { check(soundData._id, String); - matchingResults = await CustomSounds.findByNameExceptId(soundData.name, soundData._id).toArray(); - } else { - matchingResults = await CustomSounds.findByName(soundData.name).toArray(); } - - if (matchingResults.length > 0) { - throw new Meteor.Error('Custom_Sound_Error_Name_Already_In_Use', 'The custom sound name is already in use', { - method: 'insertOrUpdateSound', - }); - } - - if (!soundData._id) { - return ( - await CustomSounds.create({ - name: soundData.name, - extension: soundData.extension, - }) - ).insertedId; - } - - // update sound - if (soundData.newFile) { - await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.previousExtension}`); - } - - if (soundData.name !== soundData.previousName) { - await CustomSounds.setName(soundData._id, soundData.name); - void api.broadcast('notify.updateCustomSound', { - soundData: { - _id: soundData._id, - name: soundData.name, - extension: soundData.extension, - }, - }); - } - - return soundData._id; + return insertOrUpdateSound(soundData); }, }); diff --git a/apps/meteor/app/custom-sounds/server/methods/uploadCustomSound.ts b/apps/meteor/app/custom-sounds/server/methods/uploadCustomSound.ts index 64286bb71d86a..e979d66110ba9 100644 --- a/apps/meteor/app/custom-sounds/server/methods/uploadCustomSound.ts +++ b/apps/meteor/app/custom-sounds/server/methods/uploadCustomSound.ts @@ -1,12 +1,11 @@ -import { api } from '@rocket.chat/core-services'; import type { RequiredField } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Meteor } from 'meteor/meteor'; import type { ICustomSoundData } from './insertOrUpdateSound'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { RocketChatFile } from '../../../file/server'; -import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; +import { uploadCustomSound } from '../lib/uploadCustomSound'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -17,23 +16,11 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async uploadCustomSound(binaryContent, contentType, soundData) { + methodDeprecationLogger.method('uploadCustomSound', '9.0.0', ['/v1/custom-sounds.create', '/v1/custom-sounds.update']); if (!this.userId || !(await hasPermissionAsync(this.userId, 'manage-sounds'))) { throw new Meteor.Error('not_authorized'); } - - const file = Buffer.from(binaryContent, 'binary'); - - const rs = RocketChatFile.bufferToStream(file); - await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.extension}`); - - return new Promise((resolve) => { - const ws = RocketChatFileCustomSoundsInstance.createWriteStream(`${soundData._id}.${soundData.extension}`, contentType); - ws.on('end', () => { - setTimeout(() => api.broadcast('notify.updateCustomSound', { soundData }), 500); - resolve(); - }); - - rs.pipe(ws); - }); + const buffer = Buffer.from(binaryContent, 'binary'); + await uploadCustomSound(buffer, contentType, soundData); }, }); diff --git a/apps/meteor/app/emoji-emojione/lib/generateEmojiIndex.mjs b/apps/meteor/app/emoji-emojione/lib/generateEmojiIndex.mjs index 009ef3dcc8f47..fbed7b93cd9b1 100644 --- a/apps/meteor/app/emoji-emojione/lib/generateEmojiIndex.mjs +++ b/apps/meteor/app/emoji-emojione/lib/generateEmojiIndex.mjs @@ -3,7 +3,7 @@ // before using this script make sure to run: npm i --no-save node-sprite-generator // node --experimental-modules generateEmojiIndex.mjs -import fs from 'fs'; +import fs from 'node:fs'; import nsg from 'node-sprite-generator'; import _ from 'underscore'; import gm from 'gm'; // lgtm[js/unused-local-variable] diff --git a/apps/meteor/app/file-upload/lib/FileUploadBase.ts b/apps/meteor/app/file-upload/lib/FileUploadBase.ts index e6027c833e5da..a9dc82ef46e57 100644 --- a/apps/meteor/app/file-upload/lib/FileUploadBase.ts +++ b/apps/meteor/app/file-upload/lib/FileUploadBase.ts @@ -1,4 +1,4 @@ -import path from 'path'; +import path from 'node:path'; import { UploadFS } from '../../../server/ufs'; diff --git a/apps/meteor/app/file-upload/server/config/AmazonS3.ts b/apps/meteor/app/file-upload/server/config/AmazonS3.ts index 36da7e5feeee9..77a554e46b7af 100644 --- a/apps/meteor/app/file-upload/server/config/AmazonS3.ts +++ b/apps/meteor/app/file-upload/server/config/AmazonS3.ts @@ -1,5 +1,5 @@ -import http from 'http'; -import https from 'https'; +import http from 'node:http'; +import https from 'node:https'; import _ from 'underscore'; diff --git a/apps/meteor/app/file-upload/server/config/FileSystem.ts b/apps/meteor/app/file-upload/server/config/FileSystem.ts index c39cf5807d602..512b2dd175f20 100644 --- a/apps/meteor/app/file-upload/server/config/FileSystem.ts +++ b/apps/meteor/app/file-upload/server/config/FileSystem.ts @@ -1,4 +1,4 @@ -import fsp from 'fs/promises'; +import fsp from 'node:fs/promises'; import { getContentDisposition } from './helper'; import { UploadFS } from '../../../../server/ufs'; diff --git a/apps/meteor/app/file-upload/server/config/GoogleStorage.ts b/apps/meteor/app/file-upload/server/config/GoogleStorage.ts index ccd94d533b62c..fface3fce7d2d 100644 --- a/apps/meteor/app/file-upload/server/config/GoogleStorage.ts +++ b/apps/meteor/app/file-upload/server/config/GoogleStorage.ts @@ -1,5 +1,5 @@ -import http from 'http'; -import https from 'https'; +import http from 'node:http'; +import https from 'node:https'; import _ from 'underscore'; @@ -68,7 +68,7 @@ const configure = _.debounce(() => { const projectId = settings.get('FileUpload_GoogleStorage_ProjectId'); const accessId = settings.get('FileUpload_GoogleStorage_AccessId'); const secret = settings.get('FileUpload_GoogleStorage_Secret'); - const URLExpiryTimeSpan = settings.get('FileUpload_S3_URLExpiryTimeSpan'); + const URLExpiryTimeSpan = settings.get('FileUpload_GoogleStorage_URLExpiryTimeSpan'); if (!bucket || !accessId || !secret) { return; diff --git a/apps/meteor/app/file-upload/server/config/GridFS.ts b/apps/meteor/app/file-upload/server/config/GridFS.ts index bb9dfa5891f26..94c6629e4f245 100644 --- a/apps/meteor/app/file-upload/server/config/GridFS.ts +++ b/apps/meteor/app/file-upload/server/config/GridFS.ts @@ -1,7 +1,7 @@ -import type * as http from 'http'; -import type { TransformCallback, TransformOptions } from 'stream'; -import stream from 'stream'; -import zlib from 'zlib'; +import type * as http from 'node:http'; +import type { TransformCallback, TransformOptions } from 'node:stream'; +import stream from 'node:stream'; +import zlib from 'node:zlib'; import type { IUpload } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; diff --git a/apps/meteor/app/file-upload/server/config/helper.ts b/apps/meteor/app/file-upload/server/config/helper.ts index f1c465537255c..df07a2ac41bf4 100644 --- a/apps/meteor/app/file-upload/server/config/helper.ts +++ b/apps/meteor/app/file-upload/server/config/helper.ts @@ -1,5 +1,5 @@ -import type http from 'http'; -import URL from 'url'; +import type http from 'node:http'; +import URL from 'node:url'; export const forceDownload = (req: http.IncomingMessage): boolean => { const { query } = URL.parse(req.url || '', true); diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.ts index a368d28586c0c..43dfc089e2e4f 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.ts @@ -1,13 +1,13 @@ -import { Buffer } from 'buffer'; -import type { WriteStream } from 'fs'; -import fs from 'fs'; -import { unlink, rename, writeFile } from 'fs/promises'; -import type * as http from 'http'; -import type * as https from 'https'; -import stream from 'stream'; -import { finished } from 'stream/promises'; -import URL from 'url'; -import { isArrayBufferView } from 'util/types'; +import { Buffer } from 'node:buffer'; +import type { WriteStream } from 'node:fs'; +import fs from 'node:fs'; +import { unlink, rename, writeFile } from 'node:fs/promises'; +import type * as http from 'node:http'; +import type * as https from 'node:https'; +import stream from 'node:stream'; +import { finished } from 'node:stream/promises'; +import URL from 'node:url'; +import { isArrayBufferView } from 'node:util/types'; import { hashLoginToken } from '@rocket.chat/account-utils'; import { Apps, AppEvents } from '@rocket.chat/apps'; @@ -284,7 +284,7 @@ export const FileUpload = { ); }, - async resizeImagePreview(fileParam: IUpload) { + async resizeImagePreview(fileParam: Pick) { let file = await Uploads.findOneById(fileParam._id); if (!file) { return; @@ -302,7 +302,7 @@ export const FileUpload = { return sharp(await FileUpload.getBuffer(file)).metadata(); }, - async createImageThumbnail(fileParam: IUpload) { + async createImageThumbnail(fileParam: Pick) { if (!settings.get('Message_Attachments_Thumbnails_Enabled')) { return; } @@ -836,7 +836,7 @@ export class FileUploadClass { } private async _validateFile( - fileData: OptionalId, + fileData: Omit, '_updatedAt'>, content: stream.Readable | Buffer | string, ): Promise { const filter = this.store.getFilter(); @@ -863,7 +863,7 @@ export class FileUploadClass { } async _doInsert( - fileData: OptionalId, + fileData: Omit, '_updatedAt'>, content: stream.Readable | Buffer | string, options?: { session?: ClientSession }, ): Promise { @@ -888,7 +888,7 @@ export class FileUploadClass { } async insert( - fileData: OptionalId, + fileData: Omit, '_updatedAt'>, streamOrBuffer: stream.Readable | Buffer | string, options?: { session?: ClientSession }, ): Promise { diff --git a/apps/meteor/app/file-upload/server/lib/ranges.ts b/apps/meteor/app/file-upload/server/lib/ranges.ts index 0ac86347ca423..0fedb0e981fac 100644 --- a/apps/meteor/app/file-upload/server/lib/ranges.ts +++ b/apps/meteor/app/file-upload/server/lib/ranges.ts @@ -1,4 +1,4 @@ -import type http from 'http'; +import type http from 'node:http'; import type { IUpload } from '@rocket.chat/core-typings'; diff --git a/apps/meteor/app/file-upload/server/lib/requests.ts b/apps/meteor/app/file-upload/server/lib/requests.ts index b2ae48fbca362..22799b5cb5fae 100644 --- a/apps/meteor/app/file-upload/server/lib/requests.ts +++ b/apps/meteor/app/file-upload/server/lib/requests.ts @@ -1,4 +1,4 @@ -import type { IncomingMessage } from 'http'; +import type { IncomingMessage } from 'node:http'; import { Uploads } from '@rocket.chat/models'; import { WebApp } from 'meteor/webapp'; diff --git a/apps/meteor/app/file-upload/server/lib/urlExpiry.spec.ts b/apps/meteor/app/file-upload/server/lib/urlExpiry.spec.ts new file mode 100644 index 0000000000000..69a4da82730df --- /dev/null +++ b/apps/meteor/app/file-upload/server/lib/urlExpiry.spec.ts @@ -0,0 +1,16 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { MIN_URL_EXPIRY_TIME_SPAN_SECONDS, URL_EXPIRY_FALLBACK_SECONDS, getUrlExpiryTimeSpanWithFallback } from './urlExpiry'; + +describe('getUrlExpiryTimeSpanWithFallback', () => { + it('uses fallback when configured value is below minimum', () => { + expect(getUrlExpiryTimeSpanWithFallback(0)).to.equal(URL_EXPIRY_FALLBACK_SECONDS); + expect(getUrlExpiryTimeSpanWithFallback(3)).to.equal(URL_EXPIRY_FALLBACK_SECONDS); + }); + + it('uses configured value when at or above minimum', () => { + expect(getUrlExpiryTimeSpanWithFallback(MIN_URL_EXPIRY_TIME_SPAN_SECONDS)).to.equal(MIN_URL_EXPIRY_TIME_SPAN_SECONDS); + expect(getUrlExpiryTimeSpanWithFallback(300)).to.equal(300); + }); +}); diff --git a/apps/meteor/app/file-upload/server/lib/urlExpiry.ts b/apps/meteor/app/file-upload/server/lib/urlExpiry.ts new file mode 100644 index 0000000000000..2d6336b6ef3d1 --- /dev/null +++ b/apps/meteor/app/file-upload/server/lib/urlExpiry.ts @@ -0,0 +1,6 @@ +export const MIN_URL_EXPIRY_TIME_SPAN_SECONDS = 5; +export const URL_EXPIRY_FALLBACK_SECONDS = 900; + +export const getUrlExpiryTimeSpanWithFallback = (configuredValue: number): number => { + return configuredValue >= MIN_URL_EXPIRY_TIME_SPAN_SECONDS ? configuredValue : URL_EXPIRY_FALLBACK_SECONDS; +}; diff --git a/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts b/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts index b223d879c78bb..2ab3312b31f30 100644 --- a/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts +++ b/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts @@ -1,4 +1,4 @@ -import stream from 'stream'; +import stream from 'node:stream'; import { DeleteObjectCommand, @@ -13,12 +13,12 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import type { IUpload } from '@rocket.chat/core-typings'; import { Random } from '@rocket.chat/random'; import { check } from 'meteor/check'; -import type { OptionalId } from 'mongodb'; import _ from 'underscore'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { UploadFS } from '../../../../server/ufs'; import type { StoreOptions } from '../../../../server/ufs/ufs-store'; +import { getUrlExpiryTimeSpanWithFallback } from '../../server/lib/urlExpiry'; export type S3Options = StoreOptions & { connection: S3ClientConfig; @@ -27,11 +27,11 @@ export type S3Options = StoreOptions & { ACL: string; }; URLExpiryTimeSpan: number; - getPath: (file: OptionalId) => string; + getPath: (file: Omit) => string; }; class AmazonS3Store extends UploadFS.Store { - protected getPath: (file: IUpload) => string; + protected getPath: (file: Omit) => string; constructor(options: S3Options) { // Default options @@ -81,6 +81,7 @@ class AmazonS3Store extends UploadFS.Store { }; this.getRedirectURL = async (file, forceDownload = false) => { + const expiresIn = getUrlExpiryTimeSpanWithFallback(classOptions.URLExpiryTimeSpan); return getSignedUrl( s3, new GetObjectCommand({ @@ -88,9 +89,7 @@ class AmazonS3Store extends UploadFS.Store { ResponseContentDisposition: `${forceDownload ? 'attachment' : 'inline'}; filename="${encodeURI(file.name || '')}"`, Bucket: classOptions.params.Bucket, }), - { - expiresIn: classOptions.URLExpiryTimeSpan, // seconds - }, + { expiresIn }, ); }; @@ -105,7 +104,7 @@ class AmazonS3Store extends UploadFS.Store { } file.AmazonS3 = { - path: classOptions.getPath(file), + path: classOptions.getPath(file as Omit), }; file.store = this.options.name; // assign store to file @@ -204,7 +203,7 @@ class AmazonS3Store extends UploadFS.Store { }; this.getUrlExpiryTimeSpan = async () => { - return classOptions.URLExpiryTimeSpan || null; + return getUrlExpiryTimeSpanWithFallback(classOptions.URLExpiryTimeSpan); }; } } diff --git a/apps/meteor/app/file-upload/ufs/GoogleStorage/server.ts b/apps/meteor/app/file-upload/ufs/GoogleStorage/server.ts index e2b71ac8052d7..03b74df609b01 100644 --- a/apps/meteor/app/file-upload/ufs/GoogleStorage/server.ts +++ b/apps/meteor/app/file-upload/ufs/GoogleStorage/server.ts @@ -3,11 +3,11 @@ import { Storage } from '@google-cloud/storage'; import type { IUpload } from '@rocket.chat/core-typings'; import { Random } from '@rocket.chat/random'; import { check } from 'meteor/check'; -import type { OptionalId } from 'mongodb'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { UploadFS } from '../../../../server/ufs'; import type { StoreOptions } from '../../../../server/ufs/ufs-store'; +import { getUrlExpiryTimeSpanWithFallback } from '../../server/lib/urlExpiry'; type GStoreOptions = StoreOptions & { connection: { @@ -19,11 +19,11 @@ type GStoreOptions = StoreOptions & { }; bucket: string; URLExpiryTimeSpan: number; - getPath: (file: OptionalId) => string; + getPath: (file: Omit) => string; }; class GoogleStorageStore extends UploadFS.Store { - protected getPath: (file: IUpload) => string; + protected getPath: (file: Omit) => string; constructor(options: GStoreOptions) { super(options); @@ -56,16 +56,22 @@ class GoogleStorageStore extends UploadFS.Store { }; this.getRedirectURL = async (file, forceDownload = false) => { + const expirySeconds = getUrlExpiryTimeSpanWithFallback(options.URLExpiryTimeSpan); const params: GetSignedUrlConfig = { action: 'read', responseDisposition: forceDownload ? 'attachment' : 'inline', - expires: Date.now() + options.URLExpiryTimeSpan * 1000, + version: 'v2', + expires: Date.now() + expirySeconds * 1000, }; const res = await bucket.file(this.getPath(file)).getSignedUrl(params); return res[0]; }; + this.getUrlExpiryTimeSpan = async () => { + return getUrlExpiryTimeSpanWithFallback(options.URLExpiryTimeSpan); + }; + /** * Creates the file in the collection * @param file @@ -80,7 +86,7 @@ class GoogleStorageStore extends UploadFS.Store { } file.GoogleStorage = { - path: options.getPath(file), + path: options.getPath(file as Omit), }; file.store = this.options.name; // assign store to file diff --git a/apps/meteor/app/file-upload/ufs/Webdav/server.ts b/apps/meteor/app/file-upload/ufs/Webdav/server.ts index e5a8a62b5d059..7eccb59d609cf 100644 --- a/apps/meteor/app/file-upload/ufs/Webdav/server.ts +++ b/apps/meteor/app/file-upload/ufs/Webdav/server.ts @@ -1,9 +1,8 @@ -import stream from 'stream'; +import stream from 'node:stream'; import type { IUpload } from '@rocket.chat/core-typings'; import { Random } from '@rocket.chat/random'; import { check } from 'meteor/check'; -import type { OptionalId } from 'mongodb'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { UploadFS } from '../../../../server/ufs'; @@ -19,11 +18,11 @@ type WebdavOptions = StoreOptions & { }; }; uploadFolderPath: string; - getPath: (file: OptionalId) => string; + getPath: (file: Omit) => string; }; class WebdavStore extends UploadFS.Store { - protected getPath: (file: IUpload) => string; + protected getPath: (file: Omit) => string; constructor(options: WebdavOptions) { super(options); @@ -72,7 +71,7 @@ class WebdavStore extends UploadFS.Store { } file.Webdav = { - path: options.getPath(file), + path: options.getPath(file as Omit), }; file.store = this.options.name; diff --git a/apps/meteor/app/file/server/file.server.ts b/apps/meteor/app/file/server/file.server.ts index 629684391bcbd..02a5a5b03edf3 100644 --- a/apps/meteor/app/file/server/file.server.ts +++ b/apps/meteor/app/file/server/file.server.ts @@ -1,8 +1,8 @@ -import type { ReadStream } from 'fs'; -import fs from 'fs'; -import fsp from 'fs/promises'; -import path from 'path'; -import { Readable } from 'stream'; +import type { ReadStream } from 'node:fs'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { Readable } from 'node:stream'; import type { ObjectId } from 'bson'; import { MongoInternals } from 'meteor/mongo'; diff --git a/apps/meteor/app/file/server/functions/sanitizeFileName.ts b/apps/meteor/app/file/server/functions/sanitizeFileName.ts index 5b9ca233cbe07..fb19617afd496 100644 --- a/apps/meteor/app/file/server/functions/sanitizeFileName.ts +++ b/apps/meteor/app/file/server/functions/sanitizeFileName.ts @@ -1,4 +1,4 @@ -import path from 'path'; +import path from 'node:path'; export function sanitizeFileName(fileName: string) { const base = path.basename(fileName); diff --git a/apps/meteor/app/importer-pending-files/server/PendingFileImporter.ts b/apps/meteor/app/importer-pending-files/server/PendingFileImporter.ts index 56aa0293aedb0..35b88c4267138 100644 --- a/apps/meteor/app/importer-pending-files/server/PendingFileImporter.ts +++ b/apps/meteor/app/importer-pending-files/server/PendingFileImporter.ts @@ -1,5 +1,5 @@ -import http from 'http'; -import https from 'https'; +import http from 'node:http'; +import https from 'node:https'; import { api } from '@rocket.chat/core-services'; import type { IImport, MessageAttachment, IUpload, IImporterShortSelection } from '@rocket.chat/core-typings'; diff --git a/apps/meteor/app/importer-slack-users/server/SlackUsersImporter.ts b/apps/meteor/app/importer-slack-users/server/SlackUsersImporter.ts index 581172a617b96..dcf2d772bd6f1 100644 --- a/apps/meteor/app/importer-slack-users/server/SlackUsersImporter.ts +++ b/apps/meteor/app/importer-slack-users/server/SlackUsersImporter.ts @@ -1,4 +1,4 @@ -import fs from 'fs'; +import fs from 'node:fs'; import type { IImport, IImportUser } from '@rocket.chat/core-typings'; import { Settings } from '@rocket.chat/models'; diff --git a/apps/meteor/app/importer/server/classes/converters/RecordConverter.ts b/apps/meteor/app/importer/server/classes/converters/RecordConverter.ts index 3ae1df007f75a..56b49df61cd44 100644 --- a/apps/meteor/app/importer/server/classes/converters/RecordConverter.ts +++ b/apps/meteor/app/importer/server/classes/converters/RecordConverter.ts @@ -158,6 +158,7 @@ export class RecordConverter({ - async sendMessage(message) { - const uid = getUserId(); - if (!uid || trim(message.msg) === '') { - return false; - } - const messageAlreadyExists = message._id && Messages.state.get(message._id); - if (messageAlreadyExists) { - return dispatchToastMessage({ type: 'error', message: t('Message_Already_Sent') }); - } - const user = getUser(); - if (!user?.username) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'sendMessage' }); - } - message.ts = new Date(); - message.u = { +export const runOptimisticSendMessage = async ( + message: Partial & { rid: IMessage['rid']; msg: IMessage['msg'] }, +): Promise => { + const uid = getUserId(); + if (!uid || trim(message.msg) === '') { + return; + } + const messageAlreadyExists = message._id && Messages.state.get(message._id); + if (messageAlreadyExists) { + dispatchToastMessage({ type: 'error', message: t('Message_Already_Sent') }); + return; + } + const user = getUser(); + if (!user?.username) { + return; + } + + const room = Rooms.state.get(message.rid); + if (room?.federated) { + return; + } + + const optimistic: IMessage = { + ...(message as IMessage), + ts: new Date(), + u: { _id: uid, username: user.username, name: user.name || '', - }; - message.temp = true; - if (settings.peek('Message_Read_Receipt_Enabled')) { - message.unread = true; - } + }, + temp: true, + ...(settings.peek('Message_Read_Receipt_Enabled') ? { unread: true } : {}), + }; - // If the room is federated, send the message to matrix only - const room = Rooms.state.get(message.rid); - if (room?.federated) { - return; - } + const processed = await onClientMessageReceived(optimistic); + Messages.state.store(processed); - await onClientMessageReceived(message as IMessage).then((message) => { - Messages.state.store(message); - return clientCallbacks.run('afterSaveMessage', message, { room, user }); - }); - }, -}); + if (processed.tmid) { + upsertThreadMessageInCache(processed, processed.rid, processed.tmid); + } +}; diff --git a/apps/meteor/app/lib/server/functions/getFullUserData.ts b/apps/meteor/app/lib/server/functions/getFullUserData.ts index 6c6e11a50a4c4..702622053aeb3 100644 --- a/apps/meteor/app/lib/server/functions/getFullUserData.ts +++ b/apps/meteor/app/lib/server/functions/getFullUserData.ts @@ -78,24 +78,29 @@ const getFields = (canViewAllInfo: boolean): Record => ({ const findTargetUser = (type: string, value: string, opts: any) => { if (type === 'importId') return Users.findOneByImportId(value, opts); if (type === 'email') return Users.findOneByEmailAddress(value, opts); + if (type === 'freeSwitchExtension') return Users.findOneByFreeSwitchExtension(value, opts); return Users.findOneByIdOrUsername(value, opts); }; -export async function getFullUserDataByIdOrUsernameOrImportIdOrEmail( +export async function getFullUserDataByUniqueSearchTerm( userId: string, searchValue: string, - searchType: 'id' | 'username' | 'importId' | 'email', + searchType: 'id' | 'username' | 'importId' | 'email' | 'freeSwitchExtension', ): Promise { - const caller = await Users.findOneById(userId, { projection: { username: 1, importIds: 1, emails: 1 } }); + const caller = await Users.findOneById(userId, { projection: { username: 1, importIds: 1, emails: 1, freeSwitchExtension: 1 } }); + if (!caller) { return null; } + const myself = (searchType === 'id' && searchValue === userId) || (searchType === 'username' && searchValue === caller.username) || (searchType === 'importId' && caller.importIds?.includes(searchValue)) || + (searchType === 'freeSwitchExtension' && caller.freeSwitchExtension === searchValue) || (searchType === 'email' && caller.emails?.some((email: IUserEmail) => email.address.trim().toLowerCase() === searchValue.trim().toLowerCase())); + const canViewAllInfo = !!myself || (await hasPermissionAsync(userId, 'view-full-other-user-info')); // Only search for importId/email if the user has permission to view them diff --git a/apps/meteor/app/lib/server/functions/notifications/desktop.ts b/apps/meteor/app/lib/server/functions/notifications/desktop.ts index 9b49f53582063..4554656d408b6 100644 --- a/apps/meteor/app/lib/server/functions/notifications/desktop.ts +++ b/apps/meteor/app/lib/server/functions/notifications/desktop.ts @@ -67,6 +67,7 @@ export async function notifyDesktopUser({ }; metrics.notificationsSent.inc({ notification_type: 'desktop' }); + metrics.notificationsSentTotal.inc({ notification_type: 'desktop' }); void api.broadcast('notify.desktop', userId, payload); } diff --git a/apps/meteor/app/lib/server/functions/notifications/email.js b/apps/meteor/app/lib/server/functions/notifications/email.js index 7bdbd0d6e990a..4de543abb7dd6 100644 --- a/apps/meteor/app/lib/server/functions/notifications/email.js +++ b/apps/meteor/app/lib/server/functions/notifications/email.js @@ -178,13 +178,14 @@ export async function getEmailData({ message, receiver, sender, subscription, ro )}${message.tmid || message._id}@${replyto.split('@')[1]}`; } - metrics.notificationsSent.inc({ notification_type: 'email' }); return email; } export function sendEmailFromData(data) { - metrics.notificationsSent.inc({ notification_type: 'email' }); - return Mailer.send(data); + return Mailer.send(data).then(() => { + metrics.notificationsSent.inc({ notification_type: 'email' }); + metrics.notificationsSentTotal.inc({ notification_type: 'email' }); + }); } export function shouldNotifyEmail({ diff --git a/apps/meteor/app/lib/server/functions/saveUser/setPasswordUpdater.ts b/apps/meteor/app/lib/server/functions/saveUser/setPasswordUpdater.ts index f9218d64449b3..7f51b451b9002 100644 --- a/apps/meteor/app/lib/server/functions/saveUser/setPasswordUpdater.ts +++ b/apps/meteor/app/lib/server/functions/saveUser/setPasswordUpdater.ts @@ -1,4 +1,4 @@ -import crypto from 'crypto'; +import crypto from 'node:crypto'; import type { IUser } from '@rocket.chat/core-typings'; import type { Updater } from '@rocket.chat/model-typings'; diff --git a/apps/meteor/app/lib/server/lib/debug.js b/apps/meteor/app/lib/server/lib/debug.js index 71daf1a4fbcfc..d4c129a0767ff 100644 --- a/apps/meteor/app/lib/server/lib/debug.js +++ b/apps/meteor/app/lib/server/lib/debug.js @@ -57,11 +57,14 @@ const wrapMethods = function (name, originalHandler, methodsMap) { const method = name === 'stream' ? `${name}:${originalArgs[0]}` : name; - const end = metrics.meteorMethods.startTimer({ + const meteorMethodLabels = { method, has_connection: this.connection != null, has_user: this.userId != null, - }); + }; + + const end = metrics.meteorMethods.startTimer(meteorMethodLabels); + const endHistogram = metrics.meteorMethodsSeconds.startTimer(meteorMethodLabels); logger.method({ method, @@ -84,6 +87,7 @@ const wrapMethods = function (name, originalHandler, methodsMap) { async () => { const result = await originalHandler.apply(this, originalArgs); end(); + endHistogram(); return result; }, ); @@ -115,10 +119,12 @@ Meteor.publish = function (name, func) { }); const end = metrics.meteorSubscriptions.startTimer({ subscription: name }); + const endHistogram = metrics.meteorSubscriptionsSeconds.startTimer({ subscription: name }); const originalReady = this.ready; this.ready = function () { end(); + endHistogram(); return originalReady.apply(this, args); }; diff --git a/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts b/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts index ee74d472ff05c..0d1bff3250a4c 100644 --- a/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts +++ b/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts @@ -40,6 +40,7 @@ export const apiDeprecationLogger = ((logger) => { writeDeprecationHeader(res, 'endpoint-deprecation', message, version); metrics.deprecations.inc({ type: 'deprecation', kind: 'endpoint', name: endpoint }); + metrics.deprecationsTotal.inc({ type: 'deprecation', kind: 'endpoint', name: endpoint }); logger.warn({ msg: message, endpoint, version, info }); }, @@ -63,6 +64,7 @@ export const apiDeprecationLogger = ((logger) => { } metrics.deprecations.inc({ type: 'parameter-deprecation', kind: 'endpoint', name: endpoint, params: parameter }); + metrics.deprecationsTotal.inc({ type: 'parameter-deprecation', kind: 'endpoint', name: endpoint, params: parameter }); writeDeprecationHeader(res, 'parameter-deprecation', message, version); @@ -92,6 +94,7 @@ export const apiDeprecationLogger = ((logger) => { compareVersions(version, message); metrics.deprecations.inc({ type: 'invalid-usage', kind: 'endpoint', name: endpoint, params: parameter }); + metrics.deprecationsTotal.inc({ type: 'invalid-usage', kind: 'endpoint', name: endpoint, params: parameter }); writeDeprecationHeader(res, 'invalid-usage', message, version); @@ -100,20 +103,23 @@ export const apiDeprecationLogger = ((logger) => { }; })(deprecationLogger.section('API')); +type DeprecationPath = T extends `/${string}` ? (T extends PathPattern ? T : never) : string; + +type DeprecationInfo = DeprecationPath | Array>; + export const methodDeprecationLogger = ((logger) => { return { - method: ( - method: string, - version: DeprecationLoggerNextPlannedVersion, - info: T extends `/${string}` ? (T extends PathPattern ? T : never) : string, - ) => { - const replacement = typeof info === 'string' ? info : `Use the ${info} endpoint instead`; + method: (method: string, version: DeprecationLoggerNextPlannedVersion, info: DeprecationInfo) => { + const infoArray = Array.isArray(info) ? info : [info]; + const paths = infoArray.map((p) => (typeof p === 'string' && p.startsWith('/') ? `"${p}"` : p)).join(' or '); + const replacement = infoArray.length > 0 ? `Use the ${paths} endpoint${infoArray.length > 1 ? 's' : ''} instead` : ''; const message = `The method "${method}" is deprecated and will be removed on version ${version}${replacement ? ` (${replacement})` : ''}`; if (process.env.TEST_MODE === 'true') { throw new Error(message); } compareVersions(version, message); metrics.deprecations.inc({ type: 'deprecation', name: method, kind: 'method' }); + metrics.deprecationsTotal.inc({ type: 'deprecation', name: method, kind: 'method' }); logger.warn({ msg: message, method, version, replacement }); }, parameter: (method: string, parameter: string, version: DeprecationLoggerNextPlannedVersion) => { @@ -123,6 +129,7 @@ export const methodDeprecationLogger = ((logger) => { } metrics.deprecations.inc({ type: 'parameter-deprecation', name: method, params: parameter }); + metrics.deprecationsTotal.inc({ type: 'parameter-deprecation', name: method, params: parameter }); compareVersions(version, message); logger.warn({ msg: message, method, parameter, version }); @@ -149,6 +156,7 @@ export const methodDeprecationLogger = ((logger) => { compareVersions(version, message); metrics.deprecations.inc({ type: 'invalid-usage', name: method, params: parameter, kind: 'method' }); + metrics.deprecationsTotal.inc({ type: 'invalid-usage', name: method, params: parameter, kind: 'method' }); logger.warn({ msg: message, method, parameter, version }); }, diff --git a/apps/meteor/app/lib/server/lib/processDirectEmail.ts b/apps/meteor/app/lib/server/lib/processDirectEmail.ts index e88b7d58468e4..e930add5716e8 100644 --- a/apps/meteor/app/lib/server/lib/processDirectEmail.ts +++ b/apps/meteor/app/lib/server/lib/processDirectEmail.ts @@ -109,8 +109,6 @@ export const processDirectEmail = async function (email: ParsedMail): Promise = { ts: tsDiff < 10000 ? ts : new Date(), msg, @@ -120,5 +118,10 @@ export const processDirectEmail = async function (email: ParsedMail): Promise async (reply, input) => { if (reply.allowed === false) { rateLimiterLog({ msg, reply, input }); - metrics.ddpRateLimitExceeded.inc({ + const rateLimitLabels = { limit_name: name, user_id: input.userId, client_address: input.clientAddress, type: input.type, name: input.name, connection_id: input.connectionId, - }); + }; + metrics.ddpRateLimitExceeded.inc(rateLimitLabels); + metrics.ddpRateLimitExceededTotal.inc(rateLimitLabels); // sleep before sending the error to slow down next requests if (slowDownRate > 0 && reply.numInvocationsExceeded) { await sleep(slowDownRate * reply.numInvocationsExceeded); diff --git a/apps/meteor/app/livechat/client/lib/stream/queueManager.ts b/apps/meteor/app/livechat/client/lib/stream/queueManager.ts index 5fc9be444ec98..ed48999575202 100644 --- a/apps/meteor/app/livechat/client/lib/stream/queueManager.ts +++ b/apps/meteor/app/livechat/client/lib/stream/queueManager.ts @@ -5,7 +5,6 @@ import { type IOmnichannelAgent, type Serialized, } from '@rocket.chat/core-typings'; -import { Tracker } from 'meteor/tracker'; import { useLivechatInquiryStore } from '../../../../../client/hooks/useLivechatInquiryStore'; import { queryClient } from '../../../../../client/lib/queryClient'; @@ -64,8 +63,10 @@ const removeInquiry = async (inquiry: ILivechatInquiryRecord) => { return queryClient.invalidateQueries({ queryKey: ['rooms', { reference: inquiry.rid, type: 'l' }] }); }; +const INQUIRY_COUNT_SETTING = 'Livechat_guest_pool_max_number_incoming_livechats_displayed'; + const getInquiriesFromAPI = async () => { - const count = settings.peek('Livechat_guest_pool_max_number_incoming_livechats_displayed') ?? 0; + const count = settings.peek(INQUIRY_COUNT_SETTING) ?? 0; const { inquiries } = await sdk.rest.get('/v1/livechat/inquiries.queuedForUser', { count }); return inquiries; }; @@ -140,10 +141,12 @@ const subscribe = async (userId: IOmnichannelAgent['_id']) => { const cleanDepartmentListeners = addListenerForeachDepartment(agentDepartments); const globalCleanup = addGlobalListener(); - const computation = Tracker.autorun(async () => { - const inquiriesFromAPI = await getInquiriesFromAPI(); + const refetchInquiries = async () => updateInquiries(await getInquiriesFromAPI()); + + await refetchInquiries(); - await updateInquiries(inquiriesFromAPI); + const unobserveInquiryCount = settings.observe(INQUIRY_COUNT_SETTING, () => { + void refetchInquiries(); }); return () => { @@ -152,8 +155,8 @@ const subscribe = async (userId: IOmnichannelAgent['_id']) => { cleanAgentListener?.(); cleanDepartmentListeners?.(); globalCleanup?.(); + unobserveInquiryCount(); departments.clear(); - computation.stop(); }; } catch (error) { dispatchToastMessage({ type: 'error', message: error }); diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.ts b/apps/meteor/app/livechat/imports/server/rest/sms.ts index 533409d342ac2..31a8265a4ec07 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/sms.ts @@ -26,7 +26,7 @@ import { createRoom } from '../../../server/lib/rooms'; const logger = new Logger('SMS'); -const getUploadFile = async (details: Omit, fileUrl: string) => { +const getUploadFile = async (details: Omit, fileUrl: string) => { const response = await fetch(fileUrl, { ignoreSsrfValidation: false, allowList: settings.get('SSRF_Allowlist'), diff --git a/apps/meteor/app/livechat/server/business-hour/LivechatBusinessHours.ts b/apps/meteor/app/livechat/server/business-hour/LivechatBusinessHours.ts index 92f3ed840682f..188b427874aef 100644 --- a/apps/meteor/app/livechat/server/business-hour/LivechatBusinessHours.ts +++ b/apps/meteor/app/livechat/server/business-hour/LivechatBusinessHours.ts @@ -3,7 +3,7 @@ import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; import moment from 'moment-timezone'; import { ObjectId } from 'mongodb'; -export const createDefaultBusinessHourRow = (): ILivechatBusinessHour => { +export const createDefaultBusinessHourRow = (): Omit => { const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; const closedDays = ['Saturday', 'Sunday']; return { diff --git a/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.ts b/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.ts index fb379eb9b6693..aa70b15175c61 100644 --- a/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.ts +++ b/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.ts @@ -2,7 +2,7 @@ import type { ILivechatBusinessHour } from '@rocket.chat/core-typings'; import moment from 'moment'; export const filterBusinessHoursThatMustBeOpened = async ( - businessHours: ILivechatBusinessHour[], + businessHours: Omit[], ): Promise[]> => { const currentTime = moment(moment().format('dddd:HH:mm:ss'), 'dddd:HH:mm:ss'); diff --git a/apps/meteor/app/livechat/server/lib/messages.ts b/apps/meteor/app/livechat/server/lib/messages.ts index e04a3f4ddcd72..558aecde98f5c 100644 --- a/apps/meteor/app/livechat/server/lib/messages.ts +++ b/apps/meteor/app/livechat/server/lib/messages.ts @@ -1,5 +1,5 @@ -import dns from 'dns'; -import * as util from 'util'; +import dns from 'node:dns'; +import * as util from 'node:util'; import type { ILivechatVisitor, AtLeast, IMessage, IUser, IOmnichannelRoomInfo, SelectedAgent } from '@rocket.chat/core-typings'; import { LivechatDepartment, Messages } from '@rocket.chat/models'; diff --git a/apps/meteor/app/livechat/server/lib/webhooks.ts b/apps/meteor/app/livechat/server/lib/webhooks.ts index 661b428cc7cb8..de49a6bb0cae7 100644 --- a/apps/meteor/app/livechat/server/lib/webhooks.ts +++ b/apps/meteor/app/livechat/server/lib/webhooks.ts @@ -34,6 +34,7 @@ export async function sendRequest( if (result.status === 200) { metrics.totalLivechatWebhooksSuccess.inc(); + metrics.totalLivechatWebhooksSuccessTotal.inc(); await cb?.(result); return result; } @@ -46,10 +47,12 @@ export async function sendRequest( response: await result.text(), }); metrics.totalLivechatWebhooksFailures.inc(); + metrics.totalLivechatWebhooksFailuresTotal.inc(); return; } metrics.totalLivechatWebhooksFailures.inc(); + metrics.totalLivechatWebhooksFailuresTotal.inc(); throw new Error(await result.text()); } catch (err) { const retryAfter = timeout * 4; diff --git a/apps/meteor/app/livechat/server/livechat.ts b/apps/meteor/app/livechat/server/livechat.ts index 8928ee826eaf9..3efc2feb17bd3 100644 --- a/apps/meteor/app/livechat/server/livechat.ts +++ b/apps/meteor/app/livechat/server/livechat.ts @@ -1,4 +1,4 @@ -import url from 'url'; +import url from 'node:url'; import jsdom from 'jsdom'; import mem from 'mem'; 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 7dd58f35bed3e..61e4bd7421b88 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts @@ -1,4 +1,4 @@ -import type { ServerResponse } from 'http'; +import type { ServerResponse } from 'node:http'; import type { IUser, IIncomingMessage, IPersonalAccessToken, IRole } from '@rocket.chat/core-typings'; import { CredentialTokens, Rooms, Users, Roles } from '@rocket.chat/models'; 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 824b7ec684dcc..edcea9f72794e 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/ServiceProvider.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/ServiceProvider.ts @@ -1,7 +1,7 @@ -import crypto from 'crypto'; -import querystring from 'querystring'; -import util from 'util'; -import zlib from 'zlib'; +import crypto from 'node:crypto'; +import querystring from 'node:querystring'; +import util from 'node:util'; +import zlib from 'node:zlib'; import { Meteor } from 'meteor/meteor'; 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 8d99fa18b70ae..c7bd6d032fb65 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/Utils.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/Utils.ts @@ -1,5 +1,5 @@ -import { EventEmitter } from 'events'; -import zlib from 'zlib'; +import { EventEmitter } from 'node:events'; +import zlib from 'node:zlib'; import type { Logger } from '@rocket.chat/logger'; diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/signature/validateRedirectSignature.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/signature/validateRedirectSignature.ts index 9dd73052b8214..31c57ceb9bf9e 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/signature/validateRedirectSignature.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/signature/validateRedirectSignature.ts @@ -1,4 +1,4 @@ -import crypto from 'crypto'; +import crypto from 'node:crypto'; import type { SAMLRedirectEnvelope } from '../../definition/SAMLEnvelope'; import { SAMLUtils } from '../Utils'; diff --git a/apps/meteor/app/meteor-accounts-saml/server/listener.ts b/apps/meteor/app/meteor-accounts-saml/server/listener.ts index 8747bd59a8714..665be0506531f 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/listener.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/listener.ts @@ -1,4 +1,4 @@ -import type { IncomingMessage, ServerResponse } from 'http'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import bodyParser from 'body-parser'; import express from 'express'; diff --git a/apps/meteor/app/metrics/server/lib/collectMetrics.ts b/apps/meteor/app/metrics/server/lib/collectMetrics.ts index 6a393bb406506..079e4d1be3692 100644 --- a/apps/meteor/app/metrics/server/lib/collectMetrics.ts +++ b/apps/meteor/app/metrics/server/lib/collectMetrics.ts @@ -1,4 +1,4 @@ -import http from 'http'; +import http from 'node:http'; import { Statistics } from '@rocket.chat/models'; import { tracerSpan } from '@rocket.chat/tracing'; @@ -92,6 +92,7 @@ app.use('/metrics', (_req, res) => { .metrics() .then((data) => { metrics.metricsRequests.inc(); + metrics.metricsRequestsTotal.inc(); metrics.metricsSize.set(data.length); res.end(data); diff --git a/apps/meteor/app/metrics/server/lib/metrics.ts b/apps/meteor/app/metrics/server/lib/metrics.ts index c35cd51f70ca6..939b2edbd0089 100644 --- a/apps/meteor/app/metrics/server/lib/metrics.ts +++ b/apps/meteor/app/metrics/server/lib/metrics.ts @@ -1,18 +1,33 @@ +import { performance } from 'node:perf_hooks'; + import client from 'prom-client'; const percentiles = [0.01, 0.1, 0.5, 0.9, 0.95, 0.99, 1]; +const latencyBuckets = [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0]; +const queueWaitBuckets = [1, 5, 10, 30, 60, 120, 300, 600, 900, 1800, 3600]; + export const metrics = { deprecations: new client.Counter({ name: 'rocketchat_deprecations', labelNames: ['type', 'kind', 'name', 'params'], help: 'cumulated number of deprecations being used', }), + deprecationsTotal: new client.Counter({ + name: 'rocketchat_deprecations_total', + labelNames: ['type', 'kind', 'name', 'params'], + help: 'cumulated number of deprecations being used', + }), metricsRequests: new client.Counter({ name: 'rocketchat_metrics_requests', labelNames: ['notification_type'], help: 'cumulated number of calls to the metrics endpoint', }), + metricsRequestsTotal: new client.Counter({ + name: 'rocketchat_metrics_requests_total', + labelNames: ['notification_type'], + help: 'cumulated number of calls to the metrics endpoint', + }), metricsSize: new client.Gauge({ name: 'rocketchat_metrics_size', help: 'size of the metrics response in chars', @@ -28,24 +43,59 @@ export const metrics = { labelNames: ['method', 'has_connection', 'has_user'], percentiles, }), + meteorMethodsSeconds: new client.Histogram({ + name: 'rocketchat_meteor_methods_seconds', + help: 'histogram of meteor methods count and time in seconds', + labelNames: ['method', 'has_connection', 'has_user'], + buckets: latencyBuckets, + }), rocketchatCallbacks: new client.Summary({ name: 'rocketchat_callbacks', help: 'summary of rocketchat callbacks count and time', labelNames: ['hook', 'callback'], percentiles, }), + rocketchatCallbacksSeconds: new client.Histogram({ + name: 'rocketchat_callbacks_seconds', + help: 'histogram of rocketchat callbacks count and time in seconds', + labelNames: ['hook', 'callback'], + buckets: latencyBuckets, + }), rocketchatHooks: new client.Summary({ name: 'rocketchat_hooks', help: 'summary of rocketchat hooks count and time', labelNames: ['hook', 'callbacks_length'], percentiles, }), + rocketchatHooksSeconds: new client.Histogram({ + name: 'rocketchat_hooks_seconds', + help: 'histogram of rocketchat hooks count and time in seconds', + labelNames: ['hook', 'callbacks_length'], + buckets: latencyBuckets, + }), rocketchatRestApi: new client.Summary({ name: 'rocketchat_rest_api', help: 'summary of rocketchat rest api count and time', labelNames: ['method', 'entrypoint', 'user_agent', 'status', 'version'], percentiles, }), + rocketchatRestApiSeconds: new client.Histogram({ + name: 'rocketchat_rest_api_seconds', + help: 'histogram of rocketchat rest api count and time in seconds', + labelNames: ['method', 'entrypoint', 'status', 'version'], + buckets: latencyBuckets, + }), + rocketchatRestApiResponseSizeBytes: new client.Histogram({ + name: 'rocketchat_rest_api_response_size_bytes', + help: 'histogram of rocketchat rest api response sizes in bytes', + labelNames: ['method', 'entrypoint', 'status', 'version'], + buckets: [0, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000, 1000000000], + }), + rocketchatRestApiActiveRequests: new client.Gauge({ + name: 'rocketchat_rest_api_active_requests', + help: 'number of currently active rest api requests', + labelNames: ['method'], + }), meteorSubscriptions: new client.Summary({ name: 'rocketchat_meteor_subscriptions', @@ -53,16 +103,31 @@ export const metrics = { labelNames: ['subscription'], percentiles, }), + meteorSubscriptionsSeconds: new client.Histogram({ + name: 'rocketchat_meteor_subscriptions_seconds', + help: 'histogram of meteor subscriptions count and time in seconds', + labelNames: ['subscription'], + buckets: latencyBuckets, + }), messagesSent: new client.Counter({ name: 'rocketchat_message_sent', help: 'cumulated number of messages sent', }), + messagesSentTotal: new client.Counter({ + name: 'rocketchat_messages_sent_total', + help: 'cumulated number of messages sent', + }), notificationsSent: new client.Counter({ name: 'rocketchat_notification_sent', labelNames: ['notification_type'], help: 'cumulated number of notifications sent', }), + notificationsSentTotal: new client.Counter({ + name: 'rocketchat_notifications_sent_total', + labelNames: ['notification_type'], + help: 'cumulated number of notifications sent', + }), messageRoundtripTime: new client.Summary({ name: 'rocketchat_messages_roundtrip_time_summary', help: 'time spent by a message from save to receive back', @@ -71,6 +136,11 @@ export const metrics = { ageBuckets: 5, // pruneAgedBuckets: true, // Type not added to prom-client on 14.2 https://github.com/siimon/prom-client/pull/558 }), + messageRoundtripTimeSeconds: new client.Histogram({ + name: 'rocketchat_messages_roundtrip_time_seconds', + help: 'time in seconds spent by a message from save to receive back', + buckets: latencyBuckets, + }), ddpSessions: new client.Gauge({ name: 'rocketchat_ddp_sessions_count', @@ -89,6 +159,11 @@ export const metrics = { labelNames: ['limit_name', 'user_id', 'client_address', 'type', 'name', 'connection_id'], help: 'number of times a ddp rate limiter was exceeded', }), + ddpRateLimitExceededTotal: new client.Counter({ + name: 'rocketchat_ddp_rate_limit_exceeded_total', + labelNames: ['limit_name', 'user_id', 'client_address', 'type', 'name', 'connection_id'], + help: 'number of times a ddp rate limiter was exceeded', + }), version: new client.Gauge({ name: 'rocketchat_version', @@ -189,6 +264,12 @@ export const metrics = { labelNames: ['bridge', 'method', 'app_id'], percentiles, }), + appBridgeMethodsSeconds: new client.Histogram({ + name: 'rocketchat_apps_bridge_methods_seconds', + help: 'histogram of app bridge method calls count and time in seconds', + labelNames: ['bridge', 'method', 'app_id'], + buckets: latencyBuckets, + }), // Meteor Facts meteorFacts: new client.Gauge({ @@ -204,10 +285,18 @@ export const metrics = { name: 'rocketchat_livechat_webhooks_success', help: 'successful livechat webhooks', }), + totalLivechatWebhooksSuccessTotal: new client.Counter({ + name: 'rocketchat_livechat_webhooks_success_total', + help: 'successful livechat webhooks', + }), totalLivechatWebhooksFailures: new client.Counter({ name: 'rocketchat_livechat_webhooks_failures', help: 'failed livechat webhooks', }), + totalLivechatWebhooksFailuresTotal: new client.Counter({ + name: 'rocketchat_livechat_webhooks_failures_total', + help: 'failed livechat webhooks', + }), totalItemsProcessedByQueue: new client.Counter({ name: 'rocketchat_queue_items_processed_total', @@ -230,6 +319,22 @@ export const metrics = { help: 'Time taken in seconds for an item to be processed for the first time by Omni queues', percentiles, }), + timeToQueueProcessingByQueueHistogram: new client.Histogram({ + name: 'rocketchat_queue_wait_duration_seconds', + labelNames: ['queue'], + help: 'Histogram of time taken in seconds for an item to be processed for the first time by Omni queues', + buckets: queueWaitBuckets, + }), }; // Metrics +let eluBase = performance.eventLoopUtilization(); + +new client.Gauge({ + name: 'nodejs_event_loop_utilization_ratio', + help: 'Event Loop Utilization (ELU) as reported by NodeJS', + collect() { + this.set(performance.eventLoopUtilization(eluBase).utilization); + eluBase = performance.eventLoopUtilization(); + }, +}); diff --git a/apps/meteor/app/notifications/client/index.ts b/apps/meteor/app/notifications/client/index.ts deleted file mode 100644 index 47c2de4137407..0000000000000 --- a/apps/meteor/app/notifications/client/index.ts +++ /dev/null @@ -1 +0,0 @@ -import './lib/Presence'; diff --git a/apps/meteor/app/notifications/client/lib/Presence.ts b/apps/meteor/app/notifications/client/lib/Presence.ts deleted file mode 100644 index d6890d12aa52d..0000000000000 --- a/apps/meteor/app/notifications/client/lib/Presence.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 - -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]; - -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 bfb327d8eff1d..fa8a2ac9630ab 100644 --- a/apps/meteor/app/notifications/server/lib/Presence.ts +++ b/apps/meteor/app/notifications/server/lib/Presence.ts @@ -2,6 +2,7 @@ import type { IUser } from '@rocket.chat/core-typings'; import type { StreamerEvents } from '@rocket.chat/ddp-client'; import { Emitter } from '@rocket.chat/emitter'; +import { Streamer } from '../../../../server/modules/streamer/streamer.module'; import type { IPublication, IStreamerConstructor, Connection, IStreamer } from '../../../../server/modules/streamer/types'; type UserPresenceStreamProps = { @@ -48,7 +49,17 @@ class UserPresence { run = (args: UserPresenceStreamArgs): void => { const payload = this.streamer.changedPayload(this.streamer.subscriptionName, args.uid, { ...args, eventName: args.uid }); // there is no good explanation to keep eventName, I just want to save one 'DDPCommon.parseDDP' on the client side, so I'm trying to fit the Meteor Streamer's payload - if (payload) this.publication._session.socket?.send(payload); + if (!payload) { + return; + } + // after meteor 3.4.1 immediately after a disconnection session becomes null (which is not wrong) + // we were just not counting on this, session is _session so we actually should not use it + // now after any await, the session can potentially be null, so we need to check for that + if (!Streamer.isPublicationActive(this.publication)) { + return; + } + + this.publication._session.socket.send(payload); }; stop(): void { diff --git a/apps/meteor/app/push-notifications/server/lib/PushNotification.ts b/apps/meteor/app/push-notifications/server/lib/PushNotification.ts index 7fd7f404ac970..33c752825ae97 100644 --- a/apps/meteor/app/push-notifications/server/lib/PushNotification.ts +++ b/apps/meteor/app/push-notifications/server/lib/PushNotification.ts @@ -103,8 +103,10 @@ class PushNotification { idOnly, }); - metrics.notificationsSent.inc({ notification_type: 'mobile' }); await Push.send(config); + + metrics.notificationsSent.inc({ notification_type: 'mobile' }); + metrics.notificationsSentTotal.inc({ notification_type: 'mobile' }); } async getNotificationForMessageId({ diff --git a/apps/meteor/app/reactions/client/index.ts b/apps/meteor/app/reactions/client/index.ts deleted file mode 100644 index 935ab48e93ad6..0000000000000 --- a/apps/meteor/app/reactions/client/index.ts +++ /dev/null @@ -1 +0,0 @@ -import './methods/setReaction'; diff --git a/apps/meteor/app/reactions/client/methods/setReaction.ts b/apps/meteor/app/reactions/client/methods/setReaction.ts index 96cd4bcb95910..093fdd9ea2b0c 100644 --- a/apps/meteor/app/reactions/client/methods/setReaction.ts +++ b/apps/meteor/app/reactions/client/methods/setReaction.ts @@ -1,84 +1,81 @@ import type { IMessage } from '@rocket.chat/core-typings'; -import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Meteor } from 'meteor/meteor'; import { roomCoordinator } from '../../../../client/lib/rooms/roomCoordinator'; import { getUser, getUserId } from '../../../../client/lib/user'; import { Rooms, Subscriptions, Messages } from '../../../../client/stores'; import { emoji } from '../../../emoji/client'; -Meteor.methods({ - async setReaction(reaction, messageId) { - if (!getUserId()) { - throw new Meteor.Error(203, 'User_logged_out'); - } - - const user = getUser(); +export const runOptimisticSetReaction = (reaction: string, messageId: IMessage['_id']): void => { + if (!getUserId()) { + return; + } - if (!user?.username) { - return false; - } + const user = getUser(); + if (!user?.username) { + return; + } - const message: IMessage | undefined = Messages.state.get(messageId); - if (!message) { - return false; - } + const message: IMessage | undefined = Messages.state.get(messageId); + if (!message) { + return; + } - const room = Rooms.state.get(message.rid); - if (!room) { - return false; - } - - if (message.private) { - return false; - } + const room = Rooms.state.get(message.rid); + if (!room) { + return; + } - if (!emoji.list[reaction]) { - return false; - } + if (message.private) { + return; + } - if (roomCoordinator.readOnly(room, user)) { - return false; - } + if (!emoji.list[reaction]) { + return; + } - if (!Subscriptions.state.find(({ rid }) => rid === message.rid)) { - return false; - } + if (roomCoordinator.readOnly(room, user)) { + return; + } - if (message.reactions?.[reaction] && message.reactions[reaction].usernames.indexOf(user.username) !== -1) { - message.reactions[reaction].usernames.splice(message.reactions[reaction].usernames.indexOf(user.username), 1); + if (!Subscriptions.state.find(({ rid }) => rid === message.rid)) { + return; + } - if (message.reactions[reaction].usernames.length === 0) { - delete message.reactions[reaction]; - } + if (message.reactions?.[reaction] && message.reactions[reaction].usernames.indexOf(user.username) !== -1) { + message.reactions[reaction].usernames.splice(message.reactions[reaction].usernames.indexOf(user.username), 1); - if (!message.reactions || typeof message.reactions !== 'object' || Object.keys(message.reactions).length === 0) { - delete message.reactions; - Messages.state.update( - (record) => record._id === messageId, - ({ reactions: _, ...record }) => record, - ); - } else { - Messages.state.update( - (record) => record._id === messageId, - (record) => ({ ...record, reactions: message.reactions }), - ); - } - } else { - if (!message.reactions) { - message.reactions = {}; - } - if (!message.reactions[reaction]) { - message.reactions[reaction] = { - usernames: [], - }; - } - message.reactions[reaction].usernames.push(user.username); + if (message.reactions[reaction].usernames.length === 0) { + delete message.reactions[reaction]; + } + if (!message.reactions || typeof message.reactions !== 'object' || Object.keys(message.reactions).length === 0) { + delete message.reactions; Messages.state.update( (record) => record._id === messageId, - (record) => ({ ...record, reactions: message.reactions }), + ({ reactions: _, ...record }) => record, ); + return; } - }, -}); + + Messages.state.update( + (record) => record._id === messageId, + (record) => ({ ...record, reactions: message.reactions }), + ); + return; + } + + if (!message.reactions) { + message.reactions = {}; + } + if (!message.reactions[reaction]) { + message.reactions[reaction] = { + usernames: [], + }; + } + message.reactions[reaction].usernames.push(user.username); + + Messages.state.update( + (record) => record._id === messageId, + (record) => ({ ...record, reactions: message.reactions }), + ); +}; diff --git a/apps/meteor/app/slackbridge/server/RocketAdapter.ts b/apps/meteor/app/slackbridge/server/RocketAdapter.ts index cdf4cd8085af9..5c46d75368dda 100644 --- a/apps/meteor/app/slackbridge/server/RocketAdapter.ts +++ b/apps/meteor/app/slackbridge/server/RocketAdapter.ts @@ -3,7 +3,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ // @ts-nocheck -import util from 'util'; +import util from 'node:util'; import { Messages, Rooms, Users } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; diff --git a/apps/meteor/app/slackbridge/server/SlackAdapter.ts b/apps/meteor/app/slackbridge/server/SlackAdapter.ts index 831ca8a67cbce..b02ad32ea13a2 100644 --- a/apps/meteor/app/slackbridge/server/SlackAdapter.ts +++ b/apps/meteor/app/slackbridge/server/SlackAdapter.ts @@ -4,9 +4,9 @@ /* 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'; +import http from 'node:http'; +import https from 'node:https'; +import url from 'node:url'; import { Message } from '@rocket.chat/core-services'; import { Messages, Rooms, Users } from '@rocket.chat/models'; diff --git a/apps/meteor/app/slashcommands-join/client/client.ts b/apps/meteor/app/slashcommands-join/client/client.ts index bc8d589f51ac4..878981a11951c 100644 --- a/apps/meteor/app/slashcommands-join/client/client.ts +++ b/apps/meteor/app/slashcommands-join/client/client.ts @@ -1,5 +1,3 @@ -import type { Meteor } from 'meteor/meteor'; - import { slashCommands } from '../../utils/client/slashCommand'; slashCommands.add({ @@ -10,7 +8,7 @@ slashCommands.add({ permission: 'view-c-room', }, result(err, _result: unknown, params: Record) { - if ((err as Meteor.Error).error === 'error-user-already-in-room') { + if ((err as { error?: string } | undefined)?.error === 'error-user-already-in-room') { params.cmd = 'open'; params.msg.msg = params.msg.msg.replace('join', 'open'); return void slashCommands.run({ command: 'open', params: params.params, message: params.msg, triggerId: '', userId: params.userId }); diff --git a/apps/meteor/app/statistics/server/lib/getAppsStatistics.ts b/apps/meteor/app/statistics/server/lib/getAppsStatistics.ts index 90e05d1923563..1734d175c6c5d 100644 --- a/apps/meteor/app/statistics/server/lib/getAppsStatistics.ts +++ b/apps/meteor/app/statistics/server/lib/getAppsStatistics.ts @@ -1,6 +1,6 @@ import { Apps } from '@rocket.chat/apps'; +import { AppInstallationSource } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem'; import { AppStatus, AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus'; -import { AppInstallationSource } from '@rocket.chat/apps-engine/server/storage'; import mem from 'mem'; import { SystemLogger } from '../../../../server/lib/logger/system'; diff --git a/apps/meteor/app/statistics/server/lib/statistics.ts b/apps/meteor/app/statistics/server/lib/statistics.ts index c0aadb9965691..8c810acaad70b 100644 --- a/apps/meteor/app/statistics/server/lib/statistics.ts +++ b/apps/meteor/app/statistics/server/lib/statistics.ts @@ -1,5 +1,5 @@ import { log } from 'console'; -import os from 'os'; +import os from 'node:os'; import { Analytics, Team, VideoConf, Presence } from '@rocket.chat/core-services'; import type { IRoom, IStats, ISetting } from '@rocket.chat/core-typings'; 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 7486bd9aacb07..289e1c53f00f5 100644 --- a/apps/meteor/app/theme/client/imports/general/base_old.css +++ b/apps/meteor/app/theme/client/imports/general/base_old.css @@ -56,8 +56,9 @@ padding-bottom: 24px; } - & ul.messages-list { - padding: 21px 0 10px; + /* Overlay scrollbars overrides styles, so we need an important here */ + & .messages-list { + padding: 21px 0 10px !important; } } diff --git a/apps/meteor/app/theme/server/server.ts b/apps/meteor/app/theme/server/server.ts index d0342df878973..34b71e38a303d 100644 --- a/apps/meteor/app/theme/server/server.ts +++ b/apps/meteor/app/theme/server/server.ts @@ -1,4 +1,4 @@ -import crypto from 'crypto'; +import crypto from 'node:crypto'; import { Settings } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; diff --git a/apps/meteor/app/ui-master/server/index.ts b/apps/meteor/app/ui-master/server/index.ts index 53f47ca8ab4ef..24a673b3b9b85 100644 --- a/apps/meteor/app/ui-master/server/index.ts +++ b/apps/meteor/app/ui-master/server/index.ts @@ -6,6 +6,7 @@ import { Inject } from 'meteor/meteorhacks:inject-initial'; import { Tracker } from 'meteor/tracker'; import { applyHeadInjections, headInjections, injectIntoBody, injectIntoHead } from './inject'; +import { getMessageMaxParseLength } from '../../../lib/getMessageMaxParseLength'; import { withDebouncing } from '../../../lib/utils/highOrderFunctions'; import { settings } from '../../settings/server'; import { getURL } from '../../utils/server/getURL'; @@ -28,6 +29,10 @@ Meteor.startup(() => { injectIntoHead('noreferrer', ``); }); + settings.watch('Use_RC_SDK', (value) => { + injectIntoHead('Use_RC_SDK', ``); + }); + if (process.env.DISABLE_ANIMATION) { injectIntoHead( 'disable-animation', @@ -122,6 +127,9 @@ Meteor.startup(() => { })(__meteor_runtime_config__.ROOT_URL_PATH_PREFIX); injectIntoHead('base', ``); + + const escapedMessageMaxParseLength = escapeHTML(String(getMessageMaxParseLength())); + injectIntoHead('MESSAGE_MAX_PARSE_LENGTH', ``); }); const renderDynamicCssList = withDebouncing({ wait: 500 })(async () => { diff --git a/apps/meteor/app/ui-master/server/inject.ts b/apps/meteor/app/ui-master/server/inject.ts index 47b63db4bb3f8..07423bc8ac5c7 100644 --- a/apps/meteor/app/ui-master/server/inject.ts +++ b/apps/meteor/app/ui-master/server/inject.ts @@ -1,4 +1,4 @@ -import crypto from 'crypto'; +import crypto from 'node:crypto'; import type { NextHandleFunction } from 'connect'; import { Inject } from 'meteor/meteorhacks:inject-initial'; diff --git a/apps/meteor/app/ui-master/server/scripts.ts b/apps/meteor/app/ui-master/server/scripts.ts index b7e423b51d655..485a4636a8da8 100644 --- a/apps/meteor/app/ui-master/server/scripts.ts +++ b/apps/meteor/app/ui-master/server/scripts.ts @@ -33,22 +33,7 @@ window.addEventListener('Custom_Script_On_Logout', function() { ${settings.get('Custom_Script_On_Logout')} }) -${ - settings.get('Accounts_ForgetUserSessionOnWindowClose') - ? ` -window.addEventListener('load', function() { - if (window.localStorage) { - Object.keys(window.localStorage).forEach(function(key) { - window.sessionStorage.setItem(key, window.localStorage.getItem(key)); - }); - window.localStorage.clear(); - Meteor._localStorage = window.sessionStorage; - Accounts.config({ clientStorage: 'session' }); - } -}); -` - : '' -}`; +${settings.get('Accounts_ForgetUserSessionOnWindowClose') ? `window.Accounts_ForgetUserSessionOnWindowClose = true;` : ''}`; settings.watchMultiple( ['Custom_Script_Logged_Out', 'Custom_Script_Logged_In', 'Custom_Script_On_Logout', 'Accounts_ForgetUserSessionOnWindowClose'], diff --git a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts index 4922bc150d3e6..04447635ad45e 100644 --- a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts +++ b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts @@ -1,7 +1,5 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; -import { Accounts } from 'meteor/accounts-base'; -import { Tracker } from 'meteor/tracker'; import type { RefObject } from 'react'; import { limitQuoteChain } from './limitQuoteChain'; @@ -9,11 +7,13 @@ import type { FormattingButton } from './messageBoxFormatting'; import { formattingButtons } from './messageBoxFormatting'; import type { ComposerAPI } from '../../../../client/lib/chats/ChatAPI'; import { createUploadsAPI } from '../../../../client/lib/chats/uploads'; +import { settings } from '../../../../client/lib/settings'; import { withDebouncing } from '../../../../lib/utils/highOrderFunctions'; export const createComposerAPI = ( input: HTMLTextAreaElement, - storageID: string, + persistDraft: (value: string) => void, + initialDraft: string, quoteChainLimit: number, composerRef: RefObject, { rid, tmid }: { rid: string; tmid?: string }, @@ -40,20 +40,13 @@ export const createComposerAPI = ( let _quotedMessages: IMessage[] = []; const persist = withDebouncing({ wait: 300 })(() => { - if (input.value) { - Accounts.storageLocation.setItem(storageID, input.value); - return; - } - - Accounts.storageLocation.removeItem(storageID); + persistDraft(input.value); }); const notifyQuotedMessagesUpdate = (): void => { emitter.emit('quotedMessagesUpdate'); }; - input.addEventListener('input', persist); - const setText = ( text: string, { @@ -202,26 +195,31 @@ export const createComposerAPI = ( setEditing(editing); }; - const [formatters, stopFormatterTracker] = (() => { + const [formatters, stopFormatterSubscription] = (() => { let actions: FormattingButton[] = []; - const c = Tracker.autorun(() => { + const recompute = (): void => { actions = formattingButtons.filter(({ condition }) => !condition || condition()); emitter.emit('formatting'); - }); + }; + recompute(); + // Coarse-grained: fires on every setting change, but the only condition() + // today is Katex_Enabled and the recompute is a cheap zustand read, so the + // extra work per unrelated setting change is negligible. + const stop = settings.observe('*', recompute); return [ { get: () => actions, subscribe: (callback: () => void) => emitter.on('formatting', callback), }, - c, + stop, ]; })(); const release = (): void => { input.removeEventListener('input', persist); - stopFormatterTracker.stop(); + stopFormatterSubscription(); }; const wrapSelection = (pattern: string): { selectionStart: number; selectionEnd: number; value: string } => { @@ -282,10 +280,12 @@ export const createComposerAPI = ( const insertNewLine = (): void => insertText('\n'); - setText(Accounts.storageLocation.getItem(storageID) ?? '', { + setText(initialDraft, { skipFocus: true, }); + input.addEventListener('input', persist); + // Gets the text that is connected to the cursor and replaces it with the given text const replaceText = (text: string, selection: { readonly start: number; readonly end: number }): void => { const { selectionStart, selectionEnd } = input; diff --git a/apps/meteor/app/ui-message/client/messageBox/messageBoxFormatting.ts b/apps/meteor/app/ui-message/client/messageBox/messageBoxFormatting.ts index af58cbe8590b4..6a3862e490dd0 100644 --- a/apps/meteor/app/ui-message/client/messageBox/messageBoxFormatting.ts +++ b/apps/meteor/app/ui-message/client/messageBox/messageBoxFormatting.ts @@ -101,6 +101,6 @@ export const formattingButtons: ReadonlyArray = [ } }, link: 'https://khan.github.io/KaTeX/function-support.html', - condition: () => settings.watch('Katex_Enabled') ?? true, + condition: () => settings.peek('Katex_Enabled') ?? true, }, ] as const; diff --git a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts index 14b1d291ef016..95f94a4e356c7 100644 --- a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts +++ b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts @@ -1,9 +1,7 @@ import type { IMessage, IRoom, ISubscription } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { differenceInMilliseconds } from 'date-fns'; -import { ReactiveVar } from 'meteor/reactive-var'; -import { Tracker } from 'meteor/tracker'; -import type { MutableRefObject } from 'react'; +import { useCallback, useSyncExternalStore } from 'react'; import { onClientMessageReceived } from '../../../../client/lib/onClientMessageReceived'; import { getUserId } from '../../../../client/lib/user'; @@ -13,8 +11,6 @@ import { waitForElement } from '../../../../client/lib/utils/waitForElement'; import { Messages, Subscriptions } from '../../../../client/stores'; import { getUserPreference } from '../../../utils/client'; -const waitAfterFlush = () => new Promise((resolve) => Tracker.afterFlush(() => resolve(void 0))); - const processMessage = async (msg: IMessage & { ignored?: boolean }, { subscription }: { subscription?: ISubscription }) => { const userId = msg.u?._id; @@ -46,36 +42,37 @@ export async function upsertMessageBulk({ const defaultLimit = parseInt(getConfig('roomListLimit') ?? '50') || 50; +export type RoomHistoryState = { + hasMore: boolean; + hasMoreNext: boolean; + isLoading: boolean; + unreadNotLoaded: number; + firstUnread: IMessage | undefined; + loaded: number | undefined; + oldestTs?: Date; + scroll?: { + scrollHeight: number; + scrollTop: number; + }; +}; + +const roomStateEvent = (rid: IRoom['_id']) => `state:${rid}` as const; + class RoomHistoryManagerClass extends Emitter { private lastRequest?: Date; - private histories: Record< - IRoom['_id'], - { - hasMore: ReactiveVar; - hasMoreNext: ReactiveVar; - isLoading: ReactiveVar; - unreadNotLoaded: ReactiveVar; - firstUnread: ReactiveVar; - loaded: number | undefined; - oldestTs?: Date; - scroll?: { - scrollHeight: number; - scrollTop: number; - }; - } - > = {}; + private histories: Record = {}; private requestsList: string[] = []; - public getRoom(rid: IRoom['_id']) { + public getRoom(rid: IRoom['_id']): RoomHistoryState { if (!this.histories[rid]) { this.histories[rid] = { - hasMore: new ReactiveVar(true), - hasMoreNext: new ReactiveVar(false), - isLoading: new ReactiveVar(false), - unreadNotLoaded: new ReactiveVar(0), - firstUnread: new ReactiveVar(undefined), + hasMore: true, + hasMoreNext: false, + isLoading: false, + unreadNotLoaded: 0, + firstUnread: undefined, loaded: undefined, }; } @@ -83,6 +80,16 @@ class RoomHistoryManagerClass extends Emitter { return this.histories[rid]; } + public updateRoom(rid: IRoom['_id'], patch: Partial): void { + const room = this.getRoom(rid); + Object.assign(room, patch); + this.emit(roomStateEvent(rid), room); + } + + public subscribeToRoom(rid: IRoom['_id'], cb: (state: RoomHistoryState) => void): () => void { + return this.on(roomStateEvent(rid), cb); + } + private async queue(): Promise { return new Promise((resolve) => { const requestId = crypto.randomUUID(); @@ -122,12 +129,12 @@ class RoomHistoryManagerClass extends Emitter { public async getMore(rid: IRoom['_id'], { limit = defaultLimit }: { limit?: number } = {}): Promise { const room = this.getRoom(rid); - if (Tracker.nonreactive(() => room.hasMore.get()) !== true) { + if (room.hasMore !== true) { return; } try { - room.isLoading.set(true); + this.updateRoom(rid, { isLoading: true }); await this.queue(); @@ -155,8 +162,10 @@ class RoomHistoryManagerClass extends Emitter { this.unqueue(); const { messages = [] } = result; - room.unreadNotLoaded.set(result.unreadNotLoaded); - room.firstUnread.set(result.firstUnread); + this.updateRoom(rid, { + unreadNotLoaded: result.unreadNotLoaded, + firstUnread: result.firstUnread, + }); if (messages.length > 0) { room.oldestTs = messages[messages.length - 1].ts; @@ -185,17 +194,16 @@ class RoomHistoryManagerClass extends Emitter { room.loaded += visibleMessages.length; if (messages.length < limit) { - room.hasMore.set(false); + this.updateRoom(rid, { hasMore: false }); } - if (room.hasMore.get() && (visibleMessages.length === 0 || room.loaded < limit)) { + if (room.hasMore && (visibleMessages.length === 0 || room.loaded < limit)) { return this.getMore(rid); } this.emit('loaded-messages'); } finally { - room.isLoading.set(false); - await waitAfterFlush(); + this.updateRoom(rid, { isLoading: false }); } } @@ -216,16 +224,15 @@ class RoomHistoryManagerClass extends Emitter { room.scroll = undefined; } - public async getMoreNext(rid: IRoom['_id'], atBottomRef: MutableRefObject) { + public async getMoreNext(rid: IRoom['_id']) { const room = this.getRoom(rid); - if (Tracker.nonreactive(() => room.hasMoreNext.get()) !== true) { + if (room.hasMoreNext !== true) { return; } await this.queue(); - atBottomRef.current = false; - room.isLoading.set(true); + this.updateRoom(rid, { isLoading: true }); const lastMessage = Messages.state.findFirst( (record) => record.rid === rid && record._hidden !== true, @@ -244,27 +251,25 @@ class RoomHistoryManagerClass extends Emitter { this.emit('loaded-messages'); - room.isLoading.set(false); + this.updateRoom(rid, { isLoading: false }); if (!room.loaded) { room.loaded = 0; } room.loaded += result.messages.length; if (result.messages.length < defaultLimit) { - room.hasMoreNext.set(false); + this.updateRoom(rid, { hasMoreNext: false }); } } this.unqueue(); } public hasMore(rid: IRoom['_id']) { - const room = this.getRoom(rid); - return room.hasMore.get(); + return this.getRoom(rid).hasMore; } public hasMoreNext(rid: IRoom['_id']) { - const room = this.getRoom(rid); - return room.hasMoreNext.get(); + return this.getRoom(rid).hasMoreNext; } public getMoreIfIsEmpty(rid: IRoom['_id']) { @@ -276,8 +281,7 @@ class RoomHistoryManagerClass extends Emitter { } public isLoading(rid: IRoom['_id']) { - const room = this.getRoom(rid); - return room.isLoading.get(); + return this.getRoom(rid).isLoading; } public close(rid: IRoom['_id']) { @@ -288,11 +292,13 @@ class RoomHistoryManagerClass extends Emitter { public clear(rid: IRoom['_id']) { const room = this.getRoom(rid); Messages.state.remove((record) => record.rid === rid); - room.isLoading.set(false); - room.hasMore.set(true); - room.hasMoreNext.set(false); room.oldestTs = undefined; room.loaded = undefined; + this.updateRoom(rid, { + isLoading: false, + hasMore: true, + hasMoreNext: false, + }); } public async getSurroundingMessages(message?: Pick & { ts?: Date }) { @@ -315,6 +321,7 @@ class RoomHistoryManagerClass extends Emitter { } const room = this.getRoom(message.rid); + this.updateRoom(message.rid, { isLoading: true }); const subscription = Subscriptions.state.find((record) => record.rid === message.rid); const result = await callWithErrorHandling('loadSurroundingMessages', message, defaultLimit, showThreadMessages); @@ -322,6 +329,7 @@ class RoomHistoryManagerClass extends Emitter { this.clear(message.rid); if (!result) { + this.updateRoom(message.rid, { isLoading: false }); return; } const { messages = [] } = result; @@ -332,18 +340,24 @@ class RoomHistoryManagerClass extends Emitter { await upsertMessageBulk({ msgs: Array.from(result.messages).filter((msg) => msg.t !== 'command'), subscription }); - Tracker.afterFlush(async () => { - this.emit('loaded-messages'); - room.isLoading.set(false); - }); + this.emit('loaded-messages'); + this.updateRoom(message.rid, { isLoading: false }); if (!room.loaded) { room.loaded = 0; } room.loaded += result.messages.length; - room.hasMore.set(result.moreBefore); - room.hasMoreNext.set(result.moreAfter); + this.updateRoom(message.rid, { + hasMore: result.moreBefore, + hasMoreNext: result.moreAfter, + }); } } export const RoomHistoryManager = new RoomHistoryManagerClass(); + +export const useRoomHistoryState = (rid: IRoom['_id'], selector: (state: RoomHistoryState) => T): T => + useSyncExternalStore( + useCallback((onStoreChange) => RoomHistoryManager.subscribeToRoom(rid, onStoreChange), [rid]), + () => selector(RoomHistoryManager.getRoom(rid)), + ); diff --git a/apps/meteor/app/ui/client/lib/recorderjs/AudioEncoder.ts b/apps/meteor/app/ui/client/lib/recorderjs/AudioEncoder.ts index 2360c69e354aa..4a45970bda38b 100644 --- a/apps/meteor/app/ui/client/lib/recorderjs/AudioEncoder.ts +++ b/apps/meteor/app/ui/client/lib/recorderjs/AudioEncoder.ts @@ -1,5 +1,6 @@ import { Emitter } from '@rocket.chat/emitter'; -import { Meteor } from 'meteor/meteor'; + +import { absoluteUrl } from '../../../../../client/lib/absoluteUrl'; export class AudioEncoder extends Emitter { private worker: Worker; @@ -9,7 +10,7 @@ export class AudioEncoder extends Emitter { constructor(source: MediaStreamAudioSourceNode, { bufferLen = 4096, numChannels = 1, bitRate = 32 } = {}) { super(); - const workerPath = Meteor.absoluteUrl('workers/mp3-encoder/index.js'); + const workerPath = absoluteUrl('workers/mp3-encoder/index.js'); this.worker = new Worker(workerPath); this.worker.onmessage = this.handleWorkerMessage; diff --git a/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.spec.ts b/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.spec.ts index f0a4e58f8a6f8..3ef71d274e547 100644 --- a/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.spec.ts +++ b/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.spec.ts @@ -1,18 +1,6 @@ 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; @@ -93,7 +81,7 @@ describe('VideoRecorder', () => { streamDeferred.resolve(mockStream); await jest.runAllTimersAsync(); - expect(VideoRecorder.cameraStarted.get()).toBe(false); + expect(VideoRecorder.cameraStarted).toBe(false); }); it('should handle multiple start/stop cycles', async () => { @@ -115,7 +103,7 @@ describe('VideoRecorder', () => { await jest.runAllTimersAsync(); expect(cb).toHaveBeenCalledWith(true); - expect(VideoRecorder.cameraStarted.get()).toBe(true); + expect(VideoRecorder.cameraStarted).toBe(true); }); it('should invalidate pending callbacks from previous start when new start is called', async () => { @@ -155,19 +143,19 @@ describe('VideoRecorder', () => { await jest.runAllTimersAsync(); expect(cb).toHaveBeenCalledWith(true); - expect(VideoRecorder.cameraStarted.get()).toBe(true); + expect(VideoRecorder.cameraStarted).toBe(true); }); it('should stop camera tracks', () => { (VideoRecorder as any).stream = mockStream; (VideoRecorder as any).started = true; - VideoRecorder.cameraStarted.set(true); + (VideoRecorder as any)._cameraStarted = true; VideoRecorder.stop(); expect(mockVideoTrack.stop).toHaveBeenCalled(); expect(mockAudioTrack.stop).toHaveBeenCalled(); - expect(VideoRecorder.cameraStarted.get()).toBe(false); + expect(VideoRecorder.cameraStarted).toBe(false); }); it('should return supported mime types', () => { diff --git a/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.ts b/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.ts index 0557f4706cd8b..53cd04eaa5be6 100644 --- a/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.ts +++ b/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.ts @@ -1,13 +1,16 @@ -import { ReactiveVar } from 'meteor/reactive-var'; +import { Emitter } from '@rocket.chat/emitter'; +import { useCallback, useSyncExternalStore } from 'react'; -class VideoRecorder { - public cameraStarted = new ReactiveVar(false); +type VideoRecorderEvents = { + cameraStartedChange: boolean; +}; - private started = false; +class VideoRecorder extends Emitter { + private _cameraStarted = false; - private recording = new ReactiveVar(false); + private started = false; - private recordingAvailable = new ReactiveVar(false); + private recordingAvailable = false; private videoel: HTMLVideoElement | undefined; @@ -19,6 +22,18 @@ class VideoRecorder { private sessionId = 0; + public get cameraStarted(): boolean { + return this._cameraStarted; + } + + private setCameraStarted(value: boolean) { + if (this._cameraStarted === value) { + return; + } + this._cameraStarted = value; + this.emit('cameraStartedChange', value); + } + public getSupportedMimeTypes() { if (window.MediaRecorder.isTypeSupported('video/webm')) { return 'video/webm; codecs=vp8,opus'; @@ -71,12 +86,11 @@ class VideoRecorder { this.mediaRecorder = new MediaRecorder(this.stream, { mimeType: this.getSupportedMimeTypes() }); this.mediaRecorder.ondataavailable = (blobev) => { this.chunks.push(blobev.data); - if (!this.recordingAvailable.get()) { - return this.recordingAvailable.set(true); + if (!this.recordingAvailable) { + this.recordingAvailable = true; } }; this.mediaRecorder.start(); - return this.recording.set(true); } private stopStreamTracks(stream: MediaStream) { @@ -109,7 +123,7 @@ class VideoRecorder { } this.started = true; - return this.cameraStarted.set(true); + this.setCameraStarted(true); } public stop(cb?: (blob: Blob) => void) { @@ -128,8 +142,8 @@ class VideoRecorder { const wasStarted = this.started; this.started = false; - this.cameraStarted.set(false); - this.recordingAvailable.set(false); + this.setCameraStarted(false); + this.recordingAvailable = false; if (cb && this.chunks && wasStarted) { const blob = new Blob(this.chunks); @@ -141,12 +155,11 @@ class VideoRecorder { } public stopRecording() { - if (!this.started || !this.recording || !this.mediaRecorder) { + if (!this.started || !this.mediaRecorder) { return; } this.mediaRecorder.stop(); - this.recording.set(false); delete this.mediaRecorder; } } @@ -154,3 +167,9 @@ class VideoRecorder { const instance = new VideoRecorder(); export { instance as VideoRecorder }; + +export const useVideoRecorderCameraStarted = (): boolean => + useSyncExternalStore( + useCallback((onStoreChange) => instance.on('cameraStartedChange', onStoreChange), []), + () => instance.cameraStarted, + ); diff --git a/apps/meteor/app/utils/client/getRoomAvatarURL.ts b/apps/meteor/app/utils/client/getRoomAvatarURL.ts index 5abf77faa59b1..b15588bed486c 100644 --- a/apps/meteor/app/utils/client/getRoomAvatarURL.ts +++ b/apps/meteor/app/utils/client/getRoomAvatarURL.ts @@ -4,7 +4,7 @@ import { getAvatarURL } from './getAvatarURL'; import { settings } from '../../../client/lib/settings'; export const getRoomAvatarURL = ({ roomId, cache = '' }: { roomId: IRoom['_id']; cache: IRoom['avatarETag'] }) => { - const externalSource = (settings.watch('Accounts_RoomAvatarExternalProviderUrl') || '').trim().replace(/\/$/, ''); + const externalSource = (settings.peek('Accounts_RoomAvatarExternalProviderUrl') || '').trim().replace(/\/$/, ''); if (externalSource && typeof externalSource === 'string') { return externalSource.replace('{roomId}', roomId); } diff --git a/apps/meteor/app/utils/client/getURL.ts b/apps/meteor/app/utils/client/getURL.ts index b427c7110ccef..0a292e6009589 100644 --- a/apps/meteor/app/utils/client/getURL.ts +++ b/apps/meteor/app/utils/client/getURL.ts @@ -14,8 +14,8 @@ export const getURL = function ( cloudDeepLinkUrl?: string, cacheKey?: boolean, ): string { - const cdnPrefix = settings.watch('CDN_PREFIX') || ''; - const siteUrl = settings.watch('Site_Url') || ''; + const cdnPrefix = settings.peek('CDN_PREFIX') || ''; + const siteUrl = settings.peek('Site_Url') || ''; if (cacheKey) { path += `${path.includes('?') ? '&' : '?'}cacheKey=${Info.version}`; diff --git a/apps/meteor/app/utils/client/lib/RestApiClient.ts b/apps/meteor/app/utils/client/lib/RestApiClient.ts index 3ea1d39f4f2f6..7cdb81beb7835 100644 --- a/apps/meteor/app/utils/client/lib/RestApiClient.ts +++ b/apps/meteor/app/utils/client/lib/RestApiClient.ts @@ -1,9 +1,9 @@ /* eslint-disable react-hooks/rules-of-hooks */ import { RestClient } from '@rocket.chat/api-client'; -import { Accounts } from 'meteor/accounts-base'; import { invokeTwoFactorModal } from '../../../../client/lib/2fa/process2faReturn'; import { baseURI } from '../../../../client/lib/baseURI'; +import { STORAGE_KEYS, getStoredItem } from '../../../../client/lib/sdk/storage'; class RestApiClient extends RestClient { override getCredentials(): @@ -12,10 +12,7 @@ class RestApiClient extends RestClient { 'X-Auth-Token': string; } | undefined { - const [uid, token] = [ - Accounts.storageLocation.getItem(Accounts.USER_ID_KEY), - Accounts.storageLocation.getItem(Accounts.LOGIN_TOKEN_KEY), - ]; + const [uid, token] = [getStoredItem(STORAGE_KEYS.USER_ID), getStoredItem(STORAGE_KEYS.LOGIN_TOKEN)]; if (!uid || !token) { return; diff --git a/apps/meteor/app/utils/client/lib/SDKClient.ts b/apps/meteor/app/utils/client/lib/SDKClient.ts index d064f52e9ef74..60ac7001fc08d 100644 --- a/apps/meteor/app/utils/client/lib/SDKClient.ts +++ b/apps/meteor/app/utils/client/lib/SDKClient.ts @@ -1,11 +1,12 @@ import type { RestClientInterface } from '@rocket.chat/api-client'; import type { SDK, ClientStream, StreamKeys, StreamNames, StreamerCallbackArgs, ServerMethods } from '@rocket.chat/ddp-client'; import { Emitter } from '@rocket.chat/emitter'; -import { Accounts } from 'meteor/accounts-base'; -import { DDPCommon } from 'meteor/ddp-common'; import { Meteor } from 'meteor/meteor'; import { APIClient } from './RestApiClient'; +import { parseDDP } from '../../../../client/lib/sdk/ddpProtocol'; +import { ensureConnectedAndAuthenticated, getDdpSdk } from '../../../../client/lib/sdk/ddpSdk'; +import { isSdkTransportEnabled } from '../../../../client/lib/sdk/sdkTransportEnabled'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -15,10 +16,13 @@ declare module '@rocket.chat/ddp-client' { args: [key: K, ...args: unknown[]], callback: (...args: StreamerCallbackArgs) => void, ): ReturnType; + onAnyStreamEvent(name: N, callback: (eventName: string, args: unknown[]) => void): { stop: () => void }; call(method: T, ...args: Parameters): Promise>; } } +const sdkTransportEnabled = isSdkTransportEnabled(); + const isChangedCollectionPayload = ( msg: any, ): msg is { msg: 'changed'; collection: string; fields: { eventName: string; args: unknown[] } } => { @@ -142,28 +146,135 @@ const createNewMeteorStream = (streamName: StreamNames, key: StreamKeys, + streamName: StreamNames, + key: StreamKeys, + args: unknown[], +): StreamMapValue => { + const ee = new Emitter<{ + ready: [error: any] | [undefined, any]; + error: [error: any]; + stop: undefined; + }>(); + const meta = { ready: false }; + + // Defer the actual `subscribe` until DDPSDK is authenticated. Without this, + // stream subscriptions fired immediately after re-login (e.g. the + // SubscriptionsCachedStore's `notify-user//subscriptions-changed` + // listener that re-arms via onLoggedIn) hit the SDK socket while it's + // still anonymous — server rejects with `not-allowed`/`nosub`, the + // stream's `ready` promise emits an error, and the cached store never + // receives subsequent server events. The visible failure: an agent that + // just took a livechat chat post-relogin sees the chat work but the + // "Move to the queue" button never appears, because the new subscription + // the server creates for that agent is never replicated to the client's + // Subscriptions store, and pseudoRoom (= {...sub, ...room}) ends up with + // no `u` for the canMoveQueue check. + let subscription: ReturnType['client']['subscribe']> | undefined; + let offCollection: (() => void) | undefined; + let stopped = false; + + void ensureConnectedAndAuthenticated() + .catch(() => undefined) + .then(() => { + if (stopped) return; + const sdk = getDdpSdk(); + subscription = sdk.client.subscribe(`stream-${streamName}`, key, { useCollection: false, args }); + + subscription + .ready() + .then(() => { + if (stopped) return; + meta.ready = true; + ee.emit('ready', [undefined, { msg: 'ready', subs: [subscription!.id] }]); + }) + .catch((err) => { + if (stopped) return; + ee.emit('ready', [err]); + ee.emit('error', err); + }); + + offCollection = sdk.client.onCollection(`stream-${streamName}`, (data: any) => { + if (data?.msg !== 'changed') return; + if (data.collection !== `stream-${streamName}`) return; + if (data.fields?.eventName !== key) return; + streamProxy.emit(`stream-${streamName}/${key}`, data.fields.args); + }); + }); + + const onChange: ReturnType['onChange'] = (cb) => { + if (meta.ready) { + cb({ msg: 'ready', subs: [] }); + return; + } + ee.once('ready', ([error, result]) => { + if (error) { + cb({ msg: 'nosub', id: '', error }); + return; + } + cb(result); + }); + }; + + return { + stop: () => { + // Mirror Meteor's subscription semantics: explicit stop() does not fire the + // 'stop' event (onStop is reserved for server-initiated closures). + // Emitting it here would recurse through the onStop handler that + // createStreamManager registers, which itself iterates the unsubList. + stopped = true; + offCollection?.(); + subscription?.stop(); + }, + onChange, + ready: () => { + if (meta.ready) return Promise.resolve(); + return new Promise((resolve, reject) => { + ee.once('ready', ([err]) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + }, + onError: (cb: (...args: any[]) => void) => ee.once('error', (error) => cb(error)), + onStop: (cb: () => void) => ee.once('stop', cb), + get isReady() { + return meta.ready; + }, + unsubList: new Set(), + }; +}; + const createStreamManager = () => { // Emitter that replicates stream messages to registered callbacks const streamProxy = new Emitter(); - // Collection of unsubscribe callbacks for each stream. - // const proxyUnsubLists = new Map void>>(); - const streams = new Map(); - Accounts.onLogout(() => { + getDdpSdk().account.onLogout(() => { streams.forEach((stream) => { stream.unsubList.forEach((stop) => stop()); }); }); - Meteor.connection._stream.on('message', (rawMsg: string) => { - const msg = DDPCommon.parseDDP(rawMsg); - if (!isChangedCollectionPayload(msg)) { - return; - } - streamProxy.emit(`${msg.collection}/${msg.fields.eventName}` as any, msg.fields.args as any); - }); + if (!sdkTransportEnabled) { + // In legacy Meteor mode, stream frames arrive on Meteor.connection._stream + // as `changed` collection messages — bridge them into streamProxy so the + // per-stream callbacks fire. With SDK transport on, the frames arrive on + // the SDK socket and createNewDdpSdkStream registers its own onCollection + // listener instead. + Meteor.connection._stream!.on('message', (rawMsg: string) => { + const msg = parseDDP(rawMsg); + if (!isChangedCollectionPayload(msg)) { + return; + } + streamProxy.emit(`${msg.collection}/${msg.fields.eventName}` as any, msg.fields.args as any); + }); + } const stream: SDK['stream'] = >( name: N, @@ -186,7 +297,11 @@ const createStreamManager = () => { streamProxy.on(eventLiteral, proxyCallback); - const stream = streams.get(eventLiteral) || createNewMeteorStream(name, key, args); + const stream = + streams.get(eventLiteral) || + (sdkTransportEnabled + ? createNewDdpSdkStream(streamProxy, name, key as StreamKeys, args) + : createNewMeteorStream(name, key as StreamKeys, args)); const stop = (): void => { streamProxy.off(eventLiteral, proxyCallback); @@ -238,33 +353,105 @@ const createStreamManager = () => { return { stream, stopAll }; }; +// Per-stream wildcard emitters for `onAnyStreamEvent`. Each emitter is fed by +// up to two sources (de-duplicated by the underlying bridges): +// 1) `getDdpSdk().client.onCollection(...)` — the canonical bridge. Covers +// both transport-OFF (via `meteorBackedSdk.onCollection`, which listens +// on `Meteor.connection._stream`) and transport-ON (via the real DDPSDK +// socket). +// 2) When SDK transport is ON, a direct `Meteor.connection._stream` bridge +// to catch frames that land on Meteor while the SDK socket is still +// authenticating — see `apps/meteor/client/lib/presence.ts`'s fallback +// to `Meteor.subscribe('stream-user-presence', ...)`. This is the +// "artificial trigger" and is TEMPORARY: it disappears once SDK +// transport rollout completes and the Meteor fallback is removed. +// +// Bridges are wired exactly once per stream name (singleton listeners on the +// underlying Meteor stream — Meteor's `on()` has no `off()`, so we can't +// detach them per subscription anyway). Consumers register/unregister on the +// per-stream Emitter, which DOES support `off`, so unsubscription is clean. +const anyStreamEmitters = new Map>>(); +const anyStreamBridged = new Set(); + +const createOnAnyStreamEvent = () => { + return (name: N, callback: (eventName: string, args: unknown[]) => void): { stop: () => void } => { + const collectionId = `stream-${name}`; + + let emitter = anyStreamEmitters.get(collectionId); + if (!emitter) { + emitter = new Emitter>(); + anyStreamEmitters.set(collectionId, emitter); + } + + if (!anyStreamBridged.has(collectionId)) { + anyStreamBridged.add(collectionId); + const bridgeEmitter = emitter; + + // Primary bridge — works for both transport-OFF (meteorBackedSdk's + // onCollection listens on Meteor.connection._stream) and transport-ON + // (real DDPSDK socket). + getDdpSdk().client.onCollection(collectionId, (data: unknown) => { + if (!isChangedCollectionPayload(data)) return; + if (data.collection !== collectionId) return; + bridgeEmitter.emit('event', [data.fields.eventName, data.fields.args]); + }); + + // Temporary secondary bridge — see block comment above. Only needed + // while SDK transport is on AND Meteor.connection may still receive + // frames during the SDK socket's anonymous window. + if (sdkTransportEnabled) { + Meteor.connection._stream!.on('message', (rawMsg: string) => { + let msg: unknown; + try { + msg = parseDDP(rawMsg); + } catch { + return; + } + if (!isChangedCollectionPayload(msg)) return; + if (msg.collection !== collectionId) return; + bridgeEmitter.emit('event', [msg.fields.eventName, msg.fields.args]); + }); + } + } + + const handler = ([eventName, args]: [string, unknown[]]): void => { + callback(eventName, args); + }; + emitter.on('event', handler); + + return { + stop: () => { + emitter.off('event', handler); + }, + }; + }; +}; + export const createSDK = (rest: RestClientInterface) => { const { stream, stopAll } = createStreamManager(); + const onAnyStreamEvent = createOnAnyStreamEvent(); - const publish = (name: string, args: unknown[]) => { - Meteor.call(`stream-${name}`, ...args); - }; + const publish = sdkTransportEnabled + ? (name: string, args: unknown[]) => { + // DDPSDK queues outbound frames until the WebSocket handshake completes, + // so there's no need to gate on an isReady flag here. + void getDdpSdk().client.callAsync(`stream-${name}`, ...args); + } + : (name: string, args: unknown[]) => { + Meteor.call(`stream-${name}`, ...args); + }; const call = (method: T, ...args: Parameters): Promise> => { return Meteor.callAsync(method, ...args); }; - const disconnect = () => { - Meteor.disconnect(); - }; - - const reconnect = () => { - Meteor.reconnect(); - }; - return { rest, stop: stopAll, stream, + onAnyStreamEvent, publish, call, - disconnect, - reconnect, }; }; diff --git a/apps/meteor/app/utils/client/lib/getUserPreference.ts b/apps/meteor/app/utils/client/lib/getUserPreference.ts index ac3aec3e0c5ac..0e523fad8d3e2 100644 --- a/apps/meteor/app/utils/client/lib/getUserPreference.ts +++ b/apps/meteor/app/utils/client/lib/getUserPreference.ts @@ -43,5 +43,5 @@ export function getUserPreference( defaultValue?: TValue, ): TValue { const user = typeof userIdOrUser === 'string' ? Users.state.get(userIdOrUser) : userIdOrUser; - return user?.settings?.preferences?.[key] ?? defaultValue ?? settings.watch(`Accounts_Default_User_Preferences_${key}`); + return user?.settings?.preferences?.[key] ?? defaultValue ?? settings.peek(`Accounts_Default_User_Preferences_${key}`); } diff --git a/apps/meteor/app/utils/client/restrictions.ts b/apps/meteor/app/utils/client/restrictions.ts index 4e55bb1a52b90..84a1db0272d8d 100644 --- a/apps/meteor/app/utils/client/restrictions.ts +++ b/apps/meteor/app/utils/client/restrictions.ts @@ -2,8 +2,8 @@ import { settings } from '../../../client/lib/settings'; import { fileUploadIsValidContentTypeFromSettings } from '../lib/restrictions'; export const fileUploadIsValidContentType = function (type: string | undefined, customWhiteList?: string): boolean { - const blackList = settings.watch('FileUpload_MediaTypeBlackList') ?? 'image/svg+xml'; - const whiteList = customWhiteList ?? settings.watch('FileUpload_MediaTypeWhiteList') ?? ''; + const blackList = settings.peek('FileUpload_MediaTypeBlackList') ?? 'image/svg+xml'; + const whiteList = customWhiteList ?? settings.peek('FileUpload_MediaTypeWhiteList') ?? ''; return fileUploadIsValidContentTypeFromSettings(type, whiteList, blackList); }; diff --git a/apps/meteor/app/utils/rocketchat.info b/apps/meteor/app/utils/rocketchat.info index 2f8592690a8f2..627c6d80d5d52 100644 --- a/apps/meteor/app/utils/rocketchat.info +++ b/apps/meteor/app/utils/rocketchat.info @@ -1,3 +1,3 @@ { - "version": "8.4.3" + "version": "8.5.0-rc.6" } diff --git a/apps/meteor/app/utils/server/functions/isDocker.ts b/apps/meteor/app/utils/server/functions/isDocker.ts index 86471cec24d1f..56d074718d069 100644 --- a/apps/meteor/app/utils/server/functions/isDocker.ts +++ b/apps/meteor/app/utils/server/functions/isDocker.ts @@ -1,4 +1,4 @@ -import fs from 'fs'; +import fs from 'node:fs'; function hasDockerEnv(): boolean { try { diff --git a/apps/meteor/app/version-check/server/functions/getNewUpdates.ts b/apps/meteor/app/version-check/server/functions/getNewUpdates.ts index 60bacbe71c9d4..2d0ce22a3c427 100644 --- a/apps/meteor/app/version-check/server/functions/getNewUpdates.ts +++ b/apps/meteor/app/version-check/server/functions/getNewUpdates.ts @@ -1,4 +1,4 @@ -import os from 'os'; +import os from 'node:os'; import { Settings } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; diff --git a/apps/meteor/app/webdav/server/lib/webdavClientAdapter.ts b/apps/meteor/app/webdav/server/lib/webdavClientAdapter.ts index 43e9bb4642d77..c3d7df2330509 100644 --- a/apps/meteor/app/webdav/server/lib/webdavClientAdapter.ts +++ b/apps/meteor/app/webdav/server/lib/webdavClientAdapter.ts @@ -1,5 +1,5 @@ -import stream from 'stream'; -import type { Readable, Writable } from 'stream'; +import stream from 'node:stream'; +import type { Readable, Writable } from 'node:stream'; import type { WebDAVClient, FileStat, ResponseDataDetailed, WebDAVClientOptions } from 'webdav'; import { createClient } from 'webdav'; diff --git a/apps/meteor/client/apps/RealAppsEngineUIHost.ts b/apps/meteor/client/apps/RealAppsEngineUIHost.ts index c6a746f6bb7a8..a8da21e8aeea1 100644 --- a/apps/meteor/client/apps/RealAppsEngineUIHost.ts +++ b/apps/meteor/client/apps/RealAppsEngineUIHost.ts @@ -1,5 +1,6 @@ -import { AppsEngineUIHost } from '@rocket.chat/apps-engine/client/AppsEngineUIHost'; -import type { IExternalComponentRoomInfo, IExternalComponentUserInfo } from '@rocket.chat/apps-engine/client/definition'; +import { AppsEngineUIHost } from '@rocket.chat/apps/dist/client/AppsEngineUIHost'; +import type { IExternalComponentRoomInfo } from '@rocket.chat/apps/dist/client/definition/IExternalComponentRoomInfo'; +import type { IExternalComponentUserInfo } from '@rocket.chat/apps/dist/client/definition/IExternalComponentUserInfo'; import { getUserAvatarURL } from '../../app/utils/client/getUserAvatarURL'; import { sdk } from '../../app/utils/client/lib/SDKClient'; diff --git a/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx b/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx index e40afaf35f438..e3a6f13271d01 100644 --- a/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx +++ b/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx @@ -1,7 +1,7 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; +import { Random } from '@rocket.chat/random'; import { GenericModal } from '@rocket.chat/ui-client'; -import { Tracker } from 'meteor/tracker'; import type { ReactElement } from 'react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -34,19 +34,13 @@ const GameCenterInvitePlayersModal = ({ game, onClose }: IGameCenterInvitePlayer roomCoordinator.openRouteLink(result.t, result); - Tracker.autorun((c) => { - if (openedRoom !== result.rid) { - return; - } - + if (openedRoom === result.rid) { callWithErrorHandling('sendMessage', { _id: Random.id(), rid: result.rid, msg: t('Apps_Game_Center_Play_Game_Together', { name }), }); - - c.stop(); - }); + } onClose(); } catch (err) { console.warn(err); diff --git a/apps/meteor/client/apps/orchestrator.ts b/apps/meteor/client/apps/orchestrator.ts index d08641da34d22..e376211b8260a 100644 --- a/apps/meteor/client/apps/orchestrator.ts +++ b/apps/meteor/client/apps/orchestrator.ts @@ -1,5 +1,5 @@ -import { AppClientManager } from '@rocket.chat/apps-engine/client/AppClientManager'; -import type { AppsEngineUIHost } from '@rocket.chat/apps-engine/client/AppsEngineUIHost'; +import { AppClientManager } from '@rocket.chat/apps/dist/client/AppClientManager'; +import type { AppsEngineUIHost } from '@rocket.chat/apps/dist/client/AppsEngineUIHost'; import type { IPermission } from '@rocket.chat/apps-engine/definition/permissions/IPermission'; import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; import type { Serialized } from '@rocket.chat/core-typings'; diff --git a/apps/meteor/client/cachedStores/RoomsCachedStore.ts b/apps/meteor/client/cachedStores/RoomsCachedStore.ts index 85e78c9041b4f..f02ec85cced80 100644 --- a/apps/meteor/client/cachedStores/RoomsCachedStore.ts +++ b/apps/meteor/client/cachedStores/RoomsCachedStore.ts @@ -1,5 +1,5 @@ import type { IOmnichannelRoom, IRoom, IRoomWithRetentionPolicy } from '@rocket.chat/core-typings'; -import { DEFAULT_SLA_CONFIG, isABACManagedRoom, isRoomNativeFederated, LivechatPriorityWeight } from '@rocket.chat/core-typings'; +import { DEFAULT_SLA_CONFIG, isRoomNativeFederated, LivechatPriorityWeight } from '@rocket.chat/core-typings'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import { PrivateCachedStore } from '../lib/cachedStores/CachedStore'; @@ -53,9 +53,7 @@ class RoomsCachedStore extends PrivateCachedStore { source: (room as IOmnichannelRoom | undefined)?.source, queuedAt: (room as IOmnichannelRoom | undefined)?.queuedAt, federated: room.federated, - ...(isABACManagedRoom(room) && { - abacAttributes: room.abacAttributes, - }), + abacAttributes: room.abacAttributes, ...(isRoomNativeFederated(room) && { federation: room.federation, }), diff --git a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx index 2a6964462deaf..6a41769ef5566 100644 --- a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx +++ b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx @@ -1,22 +1,21 @@ import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { Icon, Box } from '@rocket.chat/fuselage'; import { Field, FieldGroup, ToggleSwitch, TextInput, TextAreaInput, - Icon, - Box, FieldHint, FieldLabel, FieldRow, FieldError, -} from '@rocket.chat/fuselage'; +} from '@rocket.chat/fuselage-forms'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { GenericModal } from '@rocket.chat/ui-client'; import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; -import { useId, useState } from 'react'; +import { useState } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { useEncryptedRoomDescription } from '../../navbar/NavBarPagesGroup/actions/useEncryptedRoomDescription'; @@ -105,13 +104,6 @@ const CreateDiscussion = ({ const getEncryptedHint = useEncryptedRoomDescription('discussion'); - const parentRoomId = useId(); - const encryptedId = useId(); - const discussionNameId = useId(); - const membersId = useId(); - const firstMessageId = useId(); - const topicId = useId(); - return ( {t('Discussion_description')} - - {t('Discussion_target_channel')} - + {t('Discussion_target_channel')} {defaultParentRoom && ( } + render={() => } /> )} {!defaultParentRoom && ( @@ -142,18 +132,13 @@ const CreateDiscussion = ({ control={control} name='parentRoom' rules={{ required: t('Required_field', { field: t('Discussion_target_channel') }) }} - render={({ field: { name, onBlur, onChange, value } }) => ( + render={({ field }) => ( (encrypted ? : null)} /> @@ -161,106 +146,66 @@ const CreateDiscussion = ({ /> )} - {errors.parentRoom && ( - - {errors.parentRoom.message} - - )} + {errors.parentRoom && {errors.parentRoom.message}} - - {t('Name')} - + {t('Name')} ( - } - /> + } error={errors.name?.message} /> )} /> - {errors.name && ( - - {errors.name.message} - - )} + {errors.name && {errors.name.message}} - {t('Topic')} + {t('Topic')} - } - /> + } /> - {t('Displayed_next_to_name')} + {t('Displayed_next_to_name')} - {t('Members')} + {t('Members')} ( - - )} + render={({ field }) => } /> - {t('Discussion_first_message_title')} + {t('Discussion_first_message_title')} ( - - )} + render={({ field }) => } /> {encrypted ? ( - {t('Discussion_first_message_disabled_due_to_e2e')} + {t('Discussion_first_message_disabled_due_to_e2e')} ) : ( - {t('First_message_hint')} + {t('First_message_hint')} )} - {t('Encrypted')} + {t('Encrypted')} ( - - )} + render={({ field: { value, ...field } }) => } /> - {getEncryptedHint({ isPrivate: true, encrypted })} + {getEncryptedHint({ isPrivate: true, encrypted })} diff --git a/apps/meteor/client/components/CreateDiscussion/DefaultParentRoomField.tsx b/apps/meteor/client/components/CreateDiscussion/DefaultParentRoomField.tsx index b34940ab5bd4b..9d1ca1a65661a 100644 --- a/apps/meteor/client/components/CreateDiscussion/DefaultParentRoomField.tsx +++ b/apps/meteor/client/components/CreateDiscussion/DefaultParentRoomField.tsx @@ -1,4 +1,5 @@ -import { Skeleton, TextInput, Callout } from '@rocket.chat/fuselage'; +import { Skeleton, Callout } from '@rocket.chat/fuselage'; +import { TextInput } from '@rocket.chat/fuselage-forms'; import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import type { ComponentPropsWithoutRef } from 'react'; diff --git a/apps/meteor/client/components/CreateDiscussion/__snapshots__/CreateDiscussion.spec.tsx.snap b/apps/meteor/client/components/CreateDiscussion/__snapshots__/CreateDiscussion.spec.tsx.snap index 5691fe063e502..f79ddc71ebcc7 100644 --- a/apps/meteor/client/components/CreateDiscussion/__snapshots__/CreateDiscussion.spec.tsx.snap +++ b/apps/meteor/client/components/CreateDiscussion/__snapshots__/CreateDiscussion.spec.tsx.snap @@ -4,7 +4,7 @@ exports[`CreateDiscussion renders Default without crashing 1`] = ` Discussion_title @@ -61,9 +61,9 @@ exports[`CreateDiscussion renders Default without crashing 1`] = ` - Parent channel or team * - + @@ -84,11 +84,12 @@ exports[`CreateDiscussion renders Default without crashing 1`] = ` role="group" > Name Topic @@ -164,9 +166,10 @@ exports[`CreateDiscussion renders Default without crashing 1`] = ` class="rcx-box rcx-box--full rcx-field__row" > Displayed_next_to_name @@ -187,19 +190,17 @@ exports[`CreateDiscussion renders Default without crashing 1`] = ` - Members - + @@ -234,7 +242,7 @@ exports[`CreateDiscussion renders Default without crashing 1`] = ` > Message @@ -242,10 +250,11 @@ exports[`CreateDiscussion renders Default without crashing 1`] = ` class="rcx-box rcx-box--full rcx-field__row" > First_message_hint @@ -264,18 +273,24 @@ exports[`CreateDiscussion renders Default without crashing 1`] = ` - Encrypted - + + + Encrypted + @@ -287,7 +302,7 @@ exports[`CreateDiscussion renders Default without crashing 1`] = ` Not_available_for_this_workspace diff --git a/apps/meteor/client/components/ImageGallery/ImageGallery.tsx b/apps/meteor/client/components/ImageGallery/ImageGallery.tsx index 0804e6c79e3d4..8ccba73c2aea9 100644 --- a/apps/meteor/client/components/ImageGallery/ImageGallery.tsx +++ b/apps/meteor/client/components/ImageGallery/ImageGallery.tsx @@ -98,7 +98,15 @@ const swiperStyle = css` } `; -export const ImageGallery = ({ images, onClose, loadMore }: { images: IUpload[]; onClose: () => void; loadMore?: () => void }) => { +export const ImageGallery = ({ + images, + onClose, + loadMore, +}: { + images: Pick[]; + onClose: () => void; + loadMore?: () => void; +}) => { const { t } = useTranslation(); const swiperRef = useRef(null); const [, setSwiperInst] = useState(); diff --git a/apps/meteor/client/components/RoomAutoComplete/RoomAutoComplete.tsx b/apps/meteor/client/components/RoomAutoComplete/RoomAutoComplete.tsx index f36e9f3ea252e..5ebc5393b0847 100644 --- a/apps/meteor/client/components/RoomAutoComplete/RoomAutoComplete.tsx +++ b/apps/meteor/client/components/RoomAutoComplete/RoomAutoComplete.tsx @@ -1,11 +1,15 @@ import type { IRoom } from '@rocket.chat/core-typings'; -import { AutoComplete, Option, Box } from '@rocket.chat/fuselage'; +import { Option, Box } from '@rocket.chat/fuselage'; +import type { AutoCompleteProps } from '@rocket.chat/fuselage'; +import { AutoComplete } from '@rocket.chat/fuselage-forms'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { RoomAvatar } from '@rocket.chat/ui-avatar'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import type { ComponentProps, ReactElement } from 'react'; -import { memo, useMemo, useState } from 'react'; +import type { ReactElement } from 'react'; +import { forwardRef, memo, useMemo, useState } from 'react'; + +type LabelType = { name: string; avatarETag?: string; type: IRoom['t']; encrypted?: IRoom['encrypted'] }; const generateQuery = ( term = '', @@ -13,7 +17,7 @@ const generateQuery = ( selector: string; } => ({ selector: JSON.stringify({ name: term }) }); -type RoomAutoCompleteProps = Omit, 'filter'> & { +type RoomAutoCompleteProps = Omit, 'filter'> & { scope?: 'admin' | 'regular'; renderRoomIcon?: (props: { encrypted: IRoom['encrypted']; type: IRoom['t'] }) => ReactElement | null; setSelectedRoom?: (room: IRoom | undefined) => void; @@ -32,7 +36,10 @@ const ROOM_AUTOCOMPLETE_PARAMS = { }, } as const; -const RoomAutoComplete = ({ value, onChange, scope = 'regular', renderRoomIcon, setSelectedRoom, ...props }: RoomAutoCompleteProps) => { +const RoomAutoComplete = forwardRef(function RoomAutoComplete( + { value, onChange, scope = 'regular', renderRoomIcon, setSelectedRoom, ...props }, + ref, +) { const [filter, setFilter] = useState(''); const filterDebounced = useDebouncedValue(filter, 300); const roomsAutoCompleteEndpoint = useEndpoint('GET', ROOM_AUTOCOMPLETE_PARAMS[scope].endpoint); @@ -57,6 +64,7 @@ const RoomAutoComplete = ({ value, onChange, scope = 'regular', renderRoomIcon, return ( { onChange(val); @@ -76,7 +84,7 @@ const RoomAutoComplete = ({ value, onChange, scope = 'regular', renderRoomIcon, {label?.name} - {renderRoomIcon?.({ ...label })} + {renderRoomIcon?.({ encrypted: label?.encrypted, type: label?.type })} > )} renderItem={({ value, label, ...props }) => ( @@ -85,7 +93,7 @@ const RoomAutoComplete = ({ value, onChange, scope = 'regular', renderRoomIcon, label={ <> {label?.name} - {renderRoomIcon?.({ ...label })} + {renderRoomIcon?.({ encrypted: label?.encrypted, type: label?.type })} > } avatar={} @@ -94,6 +102,6 @@ const RoomAutoComplete = ({ value, onChange, scope = 'regular', renderRoomIcon, options={options} /> ); -}; +}); export default memo(RoomAutoComplete); diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx index 7880d2f375594..31dd7696dd606 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx @@ -1,9 +1,10 @@ -import { Box, FieldGroup, TextInput, Field, FieldLabel, FieldRow, FieldError, Button } from '@rocket.chat/fuselage'; -import { useAutoFocus } from '@rocket.chat/fuselage-hooks'; +import { Box, Button } from '@rocket.chat/fuselage'; +import { FieldGroup, TextInput, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage-forms'; import { GenericModal } from '@rocket.chat/ui-client'; import { useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts'; -import type { ReactElement, ChangeEvent, SyntheticEvent } from 'react'; -import { useId, useState } from 'react'; +import type { ReactElement } from 'react'; +import { useEffect } from 'react'; +import { useForm, Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import type { OnConfirm } from './TwoFactorModal'; @@ -12,15 +13,37 @@ import { Method } from './TwoFactorModal'; type TwoFactorEmailModalProps = { onConfirm: OnConfirm; onClose: () => void; - emailOrUsername: string; invalidAttempt?: boolean; + emailOrUsername: string; +}; + +type TwoFactorEmailFormData = { + code: string; }; const TwoFactorEmailModal = ({ onConfirm, onClose, emailOrUsername, invalidAttempt }: TwoFactorEmailModalProps): ReactElement => { const dispatchToastMessage = useToastMessageDispatch(); const { t } = useTranslation(); - const [code, setCode] = useState(''); - const ref = useAutoFocus(); + + const { + control, + handleSubmit, + setError, + setValue, + clearErrors, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { code: '' }, + }); + + useEffect(() => { + if (invalidAttempt) { + setError('code', { + type: 'manual', + message: t('Invalid_two_factor_code'), + }); + } + }, [invalidAttempt, setError, t]); const sendEmailCode = useEndpoint('POST', '/v1/users.2fa.sendEmailCode'); @@ -36,46 +59,55 @@ const TwoFactorEmailModal = ({ onConfirm, onClose, emailOrUsername, invalidAttem } }; - const onConfirmEmailCode = (e: SyntheticEvent): void => { - e.preventDefault(); - onConfirm(code, Method.EMAIL); - }; - - const onChange = ({ currentTarget }: ChangeEvent): void => { - setCode(currentTarget.value); - }; - - const id = useId(); + const onSubmit = handleSubmit(async ({ code }) => { + try { + await onConfirm(code, Method.EMAIL); + } catch (error) { + setError('code', { + type: 'manual', + message: t('Invalid_two_factor_code'), + }); + setValue('code', ''); + } + }); return ( } + wrapperFunction={(props) => } onCancel={onClose} confirmText={t('Verify')} title={t('Enter_authentication_code')} onClose={onClose} variant='warning' - confirmDisabled={!code} + confirmDisabled={isSubmitting} tagline={t('Email_two-factor_authentication')} icon={null} > - - {t('Enter_the_code_we_just_emailed_you')} - + {t('Enter_the_code_we_just_emailed_you')} - ( + { + clearErrors('code'); + onChange(e); + }} + placeholder={t('Enter_code_here')} + autoComplete='one-time-code' + inputMode='numeric' + disabled={isSubmitting} + error={errors.code?.message} + /> + )} /> - {invalidAttempt && {t('Invalid_password')}} + {errors.code && {errors.code.message}} diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx index 1a23a17a3718e..cd6cb035ff842 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx @@ -10,7 +10,7 @@ export enum Method { PASSWORD = 'password', } -export type OnConfirm = (code: string, method: Method) => void; +export type OnConfirm = (code: string, method: Method) => void | Promise; type TwoFactorModalProps = { onConfirm: OnConfirm; diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorPasswordModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorPasswordModal.tsx index d24f5bf6cae28..f4656bee310ee 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorPasswordModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorPasswordModal.tsx @@ -1,8 +1,8 @@ -import { Box, PasswordInput, FieldGroup, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; -import { useAutoFocus } from '@rocket.chat/fuselage-hooks'; +import { Box } from '@rocket.chat/fuselage'; +import { PasswordInput, FieldGroup, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage-forms'; import { GenericModal } from '@rocket.chat/ui-client'; -import type { ReactElement, ChangeEvent, Ref, SyntheticEvent } from 'react'; -import { useId, useState } from 'react'; +import { useEffect, type ReactElement } from 'react'; +import { useForm, Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import type { OnConfirm } from './TwoFactorModal'; @@ -14,48 +14,79 @@ type TwoFactorPasswordModalProps = { invalidAttempt?: boolean; }; +type TwoFactorPasswordFormData = { + password: string; +}; + const TwoFactorPasswordModal = ({ onConfirm, onClose, invalidAttempt }: TwoFactorPasswordModalProps): ReactElement => { const { t } = useTranslation(); - const [code, setCode] = useState(''); - const ref = useAutoFocus(); - const onConfirmTotpCode = (e: SyntheticEvent): void => { - e.preventDefault(); - onConfirm(code, Method.PASSWORD); - }; + const { + control, + handleSubmit, + setError, + setValue, + clearErrors, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { password: '' }, + }); - const onChange = ({ currentTarget }: ChangeEvent): void => { - setCode(currentTarget.value); - }; + useEffect(() => { + if (invalidAttempt) { + setError('password', { + type: 'manual', + message: t('Invalid_password'), + }); + } + }, [invalidAttempt, setError, t]); - const id = useId(); + const onSubmit = handleSubmit(async ({ password }) => { + try { + await onConfirm(password, Method.PASSWORD); + } catch (error) { + setError('password', { + type: 'manual', + message: t('Invalid_password'), + }); + setValue('password', ''); + } + }); return ( } + wrapperFunction={(props) => } onCancel={onClose} confirmText={t('Verify')} title={t('Please_enter_your_password')} onClose={onClose} variant='warning' icon='info' - confirmDisabled={!code} + confirmDisabled={isSubmitting} > - - {t('For_your_security_you_must_enter_your_current_password_to_continue')} - + {t('For_your_security_you_must_enter_your_current_password_to_continue')} - } - value={code} - onChange={onChange} - placeholder={t('Password')} - > + ( + { + clearErrors('password'); + onChange(e); + }} + placeholder={t('Password')} + disabled={isSubmitting} + error={errors.password?.message} + /> + )} + /> - {invalidAttempt && {t('Invalid_password')}} + {errors.password && {errors.password.message}} diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx index ff678c68f0603..bf74027d33164 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx @@ -1,8 +1,8 @@ -import { Box, TextInput, Field, FieldGroup, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; -import { useAutoFocus } from '@rocket.chat/fuselage-hooks'; +import { Box } from '@rocket.chat/fuselage'; +import { FieldGroup, TextInput, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage-forms'; import { GenericModal } from '@rocket.chat/ui-client'; -import type { ReactElement, ChangeEvent, SyntheticEvent } from 'react'; -import { useId, useState } from 'react'; +import { useEffect, type ReactElement } from 'react'; +import { useForm, Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import type { OnConfirm } from './TwoFactorModal'; @@ -15,51 +15,83 @@ type TwoFactorTotpModalProps = { invalidAttempt?: boolean; }; +type TwoFactorTotpFormData = { + code: string; +}; + const TwoFactorTotpModal = ({ onConfirm, onClose, onDismiss, invalidAttempt }: TwoFactorTotpModalProps): ReactElement => { const { t } = useTranslation(); - const [code, setCode] = useState(''); - const ref = useAutoFocus(); - const onConfirmTotpCode = (e: SyntheticEvent): void => { - e.preventDefault(); - onConfirm(code, Method.TOTP); - }; + const { + control, + handleSubmit, + setError, + setValue, + clearErrors, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { code: '' }, + }); + + useEffect(() => { + if (invalidAttempt) { + setError('code', { + type: 'manual', + message: t('Invalid_two_factor_code'), + }); + } + }, [invalidAttempt, setError, t]); - const onChange = ({ currentTarget }: ChangeEvent): void => { - setCode(currentTarget.value); - }; + const onSubmit = handleSubmit(async ({ code }) => { + try { + await onConfirm(code, Method.TOTP); + } catch (error) { + setError('code', { + type: 'manual', + message: t('Invalid_two_factor_code'), + }); + setValue('code', ''); + } + }); - const id = useId(); return ( } + wrapperFunction={(props) => } onCancel={onClose} confirmText={t('Verify')} title={t('Enter_TOTP_password')} onClose={onClose} onDismiss={onDismiss} variant='warning' - confirmDisabled={!code} + confirmDisabled={isSubmitting} tagline={t('Two-factor_authentication')} icon={null} > - - {t('Enter_the_code_provided_by_your_authentication_app_to_continue')} - + {t('Enter_the_code_provided_by_your_authentication_app_to_continue')} - ( + { + clearErrors('code'); + onChange(e); + }} + placeholder={t('Enter_code_here')} + autoComplete='one-time-code' + inputMode='numeric' + disabled={isSubmitting} + error={errors.code?.message} + /> + )} /> - {invalidAttempt && {t('Invalid_password')}} + {errors.code && {errors.code.message}} diff --git a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx index 413cab7f6ea0c..d4cc3dea17fb7 100644 --- a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx +++ b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx @@ -1,9 +1,9 @@ -import { MultiSelectFiltered } from '@rocket.chat/fuselage'; +import { MultiSelectFiltered } from '@rocket.chat/fuselage-forms'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import type { ReactElement, AllHTMLAttributes } from 'react'; -import { memo, useState, useCallback, useMemo } from 'react'; +import type { AllHTMLAttributes } from 'react'; +import { memo, useState, useCallback, useMemo, forwardRef } from 'react'; import AutocompleteOptions, { OptionsContext } from './UserAutoCompleteMultipleOptions'; import UserAvatarChip from './UserAvatarChip'; @@ -29,96 +29,101 @@ type UserAutoCompleteOptions = { const matrixRegex = new RegExp('@(.*:.*)'); -const UserAutoCompleteMultiple = ({ onChange, value, placeholder, federated, ...props }: UserAutoCompleteMultipleProps): ReactElement => { - const [filter, setFilter] = useState(''); - const [selectedCache, setSelectedCache] = useState({}); - - const debouncedFilter = useDebouncedValue(filter, 500); - const getUsers = useEndpoint('GET', '/v1/users.autocomplete'); - - const { data } = useQuery({ - queryKey: usersQueryKeys.userAutoComplete(debouncedFilter, federated ?? false), - - queryFn: async () => { - const users = await getUsers({ selector: JSON.stringify({ term: debouncedFilter }) }); - const options = users.items.map((item): [string, UserAutoCompleteOptionType] => [item.username, item]); - - // Add extra option if filter text matches `username:server` - // Used to add federated users that do not exist yet - if (federated && matrixRegex.test(debouncedFilter)) { - options.unshift([debouncedFilter, { name: debouncedFilter, username: debouncedFilter, _federated: true }]); - } - - return options; - }, - - placeholderData: keepPreviousData, - }); - - const options = useMemo(() => data || [], [data]); - - const onAddUser = useCallback( - (username: string): void => { - const user = options?.find(([val]) => val === username)?.[1]; - if (!user) { - throw new Error('UserAutoCompleteMultiple - onAddSelected - failed to cache option'); - } - setSelectedCache((selectedCache) => ({ ...selectedCache, [username]: user })); - }, - [setSelectedCache, options], - ); - - const onRemoveUser = useCallback( - (username: string): void => - setSelectedCache((selectedCache) => { - const users = { ...selectedCache }; - delete users[username]; - return users; - }), - [setSelectedCache], - ); - - const handleOnChange = useCallback( - (usernames: string[]) => { - onChange(usernames); - const newAddedUsername = usernames.filter((username) => !value?.includes(username))[0]; - const removedUsername = value?.filter((username) => !usernames.includes(username))[0]; - setFilter(''); - newAddedUsername && onAddUser(newAddedUsername); - removedUsername && onRemoveUser(removedUsername); - }, - [onChange, setFilter, onAddUser, onRemoveUser, value], - ); - - return ( - - void }) => { - const currentCachedOption = selectedCache[username] || {}; - - return ( - - ); - }} - renderOptions={AutocompleteOptions} - options={options.concat(Object.entries(selectedCache)).map(([, item]) => [item.username, item.name || item.username])} - /> - - ); -}; +const UserAutoCompleteMultiple = forwardRef( + ({ onChange, value, placeholder, federated, ...props }, ref) => { + const [filter, setFilter] = useState(''); + const [selectedCache, setSelectedCache] = useState({}); + + const debouncedFilter = useDebouncedValue(filter, 500); + const getUsers = useEndpoint('GET', '/v1/users.autocomplete'); + + const { data } = useQuery({ + queryKey: usersQueryKeys.userAutoComplete(debouncedFilter, federated ?? false), + + queryFn: async () => { + const users = await getUsers({ selector: JSON.stringify({ term: debouncedFilter }) }); + const options = users.items.map((item): [string, UserAutoCompleteOptionType] => [item.username, item]); + + // Add extra option if filter text matches `username:server` + // Used to add federated users that do not exist yet + if (federated && matrixRegex.test(debouncedFilter)) { + options.unshift([debouncedFilter, { name: debouncedFilter, username: debouncedFilter, _federated: true }]); + } + + return options; + }, + + placeholderData: keepPreviousData, + }); + + const options = useMemo(() => data || [], [data]); + + const onAddUser = useCallback( + (username: string): void => { + const user = options?.find(([val]) => val === username)?.[1]; + if (!user) { + throw new Error('UserAutoCompleteMultiple - onAddSelected - failed to cache option'); + } + setSelectedCache((selectedCache) => ({ ...selectedCache, [username]: user })); + }, + [setSelectedCache, options], + ); + + const onRemoveUser = useCallback( + (username: string): void => + setSelectedCache((selectedCache) => { + const users = { ...selectedCache }; + delete users[username]; + return users; + }), + [setSelectedCache], + ); + + const handleOnChange = useCallback( + (usernames: string[]) => { + onChange(usernames); + const newAddedUsername = usernames.filter((username) => !value?.includes(username))[0]; + const removedUsername = value?.filter((username) => !usernames.includes(username))[0]; + setFilter(''); + newAddedUsername && onAddUser(newAddedUsername); + removedUsername && onRemoveUser(removedUsername); + }, + [onChange, setFilter, onAddUser, onRemoveUser, value], + ); + + return ( + + void }) => { + const currentCachedOption = selectedCache[username] || {}; + + return ( + + ); + }} + renderOptions={AutocompleteOptions} + options={options.concat(Object.entries(selectedCache)).map(([, item]) => [item.username, item.name || item.username])} + /> + + ); + }, +); + +UserAutoCompleteMultiple.displayName = 'UserAutoCompleteMultiple'; export default memo(UserAutoCompleteMultiple); diff --git a/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap b/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap index 5f905db5259be..09d900532be03 100644 --- a/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap +++ b/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap @@ -66,6 +66,7 @@ exports[`renders Default without crashing 1`] = ` class="rcx-box rcx-box--full rcx-css-902tla" > setAvatarUrlError(t('error-invalid-image-url'))} /> diff --git a/apps/meteor/client/components/message/helpers/getCheckboxLabel.tsx b/apps/meteor/client/components/message/helpers/getCheckboxLabel.tsx new file mode 100644 index 0000000000000..0fb9c981838ab --- /dev/null +++ b/apps/meteor/client/components/message/helpers/getCheckboxLabel.tsx @@ -0,0 +1,13 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import type { TFunction } from 'i18next'; + +export const getCheckboxLabel = (message: IMessage, t: TFunction): string => { + const username = message.u.name || message.u.username; + if (message.msg) { + return t('Select_message_from_user_with_preview', { + username, + message: message.msg, + }); + } + return t('Select_message_from_user', { username }); +}; diff --git a/apps/meteor/client/components/message/list/MessageListContext.tsx b/apps/meteor/client/components/message/list/MessageListContext.tsx index 1f462d2539398..51b90fe36fd62 100644 --- a/apps/meteor/client/components/message/list/MessageListContext.tsx +++ b/apps/meteor/client/components/message/list/MessageListContext.tsx @@ -1,5 +1,5 @@ import type { IMessage } from '@rocket.chat/core-typings'; -import type { KeyboardEvent, MouseEvent, RefCallback } from 'react'; +import type { KeyboardEvent, MouseEvent } from 'react'; import { createContext, useContext } from 'react'; import type { useFormatDate } from '../../../hooks/useFormatDate'; @@ -42,7 +42,6 @@ export type MessageListContextValue = { formatDateAndTime: ReturnType; formatTime: ReturnType; formatDate: ReturnType; - messageListRef?: RefCallback; }; export const messageListContextDefaultValue: MessageListContextValue = { @@ -73,7 +72,6 @@ export const messageListContextDefaultValue: MessageListContextValue = { formatDateAndTime: () => '', formatTime: () => '', formatDate: () => '', - messageListRef: undefined, }; export const MessageListContext = createContext(messageListContextDefaultValue); @@ -98,8 +96,6 @@ export const useUserHasReacted: MessageListContextValue['useUserHasReacted'] = ( export const useOpenEmojiPicker: MessageListContextValue['useOpenEmojiPicker'] = (...args) => useContext(MessageListContext).useOpenEmojiPicker(...args); -export const useMessageListRef = (): MessageListContextValue['messageListRef'] => useContext(MessageListContext).messageListRef; - export const useMessageListShowColors = (): MessageListContextValue['showColors'] => useContext(MessageListContext).showColors; export const useMessageListKatex = (): MessageListContextValue['katex'] => useContext(MessageListContext).katex; diff --git a/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx b/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx index 6ce3dc1be51d6..f206df360e76a 100644 --- a/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx +++ b/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx @@ -6,12 +6,11 @@ import { type IRoom, type ISubscription, } from '@rocket.chat/core-typings'; -import { useUser, useEndpoint } from '@rocket.chat/ui-contexts'; -import { useCallback } from 'react'; +import { useUser, useEndpoint, usePermission } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useEmojiPickerData } from '../../../../../contexts/EmojiPickerContext'; -import { useReactiveValue } from '../../../../../hooks/useReactiveValue'; import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; import EmojiElement from '../../../../../views/composer/EmojiPicker/EmojiElement'; import { useChat } from '../../../../../views/room/contexts/ChatContext'; @@ -33,8 +32,10 @@ const ReactionMessageAction = ({ message, room, subscription }: ReactionMessageA const isFederated = room && isRoomFederated(room); const isFederationBlocked = isFederated && !isRoomNativeFederated(room); - const enabled = useReactiveValue( - useCallback(() => { + // depend on post-readonly so readOnly re-evaluates when the permission toggles at runtime. + const postReadOnly = usePermission('post-readonly', room._id); + const enabled = useMemo( + () => { if (isFederationBlocked) { return false; } @@ -48,7 +49,9 @@ const ReactionMessageAction = ({ message, room, subscription }: ReactionMessageA } return true; - }, [chat, room, subscription, message.private, user, isFederationBlocked]), + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [chat, room, subscription, message.private, user, isFederationBlocked, postReadOnly], ); if (!enabled) { diff --git a/apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/TimestampPickerModal.tsx b/apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/TimestampPickerModal.tsx index abea5601d6d6a..4a92a3b96d05d 100644 --- a/apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/TimestampPickerModal.tsx +++ b/apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/TimestampPickerModal.tsx @@ -64,11 +64,11 @@ export const TimestampPickerModal = ({ onClose, composer }: TimestampPickerProps confirmDisabled={!isValid} > + } /> } /> } /> } /> - ); diff --git a/apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/TimezoneSelector.tsx b/apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/TimezoneSelector.tsx index 969004a40e6cc..8a8c07b54d624 100644 --- a/apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/TimezoneSelector.tsx +++ b/apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/TimezoneSelector.tsx @@ -1,4 +1,4 @@ -import { Box, Field, FieldLabel, FieldRow, Select } from '@rocket.chat/fuselage'; +import { Box, Field, FieldHint, FieldDescription, FieldLabel, FieldRow, Select } from '@rocket.chat/fuselage'; import type { ReactElement, Key } from 'react'; import { useTranslation } from 'react-i18next'; @@ -26,9 +26,11 @@ const TimezoneSelector = ({ value, onChange }: TimezoneSelectorProps): ReactElem {t('Timezone')} + {t('Timezone_picker_description')} + {t('Timezone_picker_hint')} ); diff --git a/apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/__snapshots__/TimestampPicker.spec.tsx.snap b/apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/__snapshots__/TimestampPicker.spec.tsx.snap index 70b7c237a1b2f..a504f39d3e63c 100644 --- a/apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/__snapshots__/TimestampPicker.spec.tsx.snap +++ b/apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/__snapshots__/TimestampPicker.spec.tsx.snap @@ -98,6 +98,28 @@ exports[`Story Tests renders Default without crashing 1`] = ` + + + + Preview + + + + Mocked GazzodownText + + + + @@ -190,6 +212,56 @@ exports[`Story Tests renders Default without crashing 1`] = ` + + + + + + t + + + T + + + d + + + D + + + f + + + F + + + R + + + + - - - - - - t - - - T - - - d - - - D - - - f - - - F - - - R - - - - Timezone + + Timezone_picker_description + + + + + + + local + + + UTC-12 + + + UTC-11 + + + UTC-10 + + + UTC-9 + + + UTC-8 + + + UTC-7 + + + UTC-6 + + + UTC-5 + + + UTC-4 + + + UTC-3 + + + UTC-2 + + + UTC-1 + + + UTC + + + UTC+1 + + + UTC+2 + + + UTC+3 + + + UTC+4 + + + UTC+5 + + + UTC+6 + + + UTC+7 + + + UTC+8 + + + UTC+9 + + + UTC+10 + + + UTC+11 + + + UTC+12 + + + + - - - - - - local - - - UTC-12 - - - UTC-11 - - - UTC-10 - - - UTC-9 - - - UTC-8 - - - UTC-7 - - - UTC-6 - - - UTC-5 - - - UTC-4 - - - UTC-3 - - - UTC-2 - - - UTC-1 - - - UTC - - - UTC+1 - - - UTC+2 - - - UTC+3 - - - UTC+4 - - - UTC+5 - - - UTC+6 - - - UTC+7 - - - UTC+8 - - - UTC+9 - - - UTC+10 - - - UTC+11 - - - UTC+12 - - - - - - - - - - Preview - - - Mocked GazzodownText - + Timezone_picker_hint diff --git a/apps/meteor/client/components/message/toolbar/useReplyInDMAction.spec.ts b/apps/meteor/client/components/message/toolbar/useReplyInDMAction.spec.ts new file mode 100644 index 0000000000000..5542098c4c6fd --- /dev/null +++ b/apps/meteor/client/components/message/toolbar/useReplyInDMAction.spec.ts @@ -0,0 +1,86 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook } from '@testing-library/react'; + +import { useReplyInDMAction } from './useReplyInDMAction'; +import { createFakeMessage, createFakeRoom, createFakeSubscription, createFakeUser } from '../../../../tests/mocks/data'; +import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; + +jest.mock('../../../lib/rooms/roomCoordinator', () => ({ + roomCoordinator: { + openRouteLink: jest.fn(), + }, +})); + +const mockedOpenRouteLink = jest.mocked(roomCoordinator.openRouteLink); + +const currentUser = createFakeUser({ + _id: 'current-user-id', + username: 'currentuser', +}); + +const messageAuthor = { + _id: 'author-user-id', + username: 'authoruser', + name: 'Author User', +}; + +const message = createFakeMessage({ + _id: 'reply-message-id', + u: messageAuthor, +}); + +const room = createFakeRoom({ + _id: 'channel-id', + t: 'c', + name: 'general', +}); + +const subscription = createFakeSubscription({ + rid: 'channel-id', + t: 'c', +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('useReplyInDMAction', () => { + it('should not carry over the msg search parameter when opening a direct message', () => { + const getSearchParameters = jest.fn().mockReturnValue({ + msg: 'stale-message-id', + layout: 'embedded', + }); + + const { result } = renderHook(() => useReplyInDMAction(message, { room, subscription }), { + wrapper: mockAppRoot().withUser(currentUser).withPermission('create-d').withRouter({ getSearchParameters }).build(), + }); + + expect(result.current).not.toBeNull(); + + result.current?.action({ stopPropagation: jest.fn() } as unknown as UIEvent); + + expect(getSearchParameters).toHaveBeenCalled(); + expect(mockedOpenRouteLink).toHaveBeenCalledWith( + 'd', + { name: messageAuthor.username }, + { + layout: 'embedded', + reply: 'reply-message-id', + }, + ); + + const searchParams = mockedOpenRouteLink.mock.calls[0]?.[2]; + expect(searchParams).not.toHaveProperty('msg'); + }); + + it('should return null when already in a direct message room', () => { + const dmRoom = createFakeRoom({ t: 'd' }); + const dmSubscription = createFakeSubscription({ t: 'd' }); + + const { result } = renderHook(() => useReplyInDMAction(message, { room: dmRoom, subscription: dmSubscription }), { + wrapper: mockAppRoot().withUser(currentUser).withPermission('create-d').build(), + }); + + expect(result.current).toBeNull(); + }); +}); diff --git a/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts b/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts index dd10e3f56c6af..be05dad70f103 100644 --- a/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts +++ b/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts @@ -71,11 +71,12 @@ export const useReplyInDMAction = ( context: ['message', 'message-mobile', 'threads', 'federated'], type: 'communication', action() { + const { msg: _, ...searchParameters } = router.getSearchParameters(); roomCoordinator.openRouteLink( 'd', { name: message.u.username }, { - ...router.getSearchParameters(), + ...searchParameters, reply: message._id, }, ); diff --git a/apps/meteor/client/components/message/variants/RoomMessage.tsx b/apps/meteor/client/components/message/variants/RoomMessage.tsx index 14895b05d0929..01a8eea26916d 100644 --- a/apps/meteor/client/components/message/variants/RoomMessage.tsx +++ b/apps/meteor/client/components/message/variants/RoomMessage.tsx @@ -2,9 +2,10 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { Message, MessageLeftContainer, MessageContainer, CheckBox } from '@rocket.chat/fuselage'; import { useToggle } from '@rocket.chat/fuselage-hooks'; import { MessageAvatar } from '@rocket.chat/ui-avatar'; -import { useTranslation, useUserId, useUserCard } from '@rocket.chat/ui-contexts'; +import { useUserId, useUserCard } from '@rocket.chat/ui-contexts'; import type { ComponentProps, ReactElement } from 'react'; import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; import type { MessageActionContext } from '../../../../app/ui-utils/client/lib/MessageAction'; import { useIsMessageHighlight } from '../../../views/room/MessageList/contexts/MessageHighlightContext'; @@ -14,13 +15,13 @@ import { useIsSelectedMessage, useCountSelected, } from '../../../views/room/MessageList/contexts/SelectedMessagesContext'; -import { useJumpToMessage } from '../../../views/room/MessageList/hooks/useJumpToMessage'; import Emoji from '../../Emoji'; import IgnoredContent from '../IgnoredContent'; import MessageHeader from '../MessageHeader'; import MessageToolbarHolder from '../MessageToolbarHolder'; import StatusIndicators from '../StatusIndicators'; import RoomMessageContent from './room/RoomMessageContent'; +import { getCheckboxLabel } from '../helpers/getCheckboxLabel'; import { useMessageListReadReceipts } from '../list/MessageListContext'; type RoomMessageProps = { @@ -71,7 +72,7 @@ const RoomMessage = ({ searchText, ...props }: RoomMessageProps): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); const uid = useUserId(); const editing = useIsMessageHighlight(message._id); const [displayIgnoredMessage, toggleDisplayIgnoredMessage] = useToggle(false); @@ -86,17 +87,27 @@ const RoomMessage = ({ const { enabled: readReceiptEnabled } = useMessageListReadReceipts(); useCountSelected(); - const messageRef = useJumpToMessage(message._id); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!selecting) return; + + if (!(e.code === 'Space' || e.code === 'Enter')) return; + + e.preventDefault(); + toggleSelected(); + }; + + const checkboxLabel = getCheckboxLabel(message, t); return ( )} - {selecting && } + {selecting && } {sequential && } diff --git a/apps/meteor/client/components/message/variants/SystemMessage.tsx b/apps/meteor/client/components/message/variants/SystemMessage.tsx index 61095f2a8810e..56712220f42e1 100644 --- a/apps/meteor/client/components/message/variants/SystemMessage.tsx +++ b/apps/meteor/client/components/message/variants/SystemMessage.tsx @@ -30,6 +30,7 @@ import { } from '../../../views/room/MessageList/contexts/SelectedMessagesContext'; import Attachments from '../content/Attachments'; import MessageActions from '../content/MessageActions'; +import { getCheckboxLabel } from '../helpers/getCheckboxLabel'; import { useMessageListShowRealName, useMessageListShowUsername, @@ -63,19 +64,31 @@ const SystemMessage = ({ message, showUserAvatar, ...props }: SystemMessageProps useCountSelected(); const buttonProps = useButtonPattern((e) => openUserCard(e, user.username)); + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isSelecting) return; + + if (!(e.code === 'Space' || e.code === 'Enter')) return; + + e.preventDefault(); + toggleSelected(); + }; + + const checkboxLabel = getCheckboxLabel(message, t); + return ( {!isSelecting && showUserAvatar && } - {isSelecting && } + {isSelecting && } diff --git a/apps/meteor/client/components/message/variants/ThreadMessage.tsx b/apps/meteor/client/components/message/variants/ThreadMessage.tsx index 04a93a3d78d52..a2a7da39488d7 100644 --- a/apps/meteor/client/components/message/variants/ThreadMessage.tsx +++ b/apps/meteor/client/components/message/variants/ThreadMessage.tsx @@ -8,7 +8,6 @@ import { memo } from 'react'; import type { MessageActionContext } from '../../../../app/ui-utils/client/lib/MessageAction'; import { useIsMessageHighlight } from '../../../views/room/MessageList/contexts/MessageHighlightContext'; -import { useJumpToMessage } from '../../../views/room/MessageList/hooks/useJumpToMessage'; import Emoji from '../../Emoji'; import IgnoredContent from '../IgnoredContent'; import MessageHeader from '../MessageHeader'; @@ -33,15 +32,12 @@ const ThreadMessage = ({ message, sequential, unread, showUserAvatar }: ThreadMe // Checks if is videoconf message to limit toolbox actions const messageContext: MessageActionContext = isVideoConfMessage(message) ? 'videoconf-threads' : 'threads'; - const messageRef = useJumpToMessage(message._id); - return ( e.code === 'Enter' && handleThreadClick()} + onKeyDown={(e) => (e.code === 'Enter' || e.code === 'Space') && handleThreadClick()} isSelected={isSelected} {...props} > @@ -117,7 +120,7 @@ const ThreadMessagePreview = ({ message, showUserAvatar, sequential, ...props }: size='x18' /> )} - {isSelecting && } + {isSelecting && } diff --git a/apps/meteor/client/contexts/AppsContext.tsx b/apps/meteor/client/contexts/AppsContext.tsx index 39929c20aed08..38eda15a7ab98 100644 --- a/apps/meteor/client/contexts/AppsContext.tsx +++ b/apps/meteor/client/contexts/AppsContext.tsx @@ -1,4 +1,4 @@ -import type { AppClientManager } from '@rocket.chat/apps-engine/client/AppClientManager'; +import type { AppClientManager } from '@rocket.chat/apps/dist/client/AppClientManager'; import type { IPermission } from '@rocket.chat/apps-engine/definition/permissions/IPermission'; import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; import type { Serialized } from '@rocket.chat/core-typings'; diff --git a/apps/meteor/client/definitions/IOAuthProvider.ts b/apps/meteor/client/definitions/IOAuthProvider.ts index 00bc3be2b0408..291f2b1ab68cd 100644 --- a/apps/meteor/client/definitions/IOAuthProvider.ts +++ b/apps/meteor/client/definitions/IOAuthProvider.ts @@ -1,9 +1,22 @@ -import type { Meteor } from 'meteor/meteor'; +// Shape mirrors @types/meteor's Meteor.LoginWithExternalServiceOptions — +// the boxed Boolean type is intentional so this stays structurally +// compatible with existing oauth.ts call sites still typed against +// Meteor.LoginWithExternalServiceOptions. +/* eslint-disable @typescript-eslint/no-wrapper-object-types */ +export type LoginWithExternalServiceOptions = { + requestPermissions?: readonly string[] | undefined; + requestOfflineToken?: Boolean | undefined; + forceApprovalPrompt?: Boolean | undefined; + redirectUrl?: string | undefined; + loginHint?: string | undefined; + loginStyle?: string | undefined; +}; +/* eslint-enable @typescript-eslint/no-wrapper-object-types */ export interface IOAuthProvider { readonly name: string; requestCredential( - options: Meteor.LoginWithExternalServiceOptions | undefined, + options: LoginWithExternalServiceOptions | undefined, credentialRequestCompleteCallback: (credentialTokenOrError?: string | Error) => void, ): void; } diff --git a/apps/meteor/client/hooks/useEndpointUploadMutation.ts b/apps/meteor/client/hooks/useEndpointUploadMutation.ts index 82b6a5f91a5c8..72bbd9d0e98e3 100644 --- a/apps/meteor/client/hooks/useEndpointUploadMutation.ts +++ b/apps/meteor/client/hooks/useEndpointUploadMutation.ts @@ -1,22 +1,35 @@ import type { PathFor, PathPattern } from '@rocket.chat/rest-typings'; -import { useToastMessageDispatch, useUpload } from '@rocket.chat/ui-contexts'; +import { useToastMessageDispatch, useUpload, type UploadResult } from '@rocket.chat/ui-contexts'; import { useMutation, type UseMutationOptions } from '@tanstack/react-query'; +import { t } from 'i18next'; -type UseEndpointUploadOptions = Omit, 'mutationFn'>; +type UseEndpointUploadOptions = Omit, 'mutationFn'>; -export const useEndpointUploadMutation = (endpoint: TPathPattern, options?: UseEndpointUploadOptions) => { +export const useEndpointUploadMutation = ( + endpoint: TPathPattern, + options?: UseEndpointUploadOptions, +) => { const sendData = useUpload(endpoint as PathFor<'POST'>); const dispatchToastMessage = useToastMessageDispatch(); return useMutation({ - mutationFn: async (formData: FormData) => { + mutationFn: async (formData: FormData): Promise => { const data = sendData(formData); const promise = data instanceof Promise ? data : data.promise; const result = await promise; if (!result.success) { - throw new Error(String(result.status)); + if (result.status) { + throw new Error(result.status); + } + + if (typeof result.error === 'string') { + throw new Error(result.error); + } + + throw new Error(t('FileUpload_Error')); } + return result as TData; }, onError: (error) => { dispatchToastMessage({ type: 'error', message: error }); diff --git a/apps/meteor/client/hooks/useFormatDate.ts b/apps/meteor/client/hooks/useFormatDate.ts index 35410591ddd9d..a13f77b9850de 100644 --- a/apps/meteor/client/hooks/useFormatDate.ts +++ b/apps/meteor/client/hooks/useFormatDate.ts @@ -4,6 +4,6 @@ import { useCallback } from 'react'; import { formatDate } from '../lib/utils/dateFormat'; export const useFormatDate = () => { - const format = useSetting('Message_DateFormat'); - return useCallback((time: string | Date | number) => formatDate(time, String(format)), [format]); + const format = useSetting('Message_DateFormat', 'LL'); + return useCallback((time: string | Date | number) => formatDate(time, format), [format]); }; diff --git a/apps/meteor/client/hooks/useReactiveValue.ts b/apps/meteor/client/hooks/useReactiveValue.ts deleted file mode 100644 index 6c66f227e4d0b..0000000000000 --- a/apps/meteor/client/hooks/useReactiveValue.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useMemo, useSyncExternalStore } from 'react'; - -import { createReactiveSubscriptionFactory } from '../lib/createReactiveSubscriptionFactory'; - -export const useReactiveValue = (computeCurrentValue: () => T): T => { - const [subscribe, getSnapshot] = useMemo(() => createReactiveSubscriptionFactory(computeCurrentValue)(), [computeCurrentValue]); - - return useSyncExternalStore(subscribe, getSnapshot); -}; diff --git a/apps/meteor/client/hooks/useSingleFileInput.ts b/apps/meteor/client/hooks/useSingleFileInput.ts index 629152008d014..bda6c9a1f353d 100644 --- a/apps/meteor/client/hooks/useSingleFileInput.ts +++ b/apps/meteor/client/hooks/useSingleFileInput.ts @@ -5,6 +5,8 @@ export const useSingleFileInput = ( onSetFile: (file: File, formData: FormData) => void, fileType = 'image/*', fileField = 'image', + maxSize?: number, + onError?: () => void, ): [onClick: () => void, reset: () => void] => { const ref = useRef(); @@ -40,9 +42,18 @@ export const useSingleFileInput = ( if (!fileInput?.files?.length) { return; } + + const file = fileInput.files[0]; + + if (maxSize !== undefined && file.size > maxSize) { + onError?.(); + fileInput.value = ''; + return; + } + const formData = new FormData(); - formData.append(fileField, fileInput.files[0]); - onSetFile(fileInput.files[0], formData); + formData.append(fileField, file); + onSetFile(file, formData); }; fileInput.addEventListener('change', handleFiles, false); @@ -50,7 +61,7 @@ export const useSingleFileInput = ( return (): void => { fileInput.removeEventListener('change', handleFiles, false); }; - }, [fileField, fileType, onSetFile]); + }, [fileField, fileType, onSetFile, maxSize, onError]); const onClick = useEffectEvent(() => ref?.current?.click()); const reset = useEffectEvent(() => { diff --git a/apps/meteor/client/hooks/useUserPresenceListener.ts b/apps/meteor/client/hooks/useUserPresenceListener.ts new file mode 100644 index 0000000000000..99aaf32eea273 --- /dev/null +++ b/apps/meteor/client/hooks/useUserPresenceListener.ts @@ -0,0 +1,19 @@ +import { UserStatus } from '@rocket.chat/core-typings'; +import { useStreamAll } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { Presence } from '../lib/presence'; + +const STATUS_MAP = [UserStatus.OFFLINE, UserStatus.ONLINE, UserStatus.AWAY, UserStatus.BUSY, UserStatus.DISABLED]; + +export const useUserPresenceListener = (): void => { + const subscribe = useStreamAll('user-presence'); + + useEffect( + () => + subscribe((uid, [[username, statusChanged, statusText]]) => { + Presence.notify({ _id: uid, username, status: STATUS_MAP[statusChanged as any], statusText }); + }), + [subscribe], + ); +}; diff --git a/apps/meteor/client/hooks/useWorkspaceInfo.ts b/apps/meteor/client/hooks/useWorkspaceInfo.ts index e5efd1f6a508d..657711cf8abf0 100644 --- a/apps/meteor/client/hooks/useWorkspaceInfo.ts +++ b/apps/meteor/client/hooks/useWorkspaceInfo.ts @@ -53,15 +53,15 @@ export const useWorkspaceInfo = ({ refreshStatistics }: { refreshStatistics?: bo queryFn: () => getStatistics({ refresh: refreshStatistics ? 'true' : 'false' }), staleTime: Infinity, placeholderData: keepPreviousData, - select: (data: unknown) => { - const statsData = data as Serialized; + select: (data: Serialized): IStats => { return { - ...statsData, - lastMessageSentAt: statsData.lastMessageSentAt ? new Date(statsData.lastMessageSentAt) : undefined, + ...data, + lastMessageSentAt: data.lastMessageSentAt ? new Date(data.lastMessageSentAt) : undefined, + _updatedAt: new Date(data._updatedAt), }; }, }, - ], + ] as const, }); }; diff --git a/apps/meteor/client/importPackages.ts b/apps/meteor/client/importPackages.ts index 6ec3976093bc5..e78b6a0b32ce8 100644 --- a/apps/meteor/client/importPackages.ts +++ b/apps/meteor/client/importPackages.ts @@ -6,7 +6,6 @@ import '../app/gitlab/client'; import '../app/license/client'; import '../app/lib/client'; import '../app/livechat-enterprise/client'; -import '../app/notifications/client'; import '../app/slackbridge/client'; import '../app/slashcommands-archiveroom/client'; import '../app/slashcommand-asciiarts/client'; @@ -23,4 +22,3 @@ import '../app/slashcommands-unarchiveroom/client'; import '../app/wordpress/client'; import '../app/utils/client'; import '../app/ui-utils/client'; -import '../app/reactions/client'; diff --git a/apps/meteor/client/lib/2fa/overrideLoginMethod.ts b/apps/meteor/client/lib/2fa/overrideLoginMethod.ts index b499201756768..2e339c5741d0e 100644 --- a/apps/meteor/client/lib/2fa/overrideLoginMethod.ts +++ b/apps/meteor/client/lib/2fa/overrideLoginMethod.ts @@ -1,11 +1,9 @@ import { Accounts } from 'meteor/accounts-base'; -import type { Meteor } from 'meteor/meteor'; +import type { MeteorErrorLike } from './types'; import { isTotpInvalidError, isTotpMaxAttemptsError, isTotpRequiredError } from './utils'; -type LoginError = globalThis.Error | Meteor.Error | Meteor.TypedError; - -export type LoginCallback = (error: LoginError | undefined, result?: unknown) => void; +export type LoginCallback = (error: MeteorErrorLike | undefined, result?: unknown) => void; export const overrideLoginMethod = ( loginMethod: (...args: [...args: TArgs, cb: LoginCallback]) => void, @@ -13,47 +11,51 @@ export const overrideLoginMethod = ( callback: LoginCallback | undefined, loginMethodTOTP: (...args: [...args: TArgs, code: string, cb: LoginCallback]) => void, ) => { - loginMethod(...loginArgs, async (error: LoginError | undefined, result?: unknown) => { + loginMethod(...loginArgs, async (error: MeteorErrorLike | undefined, result?: unknown) => { if (!isTotpRequiredError(error)) { callback?.(error); - return; + return error; } const { process2faReturn } = await import('./process2faReturn'); - await process2faReturn({ - error, - result, - emailOrUsername: typeof loginArgs[0] === 'string' ? loginArgs[0] : undefined, - originalCallback: callback, - onCode: (code: string) => { - loginMethodTOTP(...loginArgs, code, (error: LoginError | undefined, result?: unknown) => { - if (!error) { - callback?.(undefined, result); - return; - } + try { + await process2faReturn({ + error, + result, + emailOrUsername: typeof loginArgs[0] === 'string' ? loginArgs[0] : undefined, + originalCallback: callback, + onCode: (code: string) => { + return new Promise((resolve, reject) => { + loginMethodTOTP(...loginArgs, code, (error: MeteorErrorLike | undefined, result?: unknown) => { + if (!error) { + callback?.(undefined, result); + resolve(); + return; + } - if (isTotpInvalidError(error)) { - callback?.(error); - return; - } + if (isTotpInvalidError(error)) { + reject(error); + return; + } - Promise.all([import('../../../app/utils/lib/i18n'), import('../toast')]).then(([{ t }, { dispatchToastMessage }]) => { - if (isTotpMaxAttemptsError(error)) { - dispatchToastMessage({ - type: 'error', - message: t('totp-max-attempts'), - }); - callback?.(undefined); - return; - } + Promise.all([import('../../../app/utils/lib/i18n'), import('../toast')]).then(([{ t }, { dispatchToastMessage }]) => { + if (isTotpMaxAttemptsError(error)) { + dispatchToastMessage({ type: 'error', message: t('totp-max-attempts') }); + reject(error); + return; + } - dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') }); - callback?.(undefined); + dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') }); + reject(error); + }); + }); }); - }); - }, - }); + }, + }); + } catch (error) { + callback?.(error as MeteorErrorLike); + } }); }; @@ -66,7 +68,7 @@ export const handleLogin = Promise { + .catch(async (error: MeteorErrorLike | undefined) => { if (!isTotpRequiredError(error)) { return Promise.reject(error); } @@ -79,7 +81,7 @@ export const handleLogin = Promise callback?.(undefined, result)) - .catch((error: LoginError | undefined) => { + .catch((error: MeteorErrorLike | undefined) => { if (!isTotpInvalidError(error)) { callback?.(error); return; diff --git a/apps/meteor/client/lib/2fa/process2faReturn.ts b/apps/meteor/client/lib/2fa/process2faReturn.ts index 4042370caae39..6f1c5378674ce 100644 --- a/apps/meteor/client/lib/2fa/process2faReturn.ts +++ b/apps/meteor/client/lib/2fa/process2faReturn.ts @@ -1,9 +1,9 @@ import { SHA256 } from '@rocket.chat/sha256'; import { imperativeModal } from '@rocket.chat/ui-client'; -import { Meteor } from 'meteor/meteor'; import { lazy } from 'react'; import type { LoginCallback } from './overrideLoginMethod'; +import type { MeteorErrorLike } from './types'; import { isTotpInvalidError, isTotpRequiredError } from './utils'; import { getUser } from '../user'; @@ -16,8 +16,8 @@ type TwoFactorMethod = (typeof twoFactorMethods)[number]; const isTwoFactorMethod = (method: string): method is TwoFactorMethod => twoFactorMethods.includes(method as TwoFactorMethod); const hasRequiredTwoFactorMethod = ( - error: Meteor.Error, -): error is Meteor.Error & { details: { method: TwoFactorMethod; emailOrUsername?: string } } => { + error: MeteorErrorLike, +): error is MeteorErrorLike & { details: { method: TwoFactorMethod; emailOrUsername?: string } } => { const details = error.details as unknown; return ( @@ -61,10 +61,10 @@ export async function process2faReturn({ onCode, emailOrUsername, }: { - error: globalThis.Error | Meteor.Error | Meteor.TypedError | undefined; + error: MeteorErrorLike | undefined; result: unknown; originalCallback: LoginCallback | undefined; - onCode: (code: string, method: string) => void; + onCode: (code: string, method: string) => void | Promise; emailOrUsername: { username: string } | { email: string } | { id: string } | string | null | undefined; }): Promise { if (!(isTotpRequiredError(error) || isTotpInvalidError(error)) || !hasRequiredTwoFactorMethod(error)) { @@ -74,23 +74,13 @@ export async function process2faReturn({ const props = { ...getProps(error.details.method, emailOrUsername || error.details.emailOrUsername), - // eslint-disable-next-line no-nested-ternary - invalidAttempt: isTotpInvalidError(error), }; - try { - const code = await invokeTwoFactorModal(props); - - onCode(code, props.method); - } catch (error) { - process2faReturn({ - error: error as globalThis.Error | Meteor.Error | Meteor.TypedError | undefined, - result, - originalCallback, - onCode, - emailOrUsername, - }); - } + const validateCode = async (code: string, method: string): Promise => { + await onCode(code, method); + }; + + await invokeTwoFactorModal(props, validateCode); } export async function process2faAsyncReturn({ @@ -111,45 +101,70 @@ export async function process2faAsyncReturn({ const props = { method: error.details.method, emailOrUsername: emailOrUsername || error.details.emailOrUsername || getUser()?.username, - // eslint-disable-next-line no-nested-ternary - invalidAttempt: isTotpInvalidError(error), }; assertModalProps(props); - try { - const code = await invokeTwoFactorModal(props); + let result: TResult | undefined; - return onCode(code, props.method); - } catch (error) { - return process2faAsyncReturn({ - error, - onCode, - emailOrUsername, - }); + const validateCode = async (code: string, method: string): Promise => { + result = await onCode(code, method); + }; + + await invokeTwoFactorModal(props, validateCode); + + if (result === undefined) { + throw new Error('Unexpected error: result is undefined'); } + + return result; } -export const invokeTwoFactorModal = async (props: { - method: 'totp' | 'email' | 'password'; - emailOrUsername?: string | undefined; - invalidAttempt?: boolean; -}) => { - assertModalProps(props); +export const invokeTwoFactorModal = async ( + props: { + method: 'totp' | 'email' | 'password'; + emailOrUsername?: string | undefined; + invalidAttempt?: boolean; + }, + validateCode?: (code: string, method: string) => Promise, +) => { + const { invalidAttempt, ...restProps } = props; + assertModalProps(restProps); return new Promise((resolve, reject) => { + let isResolved = false; + let isClosed = false; + imperativeModal.open({ component: TwoFactorModal, props: { - ...props, - onConfirm: (code: string, method: string): void => { + ...restProps, + onConfirm: async (code: string, method: string): Promise => { + const actualCode = method === 'password' ? SHA256(code) : code; + if (validateCode) { + await validateCode(actualCode, method); + } + isResolved = true; imperativeModal.close(); - resolve(method === 'password' ? SHA256(code) : code); + resolve(actualCode); }, onClose: (): void => { + if (isClosed) { + return; + } + isClosed = true; imperativeModal.close(); - reject(new Meteor.Error('totp-canceled')); + if (!isResolved) { + Promise.all([import('../../../app/utils/lib/i18n'), import('../toast')]).then(([{ t }, { dispatchToastMessage }]) => { + dispatchToastMessage({ + type: 'error', + message: t('Two-factor_authentication_cancelled'), + }); + }); + reject(new Error('totp-canceled')); + } }, + ...(invalidAttempt && { invalidAttempt }), }, }); }); diff --git a/apps/meteor/client/lib/2fa/types.ts b/apps/meteor/client/lib/2fa/types.ts new file mode 100644 index 0000000000000..7eca3d4e52004 --- /dev/null +++ b/apps/meteor/client/lib/2fa/types.ts @@ -0,0 +1,9 @@ +// Structural shape covering both Meteor.Error and Meteor.TypedError. The 2FA +// helpers only narrow errors by `error` / `errorType` / `details` — they never +// instanceof — so a local type avoids pulling `meteor/meteor` for type info. +export type MeteorErrorLike = Error & { + error?: unknown; + errorType?: unknown; + reason?: string; + details?: unknown; +}; diff --git a/apps/meteor/client/lib/2fa/utils.ts b/apps/meteor/client/lib/2fa/utils.ts index d8d797d47b267..355483bf0f303 100644 --- a/apps/meteor/client/lib/2fa/utils.ts +++ b/apps/meteor/client/lib/2fa/utils.ts @@ -1,18 +1,20 @@ -import type { Meteor } from 'meteor/meteor'; +import type { MeteorErrorLike } from './types'; export const isTotpRequiredError = ( error: unknown, -): error is Meteor.Error & ({ error: 'totp-required' } | { errorType: 'totp-required' }) => +): error is MeteorErrorLike & ({ error: 'totp-required' } | { errorType: 'totp-required' }) => typeof error === 'object' && ((error as { error?: unknown } | undefined)?.error === 'totp-required' || (error as { errorType?: unknown } | undefined)?.errorType === 'totp-required'); -export const isTotpInvalidError = (error: unknown): error is Meteor.Error & ({ error: 'totp-invalid' } | { errorType: 'totp-invalid' }) => +export const isTotpInvalidError = ( + error: unknown, +): error is MeteorErrorLike & ({ error: 'totp-invalid' } | { errorType: 'totp-invalid' }) => (error as { error?: unknown } | undefined)?.error === 'totp-invalid' || (error as { errorType?: unknown } | undefined)?.errorType === 'totp-invalid'; export const isTotpMaxAttemptsError = ( error: unknown, -): error is Meteor.Error & ({ error: 'totp-max-attempts' } | { errorType: 'totp-max-attempts' }) => +): error is MeteorErrorLike & ({ error: 'totp-max-attempts' } | { errorType: 'totp-max-attempts' }) => (error as { error?: unknown } | undefined)?.error === 'totp-max-attempts' || (error as { errorType?: unknown } | undefined)?.errorType === 'totp-max-attempts'; diff --git a/apps/meteor/client/lib/absoluteUrl.spec.ts b/apps/meteor/client/lib/absoluteUrl.spec.ts new file mode 100644 index 0000000000000..61cee224d8a5b --- /dev/null +++ b/apps/meteor/client/lib/absoluteUrl.spec.ts @@ -0,0 +1,107 @@ +import { absoluteUrl, _relativeToSiteRootUrl } from './absoluteUrl'; + +jest.mock('./baseURI', () => ({ + baseURI: 'http://localhost:3000/', +})); + +beforeEach(() => { + absoluteUrl.defaultOptions = { rootUrl: 'http://localhost:3000/' }; +}); + +describe('absoluteUrl', () => { + it('should return the root URL with a trailing slash when no path is given', () => { + expect(absoluteUrl(undefined, { rootUrl: 'http://example.com' })).toBe('http://example.com/'); + }); + + it('should append the path to the root URL', () => { + expect(absoluteUrl('foo/bar', { rootUrl: 'http://example.com' })).toBe('http://example.com/foo/bar'); + }); + + it('should strip leading slashes from the path', () => { + expect(absoluteUrl('///foo', { rootUrl: 'http://example.com' })).toBe('http://example.com/foo'); + }); + + it('should prepend http:// when the rootUrl has no protocol', () => { + expect(absoluteUrl(undefined, { rootUrl: 'example.com' })).toBe('http://example.com/'); + }); + + it('should preserve https:// when already present in rootUrl', () => { + expect(absoluteUrl('path', { rootUrl: 'https://example.com' })).toBe('https://example.com/path'); + }); + + it('should throw when rootUrl is not provided and defaultOptions.rootUrl is unset', () => { + absoluteUrl.defaultOptions = {}; + expect(() => absoluteUrl()).toThrow('Must pass options.rootUrl or set ROOT_URL in the server environment'); + }); + + it('should use defaultOptions.rootUrl when no rootUrl option is given', () => { + absoluteUrl.defaultOptions = { rootUrl: 'http://default.example.com' }; + expect(absoluteUrl('test')).toBe('http://default.example.com/test'); + }); + + it('should upgrade http to https when secure is true and host is not localhost', () => { + expect(absoluteUrl('path', { rootUrl: 'http://example.com', secure: true })).toBe('https://example.com/path'); + }); + + it('should not upgrade to https for localhost when secure is true', () => { + expect(absoluteUrl('path', { rootUrl: 'http://localhost:3000', secure: true })).toBe('http://localhost:3000/path'); + }); + + it('should not upgrade to https for 127.0.0.1 when secure is true', () => { + expect(absoluteUrl('path', { rootUrl: 'http://127.0.0.1:3000', secure: true })).toBe('http://127.0.0.1:3000/path'); + }); + + it('should replace localhost with 127.0.0.1 when replaceLocalhost is true', () => { + expect(absoluteUrl('path', { rootUrl: 'http://localhost:3000', replaceLocalhost: true })).toBe('http://127.0.0.1:3000/path'); + }); + + it('should not replace localhost when replaceLocalhost is false', () => { + expect(absoluteUrl('path', { rootUrl: 'http://localhost:3000', replaceLocalhost: false })).toBe('http://localhost:3000/path'); + }); + + it('should accept options as the first argument when path is omitted', () => { + expect(absoluteUrl({ rootUrl: 'http://example.com' } as any)).toBe('http://example.com/'); + }); + + it('should not duplicate trailing slash', () => { + expect(absoluteUrl(undefined, { rootUrl: 'http://example.com/' })).toBe('http://example.com/'); + }); +}); + +describe('_relativeToSiteRootUrl', () => { + const originalConfig = (globalThis as any).__meteor_runtime_config__; + + afterEach(() => { + (globalThis as any).__meteor_runtime_config__ = originalConfig; + }); + + it('should prepend ROOT_URL_PATH_PREFIX to links starting with /', () => { + (globalThis as any).__meteor_runtime_config__ = { ROOT_URL_PATH_PREFIX: '/subdir' }; + expect(_relativeToSiteRootUrl('/route')).toBe('/subdir/route'); + }); + + it('should return the link unchanged when it does not start with /', () => { + (globalThis as any).__meteor_runtime_config__ = { ROOT_URL_PATH_PREFIX: '/subdir' }; + expect(_relativeToSiteRootUrl('route')).toBe('route'); + }); + + it('should return the link unchanged when __meteor_runtime_config__ is not an object', () => { + delete (globalThis as any).__meteor_runtime_config__; + expect(_relativeToSiteRootUrl('/route')).toBe('/route'); + }); + + it('should handle empty ROOT_URL_PATH_PREFIX', () => { + (globalThis as any).__meteor_runtime_config__ = { ROOT_URL_PATH_PREFIX: '' }; + expect(_relativeToSiteRootUrl('/route')).toBe('/route'); + }); +}); + +describe('defaultOptions', () => { + it('should initialize rootUrl from baseURI', () => { + expect(absoluteUrl.defaultOptions.rootUrl).toBe('http://localhost:3000/'); + }); + + it('should initialize secure from window.isSecureContext', () => { + expect(absoluteUrl.defaultOptions.secure).toBe(window.isSecureContext); + }); +}); diff --git a/apps/meteor/client/lib/absoluteUrl.ts b/apps/meteor/client/lib/absoluteUrl.ts new file mode 100644 index 0000000000000..bb4e7b9d03d00 --- /dev/null +++ b/apps/meteor/client/lib/absoluteUrl.ts @@ -0,0 +1,59 @@ +// There is a good chance this module may be promoted to root lib/ in the future + +import { baseURI } from './baseURI'; +import { getRootUrlPathPrefix } from './meteorRuntimeConfig'; + +type AbsoluteUrlOptions = { + rootUrl?: string; + secure?: boolean; + replaceLocalhost?: boolean; +}; + +export function absoluteUrl(path?: string, options?: AbsoluteUrlOptions): string { + if (!options && typeof path === 'object') { + options = path; + path = undefined; + } + + options = { ...absoluteUrl.defaultOptions, ...options }; + + let { rootUrl } = options; + + if (!rootUrl) throw Error('Must pass options.rootUrl or set ROOT_URL in the server environment'); + + if (!/^http[s]?:\/\//i.test(rootUrl)) { + rootUrl = `http://${rootUrl}`; + } + + if (!rootUrl.endsWith('/')) { + rootUrl += '/'; + } + + if (path) { + while (path.startsWith('/')) path = path.slice(1); + rootUrl += path; + } + + if (options.secure && /^http:/.test(rootUrl) && !/http:\/\/localhost[:/]/.test(rootUrl) && !/http:\/\/127\.0\.0\.1[:/]/.test(rootUrl)) { + rootUrl = rootUrl.replace(/^http:/, 'https:'); + } + + if (options.replaceLocalhost) { + rootUrl = rootUrl.replace(/^http:\/\/localhost([:/].*)/, 'http://127.0.0.1$1'); + } + + return rootUrl; +} + +absoluteUrl.defaultOptions = { + rootUrl: baseURI, + secure: window.isSecureContext, +} as AbsoluteUrlOptions; + +export function _relativeToSiteRootUrl(link: string): string { + if (link.startsWith('/')) { + link = getRootUrlPathPrefix() + link; + } + + return link; +} diff --git a/apps/meteor/client/lib/cachedStores/CachedStore.ts b/apps/meteor/client/lib/cachedStores/CachedStore.ts index 591de8226cf79..84702f8676cf0 100644 --- a/apps/meteor/client/lib/cachedStores/CachedStore.ts +++ b/apps/meteor/client/lib/cachedStores/CachedStore.ts @@ -2,9 +2,7 @@ import type { IRocketChatRecord } from '@rocket.chat/core-typings'; import type { StreamNames } from '@rocket.chat/ddp-client'; import { isTruthy } from '@rocket.chat/tools'; import localforage from 'localforage'; -import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; import { create, type StoreApi, type UseBoundStore } from 'zustand'; import { baseURI } from '../baseURI'; @@ -13,6 +11,8 @@ import { CachedStoresManager } from './CachedStoresManager'; import type { IDocumentMapStore } from './DocumentMapStore'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { withDebouncing } from '../../../lib/utils/highOrderFunctions'; +import { getDdpSdk } from '../sdk/ddpSdk'; +import { STORAGE_KEYS, getStoredItem } from '../sdk/storage'; import { getUserId } from '../user'; import { getConfig } from '../utils/getConfig'; @@ -50,7 +50,11 @@ export abstract class CachedStore implements protected eventType: StreamNames; - private readonly version = 18; + // Bumped from 18 → 19 to invalidate caches populated before the DDPSDK + // wire encoding was switched from JSON to EJSON. Entries written by the + // JSON window stored dates as ISO strings instead of Date instances, so + // fields like subscription.ls would fail `.getTime()` when read back. + private readonly version = 19; private updatedAt = new Date(0); @@ -310,16 +314,16 @@ export abstract class CachedStore implements await this.loadFromServerAndPopulate(); } - this.reconnectionComputation?.stop(); - let wentOffline = Tracker.nonreactive(() => Meteor.status().status === 'offline'); - this.reconnectionComputation = Tracker.autorun(() => { - const { status } = Meteor.status(); - - if (status === 'offline') { + this.reconnectionUnsubscribe?.(); + const sdk = getDdpSdk(); + let wentOffline = sdk.connection.status !== 'connected'; + this.reconnectionUnsubscribe = sdk.connection.on('connection', () => { + if (sdk.connection.status !== 'connected') { wentOffline = true; + return; } - - if (status === 'connected' && wentOffline) { + if (wentOffline) { + wentOffline = false; this.trySync(); } }); @@ -357,7 +361,7 @@ export abstract class CachedStore implements this.setReady(false); } - private reconnectionComputation: Tracker.Computation | undefined; + private reconnectionUnsubscribe: (() => void) | undefined; setReady(ready: boolean) { this.useReady.setState(ready); @@ -376,7 +380,7 @@ export class PublicCachedStore extends Cache export class PrivateCachedStore extends CachedStore { protected override getToken() { - return Accounts._storedLoginToken(); + return getStoredItem(STORAGE_KEYS.LOGIN_TOKEN); } override clearCacheOnLogout() { @@ -392,7 +396,7 @@ export class PrivateCachedStore extends Cach void this.init(); }); - Accounts.onLogout(() => { + getDdpSdk().account.onLogout(() => { this.release(); }); } diff --git a/apps/meteor/client/lib/cachedStores/CachedStoresManager.ts b/apps/meteor/client/lib/cachedStores/CachedStoresManager.ts index b899dde4b33a6..8988b36e6c946 100644 --- a/apps/meteor/client/lib/cachedStores/CachedStoresManager.ts +++ b/apps/meteor/client/lib/cachedStores/CachedStoresManager.ts @@ -1,4 +1,5 @@ import type { IWithManageableCache } from './CachedStore'; +import { getDdpSdk } from '../sdk/ddpSdk'; class CachedStoresManager { private items = new Set(); @@ -16,6 +17,8 @@ class CachedStoresManager { const instance = new CachedStoresManager(); +getDdpSdk().account.onLogout(() => instance.clearAllCachesOnLogout()); + export { /** @deprecated */ instance as CachedStoresManager, diff --git a/apps/meteor/client/lib/cachedStores/applyQueryOptions.ts b/apps/meteor/client/lib/cachedStores/applyQueryOptions.ts index 52f0d72e4974a..4e5abd99a45f5 100644 --- a/apps/meteor/client/lib/cachedStores/applyQueryOptions.ts +++ b/apps/meteor/client/lib/cachedStores/applyQueryOptions.ts @@ -38,7 +38,7 @@ export const applyQueryOptions = >(records: T[], o let currentPipeline = pipe(records); if (options.sort) { const sortObj = convertSort(options.sort); - for (let i = sortObj.sort.length - 1; i >= 0; i--) { + for (let i = sortObj.length - 1; i >= 0; i--) { const { field, direction } = sortObj[i]; currentPipeline = currentPipeline.sortByField(field, direction); } diff --git a/apps/meteor/client/lib/chats/data.ts b/apps/meteor/client/lib/chats/data.ts index bd02100bb9e87..96d576e64f3ad 100644 --- a/apps/meteor/client/lib/chats/data.ts +++ b/apps/meteor/client/lib/chats/data.ts @@ -10,6 +10,7 @@ import { Messages, Rooms, Subscriptions } from '../../stores'; import { settings } from '../settings'; import { getUserId } from '../user'; import { prependReplies } from '../utils/prependReplies'; +import { upsertThreadMessageInCache } from '../utils/threadMessageUtils'; export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage['_id'] | undefined }): DataAPI => { const composeMessage = async ( @@ -162,7 +163,12 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage }; const pushEphemeralMessage = async (message: Omit): Promise => { - Messages.state.store({ ...message, rid, ...(tmid && { tmid }) }); + const fullMessage = { ...message, rid, ...(tmid && { tmid }) } as IMessage; + Messages.state.store(fullMessage); + + if (tmid) { + upsertThreadMessageInCache(fullMessage, rid, tmid); + } }; const updateMessage = async (message: IEditedMessage, previewUrls?: string[]): Promise => { diff --git a/apps/meteor/client/lib/chats/flows/afterSendMessageCallback.ts b/apps/meteor/client/lib/chats/flows/afterSendMessageCallback.ts new file mode 100644 index 0000000000000..10ee699e014c6 --- /dev/null +++ b/apps/meteor/client/lib/chats/flows/afterSendMessageCallback.ts @@ -0,0 +1,24 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { clientCallbacks } from '@rocket.chat/ui-client'; + +import { Rooms } from '../../../stores'; +import { getUser } from '../../user'; + +export const afterSendMessageCallback = async (message: IMessage, rid: string) => { + const user = getUser(); + const room = Rooms.state.get(rid); + + if (!room || !user) { + return; + } + + const processedMessage = { + ...message, + u: { + _id: user?._id, + username: user?.username, + name: user?.name || '', + }, + }; + await clientCallbacks.run('afterSaveMessage', processedMessage, { room, user }); +}; diff --git a/apps/meteor/client/lib/chats/flows/processSetReaction.ts b/apps/meteor/client/lib/chats/flows/processSetReaction.ts index 1478320ef3e0b..74e28efa937d7 100644 --- a/apps/meteor/client/lib/chats/flows/processSetReaction.ts +++ b/apps/meteor/client/lib/chats/flows/processSetReaction.ts @@ -1,6 +1,7 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { emoji } from '../../../../app/emoji/client'; +import { runOptimisticSetReaction } from '../../../../app/reactions/client/methods/setReaction'; import { callWithErrorHandling } from '../../utils/callWithErrorHandling'; import type { ChatAPI } from '../ChatAPI'; @@ -22,6 +23,7 @@ export const processSetReaction = async (chat: ChatAPI, { msg }: Pick ({ + roomCoordinator: { + openRouteLink: jest.fn(), + }, +})); + +jest.mock('../../../providers/RouterProvider', () => ({ + router: { + getSearchParameters: jest.fn(), + }, +})); + +const mockedOpenRouteLink = jest.mocked(roomCoordinator.openRouteLink); +const mockedGetSearchParameters = jest.mocked(router.getSearchParameters); + +const messageAuthor = { + _id: 'author-user-id', + username: 'authoruser', + name: 'Author User', +}; + +const message = createFakeMessage({ + _id: 'reply-message-id', + u: messageAuthor, +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('replyBroadcast', () => { + it('should not carry over the msg search parameter when opening a direct message', async () => { + mockedGetSearchParameters.mockReturnValue({ + msg: 'stale-message-id', + layout: 'embedded', + }); + + await replyBroadcast({} as ChatAPI, message); + + expect(mockedGetSearchParameters).toHaveBeenCalled(); + expect(mockedOpenRouteLink).toHaveBeenCalledWith( + 'd', + { name: messageAuthor.username }, + { + layout: 'embedded', + reply: 'reply-message-id', + }, + ); + + const searchParams = mockedOpenRouteLink.mock.calls[0]?.[2]; + expect(searchParams).not.toHaveProperty('msg'); + }); +}); diff --git a/apps/meteor/client/lib/chats/flows/replyBroadcast.ts b/apps/meteor/client/lib/chats/flows/replyBroadcast.ts index 69e857ee57cb3..9d58654ae647a 100644 --- a/apps/meteor/client/lib/chats/flows/replyBroadcast.ts +++ b/apps/meteor/client/lib/chats/flows/replyBroadcast.ts @@ -5,11 +5,12 @@ import { roomCoordinator } from '../../rooms/roomCoordinator'; import type { ChatAPI } from '../ChatAPI'; export const replyBroadcast = async (_chat: ChatAPI, message: IMessage) => { + const { msg: _, ...searchParameters } = router.getSearchParameters(); roomCoordinator.openRouteLink( 'd', { name: message.u.username }, { - ...router.getSearchParameters(), + ...searchParameters, reply: message._id, }, ); diff --git a/apps/meteor/client/lib/chats/flows/sendMessage.ts b/apps/meteor/client/lib/chats/flows/sendMessage.ts index e2ad4962a851d..69d2a079d16c9 100644 --- a/apps/meteor/client/lib/chats/flows/sendMessage.ts +++ b/apps/meteor/client/lib/chats/flows/sendMessage.ts @@ -1,5 +1,6 @@ import type { IMessage } from '@rocket.chat/core-typings'; +import { runOptimisticSendMessage } from '../../../../app/lib/client/methods/sendMessage'; import { sdk } from '../../../../app/utils/client/lib/SDKClient'; import { t } from '../../../../app/utils/lib/i18n'; import { closeUnclosedCodeBlock } from '../../../../lib/utils/closeUnclosedCodeBlock'; @@ -7,6 +8,7 @@ import { Messages } from '../../../stores'; import { onClientBeforeSendMessage } from '../../onClientBeforeSendMessage'; import { dispatchToastMessage } from '../../toast'; import type { ChatAPI } from '../ChatAPI'; +import { afterSendMessageCallback } from './afterSendMessageCallback'; import { processMessageEditing } from './processMessageEditing'; import { processMessageUploads } from './processMessageUploads'; import { processSetReaction } from './processSetReaction'; @@ -44,6 +46,7 @@ const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[], } chat.composer?.clear(); + await runOptimisticSendMessage(message); await sdk.call('sendMessage', message, previewUrls); // after the request is complete we can go ahead and mark as sent @@ -106,6 +109,7 @@ export const sendMessage = async ( try { await process(chat, message, previewUrls, isSlashCommandAllowed); chat.composer?.dismissAllQuotedMessages(); + await afterSendMessageCallback(message, message.rid); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } diff --git a/apps/meteor/client/lib/chats/readStateManager.ts b/apps/meteor/client/lib/chats/readStateManager.ts index f464093b79476..e29c76cf63296 100644 --- a/apps/meteor/client/lib/chats/readStateManager.ts +++ b/apps/meteor/client/lib/chats/readStateManager.ts @@ -24,12 +24,6 @@ export class ReadStateManager extends Emitter { return this.rid; } - // TODO: Use ref to get unreadMark - // private unreadMark?: HTMLElement; - private get unreadMark() { - return document.querySelector('.rcx-message-divider--unread'); - } - public onUnreadStateChange = (callback: () => void): (() => void) => { return this.on('unread-state-change', callback); }; @@ -78,7 +72,8 @@ export class ReadStateManager extends Emitter { (record) => record.rid === this.subscription?.rid && record.ts.getTime() > (this.subscription.ls?.getTime() ?? 0) && - record.u._id !== getUserId(), + record.u._id !== getUserId() && + (!record.tmid || record.tshow === true), (a, b) => a.ts.getTime() - b.ts.getTime(), ); @@ -115,12 +110,10 @@ export class ReadStateManager extends Emitter { }; }; - private isUnreadMarkVisible(): boolean { - if (!this.unreadMark) { - return false; - } + private isUnreadMarkVisible: () => boolean = () => false; - return this.unreadMark.offsetTop > (this.unreadMark.offsetParent?.scrollTop || 0); + public setIsUnreadMarkVisibleCallback(callback: () => boolean) { + this.isUnreadMarkVisible = callback; } // This will only mark as read if the unread mark is visible @@ -134,11 +127,11 @@ export class ReadStateManager extends Emitter { return; } - if (this.unreadMark && !this.isUnreadMarkVisible()) { + if (this.firstUnreadRecordId && this.isUnreadMarkVisible() === false) { return; } // if there are unloaded unread messages, don't mark as read - if (RoomHistoryManager.getRoom(this.rid).unreadNotLoaded.get() > 0) { + if (RoomHistoryManager.getRoom(this.rid).unreadNotLoaded > 0) { return; } @@ -160,7 +153,7 @@ export class ReadStateManager extends Emitter { } return sdk.rest.post('/v1/subscriptions.read', { rid: this.rid }).then(() => { - RoomHistoryManager.getRoom(this.rid).unreadNotLoaded.set(0); + RoomHistoryManager.updateRoom(this.rid, { unreadNotLoaded: 0 }); }); } } diff --git a/apps/meteor/client/lib/createReactiveSubscriptionFactory.ts b/apps/meteor/client/lib/createReactiveSubscriptionFactory.ts deleted file mode 100644 index 8bebcff847de7..0000000000000 --- a/apps/meteor/client/lib/createReactiveSubscriptionFactory.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Tracker } from 'meteor/tracker'; - -interface ISubscriptionFactory { - (...args: any[]): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => T]; -} - -export const createReactiveSubscriptionFactory = - (computeCurrentValueWith: (...args: any[]) => T): ISubscriptionFactory => - (...args: any[]): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => T] => { - const callbacks = new Set<() => void>(); - - let currentValue = computeCurrentValueWith(...args); - - const reactiveFn = (): void => { - currentValue = computeCurrentValueWith(...args); - queueMicrotask(() => { - callbacks.forEach((callback) => { - callback(); - }); - }); - }; - - let computation: Tracker.Computation | undefined; - - return [ - (callback): (() => void) => { - callbacks.add(callback); - - queueMicrotask(() => { - if (!computation || computation.stopped) { - computation = Tracker.autorun(reactiveFn); - } - }); - - return (): void => { - callbacks.delete(callback); - - if (callbacks.size === 0) { - queueMicrotask(() => computation?.stop()); - } - }; - }, - (): T => currentValue, - ]; - }; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.spec.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.spec.ts new file mode 100644 index 0000000000000..65cb44c921df5 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.spec.ts @@ -0,0 +1,70 @@ +import { imperativeModal } from '@rocket.chat/ui-client'; + +import * as banners from '../banners'; +import { e2e } from './rocketchat.e2e'; +import { dispatchToastMessage } from '../toast'; + +jest.mock('@rocket.chat/ui-client', () => ({ + imperativeModal: { + open: jest.fn(), + close: jest.fn(), + }, +})); + +jest.mock('../../../app/utils/client', () => ({ + getUserAvatarURL: jest.fn(), +})); + +jest.mock('../../../app/utils/lib/i18n', () => ({ + t: (key: string) => key, +})); + +jest.mock('../toast', () => ({ + dispatchToastMessage: jest.fn(), +})); + +jest.mock('../banners', () => ({ + closeById: jest.fn(), + open: jest.fn(), +})); + +describe('E2E password modal', () => { + const getModalProps = () => (imperativeModal.open as jest.Mock).mock.calls.at(-1)?.[0].props; + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('keeps the E2EE alert open when the modal header close button is used', () => { + e2e.openEnterE2EEPasswordModal(jest.fn()); + + getModalProps().onClose(); + + expect(imperativeModal.close).toHaveBeenCalledTimes(1); + expect(banners.closeById).not.toHaveBeenCalled(); + }); + + it('dismisses the E2EE alert when the user explicitly cancels entering the password', () => { + e2e.openEnterE2EEPasswordModal(jest.fn()); + + getModalProps().onCancel(); + + expect(dispatchToastMessage).toHaveBeenCalledWith({ type: 'info', message: 'End_To_End_Encryption_Not_Enabled' }); + expect(imperativeModal.close).toHaveBeenCalledTimes(1); + expect(banners.closeById).toHaveBeenCalledWith('e2e'); + }); + + it('dismisses the E2EE alert after the password is entered successfully', async () => { + const onEnterE2EEPassword = jest.fn().mockResolvedValue(undefined); + + e2e.openEnterE2EEPasswordModal(onEnterE2EEPassword); + + await getModalProps().onConfirm('password'); + + expect(onEnterE2EEPassword).toHaveBeenCalledWith('password'); + expect(dispatchToastMessage).toHaveBeenCalledWith({ type: 'success', message: 'E2E_encryption_enabled' }); + expect(imperativeModal.close).toHaveBeenCalledTimes(1); + expect(banners.closeById).toHaveBeenCalledWith('e2e'); + }); +}); diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index c5418ef9967cc..6562dc384e36f 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -8,7 +8,6 @@ import { isTruthy } from '@rocket.chat/tools'; import { imperativeModal } from '@rocket.chat/ui-client'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import sampleSize from 'lodash/sampleSize'; -import { Accounts } from 'meteor/accounts-base'; import type { E2EEState } from './E2EEState'; import * as Rsa from './crypto/rsa'; @@ -27,6 +26,8 @@ import EnterE2EPasswordModal from '../../views/e2e/EnterE2EPasswordModal'; import SaveE2EPasswordModal from '../../views/e2e/SaveE2EPasswordModal'; import * as banners from '../banners'; import type { LegacyBannerPayload } from '../banners'; +import { getDdpSdk } from '../sdk/ddpSdk'; +import { STORAGE_KEYS, getStoredItem, removeStoredItem, setStoredItem } from '../sdk/storage'; import { settings } from '../settings'; import { dispatchToastMessage } from '../toast'; import { mapMessageFromApi } from '../utils/mapMessageFromApi'; @@ -314,8 +315,8 @@ class E2E extends Emitter { getKeysFromLocalStorage(): KeyPair { return { - public_key: Accounts.storageLocation.getItem('public_key'), - private_key: Accounts.storageLocation.getItem('private_key'), + public_key: getStoredItem(STORAGE_KEYS.E2EE_PUBLIC_KEY), + private_key: getStoredItem(STORAGE_KEYS.E2EE_PRIVATE_KEY), }; } @@ -334,7 +335,7 @@ class E2E extends Emitter { imperativeModal.close(); }, onConfirm: () => { - Accounts.storageLocation.removeItem('e2e.randomPassword'); + removeStoredItem(STORAGE_KEYS.E2EE_RANDOM_PASSWORD); this.setState('READY'); dispatchToastMessage({ type: 'success', message: t('E2E_encryption_enabled') }); this.closeAlert(); @@ -402,7 +403,7 @@ class E2E extends Emitter { await this.persistKeys(this.getKeysFromLocalStorage(), await this.createRandomPassword()); } - const randomPassword = Accounts.storageLocation.getItem('e2e.randomPassword'); + const randomPassword = getStoredItem(STORAGE_KEYS.E2EE_RANDOM_PASSWORD); if (randomPassword) { this.setState('SAVE_PASSWORD'); this.openAlert({ @@ -421,8 +422,8 @@ class E2E extends Emitter { span.info(this.state); this.closeAlert(); - Accounts.storageLocation.removeItem('public_key'); - Accounts.storageLocation.removeItem('private_key'); + removeStoredItem(STORAGE_KEYS.E2EE_PUBLIC_KEY); + removeStoredItem(STORAGE_KEYS.E2EE_PRIVATE_KEY); this.instancesByRoomId = {}; this.privateKey = undefined; this.publicKey = undefined; @@ -437,8 +438,8 @@ class E2E extends Emitter { async changePassword(newPassword: string): Promise { await this.persistKeys(this.getKeysFromLocalStorage(), newPassword, { force: true }); - if (Accounts.storageLocation.getItem('e2e.randomPassword')) { - Accounts.storageLocation.setItem('e2e.randomPassword', newPassword); + if (getStoredItem(STORAGE_KEYS.E2EE_RANDOM_PASSWORD)) { + setStoredItem(STORAGE_KEYS.E2EE_RANDOM_PASSWORD, newPassword); } } @@ -463,13 +464,13 @@ class E2E extends Emitter { async loadKeys({ public_key, private_key }: { public_key: string; private_key: string }): Promise { const span = log.span('loadKeys'); - Accounts.storageLocation.setItem('public_key', public_key); + setStoredItem(STORAGE_KEYS.E2EE_PUBLIC_KEY, public_key); this.publicKey = public_key; try { this.privateKey = await Rsa.importPrivateKey(JSON.parse(private_key)); - Accounts.storageLocation.setItem('private_key', private_key); + setStoredItem(STORAGE_KEYS.E2EE_PRIVATE_KEY, private_key); } catch (error) { this.setState('ERROR'); return span.error('Error importing private key: ', error); @@ -493,7 +494,7 @@ class E2E extends Emitter { const publicKey = await Rsa.exportPublicKey(keyPair.publicKey); this.publicKey = JSON.stringify(publicKey); - Accounts.storageLocation.setItem('public_key', JSON.stringify(publicKey)); + setStoredItem(STORAGE_KEYS.E2EE_PUBLIC_KEY, JSON.stringify(publicKey)); } catch (error) { this.setState('ERROR'); return span.set('error', error).error('Error exporting public key'); @@ -502,7 +503,7 @@ class E2E extends Emitter { try { const privateKey = await Rsa.exportPrivateKey(keyPair.privateKey); - Accounts.storageLocation.setItem('private_key', JSON.stringify(privateKey)); + setStoredItem(STORAGE_KEYS.E2EE_PRIVATE_KEY, JSON.stringify(privateKey)); } catch (error) { this.setState('ERROR'); return span.set('error', error).error('Error exporting private key'); @@ -517,33 +518,43 @@ class E2E extends Emitter { async createRandomPassword(): Promise { const randomPassword = await generatePassphrase(); - Accounts.storageLocation.setItem('e2e.randomPassword', randomPassword); + setStoredItem(STORAGE_KEYS.E2EE_RANDOM_PASSWORD, randomPassword); return randomPassword; } - openEnterE2EEPasswordModal(onEnterE2EEPassword?: (password: string) => void) { + openEnterE2EEPasswordModal(onEnterE2EEPassword: (password: string) => Promise) { + const close = () => { + imperativeModal.close(); + }; + const dismiss = () => { + this.closeAlert(); + imperativeModal.close(); + }; imperativeModal.open({ component: EnterE2EPasswordModal, props: { - onClose: imperativeModal.close, + onClose: close, onCancel: () => { failedToDecodeKey = false; dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') }); - this.closeAlert(); - imperativeModal.close(); + dismiss(); }, - onConfirm: (password) => { - onEnterE2EEPassword?.(password); - this.closeAlert(); - imperativeModal.close(); + onConfirm: async (password) => { + await onEnterE2EEPassword(password); + dispatchToastMessage({ type: 'success', message: t('E2E_encryption_enabled') }); + dismiss(); }, }, }); } - async requestPasswordAlert(): Promise { + async requestPasswordAlert(validatePassword: (password: string) => Promise): Promise { return new Promise((resolve) => { - const showModal = () => this.openEnterE2EEPasswordModal((password) => resolve(password)); + const showModal = () => + this.openEnterE2EEPasswordModal(async (password) => { + await validatePassword(password); + resolve(); + }); const showAlert = () => { this.openAlert({ @@ -566,48 +577,47 @@ class E2E extends Emitter { }); } - async requestPasswordModal(): Promise { - return new Promise((resolve) => this.openEnterE2EEPasswordModal((password) => resolve(password))); + async requestPasswordModal(validatePassword: (password: string) => Promise): Promise { + return new Promise((resolve) => { + this.openEnterE2EEPasswordModal(async (password) => { + await validatePassword(password); + resolve(); + }); + }); } async decodePrivateKeyFlow() { - const password = await this.requestPasswordModal(); - if (!this.db_private_key) { return; } try { - const privateKey = await this.keychain.decryptKey(this.db_private_key, password); + let privateKey: string | undefined; + await this.requestPasswordModal(async (password) => { + privateKey = await this.keychain.decryptKey(this.db_private_key as string, password); + }); if (this.db_public_key && privateKey) { await this.loadKeys({ public_key: this.db_public_key, private_key: privateKey }); - this.setState('READY'); } else { await this.createAndLoadKeys(); - this.setState('READY'); } + this.setState('READY'); + dispatchToastMessage({ type: 'success', message: t('E2E_encryption_enabled') }); } catch (error) { this.setState('ENTER_PASSWORD'); - dispatchToastMessage({ type: 'error', message: t('Your_E2EE_password_is_incorrect') }); dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') }); - throw new Error('E2E -> Error decrypting private key', { cause: error }); + throw new Error('E2E -> Error loading keys', { cause: error }); } } async decodePrivateKey(privateKey: string): Promise { - // const span = log.span('decodePrivateKey'); - const password = await this.requestPasswordAlert(); - try { - const privKey = await this.keychain.decryptKey(privateKey, password); - return privKey; - } catch (error) { - this.setState('ENTER_PASSWORD'); - dispatchToastMessage({ type: 'error', message: t('Your_E2EE_password_is_incorrect') }); - dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') }); - throw new Error('E2E -> Error decrypting private key', { cause: error }); - } + let decryptedKey: string | undefined; + await this.requestPasswordAlert(async (password) => { + decryptedKey = await this.keychain.decryptKey(privateKey, password); + }); + return decryptedKey as string; } async decryptFileContent(file: IUploadWithUser): Promise { @@ -869,6 +879,6 @@ class E2E extends Emitter { export const e2e = new E2E(); -Accounts.onLogout(() => { +getDdpSdk().account.onLogout(() => { void e2e.stopClient(); }); diff --git a/apps/meteor/client/lib/loggedIn.ts b/apps/meteor/client/lib/loggedIn.ts index 859ff2db7a4de..30d87e69dc12b 100644 --- a/apps/meteor/client/lib/loggedIn.ts +++ b/apps/meteor/client/lib/loggedIn.ts @@ -1,5 +1,4 @@ -import { Accounts } from 'meteor/accounts-base'; - +import { getDdpSdk } from './sdk/ddpSdk'; import { getUserId } from './user'; const isLoggedIn = () => { @@ -13,8 +12,8 @@ export const whenLoggedIn = () => { } return new Promise((resolve) => { - const subscription = Accounts.onLogin(() => { - subscription.stop(); + const stop = getDdpSdk().account.onLogin(() => { + stop(); resolve(); }); }); @@ -30,11 +29,11 @@ export const onLoggedIn = (cb: (() => () => void) | (() => Promise<() => void>) } }; - const subscription = Accounts.onLogin(handler); + const stop = getDdpSdk().account.onLogin(handler); if (isLoggedIn()) handler(); return () => { - subscription.stop(); + stop(); cleanup?.(); }; }; diff --git a/apps/meteor/client/lib/meteorRuntimeConfig.ts b/apps/meteor/client/lib/meteorRuntimeConfig.ts new file mode 100644 index 0000000000000..cbf9070bfc823 --- /dev/null +++ b/apps/meteor/client/lib/meteorRuntimeConfig.ts @@ -0,0 +1,23 @@ +// Single point of access to the `__meteor_runtime_config__` globals that +// Meteor's bootloader injects into the page (ROOT_URL and +// ROOT_URL_PATH_PREFIX). Consumers must read/write through these helpers so +// the eventual replacement (a static config injected at build time) is a +// single-file swap. + +const getConfig = (): { ROOT_URL?: string; ROOT_URL_PATH_PREFIX?: string } | undefined => { + if (typeof __meteor_runtime_config__ !== 'object' || __meteor_runtime_config__ === null) { + return undefined; + } + return __meteor_runtime_config__; +}; + +export const getRootUrl = (): string | undefined => getConfig()?.ROOT_URL; + +export const getRootUrlPathPrefix = (): string => getConfig()?.ROOT_URL_PATH_PREFIX ?? ''; + +export const setRootUrl = (value: string): void => { + const config = getConfig(); + if (config) { + config.ROOT_URL = value; + } +}; diff --git a/apps/meteor/client/lib/openCASLoginPopup.ts b/apps/meteor/client/lib/openCASLoginPopup.ts index 836ec66b91202..1311df7a3c276 100644 --- a/apps/meteor/client/lib/openCASLoginPopup.ts +++ b/apps/meteor/client/lib/openCASLoginPopup.ts @@ -1,5 +1,5 @@ -import { Meteor } from 'meteor/meteor'; - +import { absoluteUrl } from './absoluteUrl'; +import { getRootUrlPathPrefix } from './meteorRuntimeConfig'; import { settings } from './settings'; const openCenteredPopup = (url: string, width: number, height: number) => { @@ -32,7 +32,7 @@ const getPopupUrl = (credentialToken: string): string => { throw new Error('CAS_login_url not set'); } - const appUrl = Meteor.absoluteUrl().replace(/\/$/, '') + __meteor_runtime_config__.ROOT_URL_PATH_PREFIX; + const appUrl = absoluteUrl().replace(/\/$/, '') + getRootUrlPathPrefix(); const serviceUrl = `${appUrl}/_cas/${credentialToken}`; const url = new URL(loginUrl); url.searchParams.set('service', serviceUrl); diff --git a/apps/meteor/client/lib/presence.spec.ts b/apps/meteor/client/lib/presence.spec.ts index 5e630bd4f03da..a0ff998ca4f84 100644 --- a/apps/meteor/client/lib/presence.spec.ts +++ b/apps/meteor/client/lib/presence.spec.ts @@ -6,6 +6,10 @@ jest.mock('meteor/meteor', () => ({ Meteor: { subscribe: jest.fn(), }, + DDPCommon: { + parseDDP: jest.fn((msg: string) => JSON.parse(msg)), + stringifyDDP: jest.fn((msg: unknown) => JSON.stringify(msg)), + }, })); const mockGet = jest.fn(); diff --git a/apps/meteor/client/lib/presence.ts b/apps/meteor/client/lib/presence.ts index 16a5282c56921..6b748c85d3541 100644 --- a/apps/meteor/client/lib/presence.ts +++ b/apps/meteor/client/lib/presence.ts @@ -4,8 +4,30 @@ import type { EventHandlerOf } from '@rocket.chat/emitter'; import { Emitter } from '@rocket.chat/emitter'; import { Meteor } from 'meteor/meteor'; +import { getDdpSdk } from './sdk/ddpSdk'; +import { isSdkTransportEnabled } from './sdk/sdkTransportEnabled'; import { sdk } from '../../app/utils/client/lib/SDKClient'; +const sdkTransportEnabled = isSdkTransportEnabled(); + +const subscribeUserPresence = (payload: { added?: string[]; removed?: string[] }): void => { + if (!sdkTransportEnabled) { + // Flag off: route directly through Meteor.subscribe — bit-for-bit develop + // behaviour, no DDPSDK socket created, no proxy in the call path. + Meteor.subscribe('stream-user-presence', '', payload); + return; + } + const ddp = getDdpSdk(); + if (ddp.connection.status === 'connected' && ddp.account.uid) { + // Fire the command-style subscription over our SDK; it has no lifecycle + // (the server registers the added/removed uids and moves on), matching + // Meteor.subscribe's behaviour here. + ddp.client.subscribe('stream-user-presence', '', payload); + return; + } + Meteor.subscribe('stream-user-presence', '', payload); +}; + type InternalEvents = { remove: IUser['_id']; reset: undefined; @@ -55,7 +77,7 @@ const getPresence = ((): ((uid: UserPresence['_id']) => void) => { const ids = Array.from(currentUids); const removed = Array.from(deletedUids); - Meteor.subscribe('stream-user-presence', '', { + subscribeUserPresence({ ...(ids.length > 0 && { added: Array.from(currentUids) }), ...(removed.length && { removed: Array.from(deletedUids) }), }); diff --git a/apps/meteor/client/lib/queryKeys.ts b/apps/meteor/client/lib/queryKeys.ts index 43dc9af71a5f4..c703743e567d6 100644 --- a/apps/meteor/client/lib/queryKeys.ts +++ b/apps/meteor/client/lib/queryKeys.ts @@ -36,6 +36,8 @@ export const roomsQueryKeys = { images: (rid: IRoom['_id'], options?: { startingFromId?: string }) => [...roomsQueryKeys.room(rid), 'images', options] as const, autocomplete: (text: string) => [...roomsQueryKeys.all, 'autocomplete', text] as const, discussions: (rid: IRoom['_id'], ...args: [filter: { text?: string }]) => [...roomsQueryKeys.room(rid), 'discussions', ...args] as const, + threadMessages: (rid: IRoom['_id'], tmid: IMessage['_id']) => [...roomsQueryKeys.room(rid), 'threads', tmid, 'messages'] as const, + threadMainMessage: (rid: IRoom['_id'], tmid: IMessage['_id']) => [...roomsQueryKeys.room(rid), 'threads', tmid, 'main-message'] as const, }; export const subscriptionsQueryKeys = { diff --git a/apps/meteor/client/lib/rooms/roomCoordinator.tsx b/apps/meteor/client/lib/rooms/roomCoordinator.tsx index 54b0cf2145f5a..63830a296630e 100644 --- a/apps/meteor/client/lib/rooms/roomCoordinator.tsx +++ b/apps/meteor/client/lib/rooms/roomCoordinator.tsx @@ -1,6 +1,5 @@ import type { IRoom, RoomType, IUser, AtLeast, ValueOf, ISubscription } from '@rocket.chat/core-typings'; import type { RouteName } from '@rocket.chat/ui-contexts'; -import { Meteor } from 'meteor/meteor'; import { hasPermission } from '../../../app/authorization/client'; import type { @@ -17,6 +16,7 @@ import { router } from '../../providers/RouterProvider'; import { Subscriptions } from '../../stores'; import RoomRoute from '../../views/room/RoomRoute'; import MainLayout from '../../views/root/MainLayout'; +import { absoluteUrl } from '../absoluteUrl'; import { appLayout } from '../appLayout'; class RoomCoordinatorClient extends RoomCoordinator { @@ -203,7 +203,7 @@ class RoomCoordinatorClient extends RoomCoordinator { return false; } - return Meteor.absoluteUrl( + return absoluteUrl( router.buildRoutePath({ name: config.route.name, params: routeData, diff --git a/apps/meteor/client/lib/rooms/roomTypes/direct.ts b/apps/meteor/client/lib/rooms/roomTypes/direct.ts index c569ba47a055a..e3288968cb688 100644 --- a/apps/meteor/client/lib/rooms/roomTypes/direct.ts +++ b/apps/meteor/client/lib/rooms/roomTypes/direct.ts @@ -35,7 +35,7 @@ roomCoordinator.add( case RoomSettingsEnum.JOIN_CODE: return false; case RoomSettingsEnum.E2E: - return settings.watch('E2E_Enable') === true; + return settings.peek('E2E_Enable') === true; default: return true; } @@ -73,7 +73,7 @@ roomCoordinator.add( return; } - if (settings.watch('UI_Use_Real_Name') && subscription.fname) { + if (settings.peek('UI_Use_Real_Name') && subscription.fname) { return subscription.fname; } diff --git a/apps/meteor/client/lib/rooms/roomTypes/private.ts b/apps/meteor/client/lib/rooms/roomTypes/private.ts index 31b8fa7ca7f7b..8af650fd671b6 100644 --- a/apps/meteor/client/lib/rooms/roomTypes/private.ts +++ b/apps/meteor/client/lib/rooms/roomTypes/private.ts @@ -32,7 +32,7 @@ roomCoordinator.add( case RoomSettingsEnum.REACT_WHEN_READ_ONLY: return Boolean(!room.broadcast && room.ro); case RoomSettingsEnum.E2E: - return settings.watch('E2E_Enable') === true; + return settings.peek('E2E_Enable') === true; case RoomSettingsEnum.SYSTEM_MESSAGES: default: return true; @@ -55,7 +55,7 @@ roomCoordinator.add( if (roomData.prid || isRoomFederated(roomData)) { return roomData.fname; } - if (settings.watch('UI_Allow_room_names_with_special_chars')) { + if (settings.peek('UI_Allow_room_names_with_special_chars')) { return roomData.fname || roomData.name; } diff --git a/apps/meteor/client/lib/rooms/roomTypes/public.ts b/apps/meteor/client/lib/rooms/roomTypes/public.ts index 00a3006381356..87ebf636c5a10 100644 --- a/apps/meteor/client/lib/rooms/roomTypes/public.ts +++ b/apps/meteor/client/lib/rooms/roomTypes/public.ts @@ -53,7 +53,7 @@ roomCoordinator.add( if (roomData.prid || isRoomFederated(roomData)) { return roomData.fname; } - if (settings.watch('UI_Allow_room_names_with_special_chars')) { + if (settings.peek('UI_Allow_room_names_with_special_chars')) { return roomData.fname || roomData.name; } return roomData.name; diff --git a/apps/meteor/client/lib/sdk/ddpProtocol.ts b/apps/meteor/client/lib/sdk/ddpProtocol.ts new file mode 100644 index 0000000000000..4a9011e619cf8 --- /dev/null +++ b/apps/meteor/client/lib/sdk/ddpProtocol.ts @@ -0,0 +1,10 @@ +// Single point of access to the DDP wire codec. Today it forwards to Meteor's +// `ddp-common` package; the eventual replacement will be a standalone EJSON +// helper. Consumers MUST import parseDDP / stringifyDDP from here so the codec +// stays swappable. + +import { DDPCommon } from 'meteor/ddp-common'; + +export const { parseDDP, stringifyDDP } = DDPCommon; + +export type DDPMessage = Parameters[0]; diff --git a/apps/meteor/client/lib/sdk/ddpSdk.ts b/apps/meteor/client/lib/sdk/ddpSdk.ts new file mode 100644 index 0000000000000..b8e5d3d17914e --- /dev/null +++ b/apps/meteor/client/lib/sdk/ddpSdk.ts @@ -0,0 +1,332 @@ +import { DDPSDK } from '@rocket.chat/ddp-client'; +import EJSON from 'ejson'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +import { createMeteorBackedSdk, createMeteorBackedStorage } from './meteorBackedSdk'; +import { isSdkTransportEnabled } from './sdkTransportEnabled'; +import { getRootUrl } from '../meteorRuntimeConfig'; +import { STORAGE_KEYS, getStoredItem, removeStoredItem } from './storage'; +import { userIdStore } from '../user'; + +const sdkTransportEnabled = isSdkTransportEnabled(); + +const stripTrailingSlash = (value: string): string => (value.endsWith('/') ? value.slice(0, -1) : value); + +const computeDdpUrl = (): string => { + const rootUrl = getRootUrl(); + const source = rootUrl && rootUrl !== '/' ? rootUrl : window.location.origin; + return stripTrailingSlash(source.replace(/^http/, 'ws')); +}; + +let instance: DDPSDK | undefined; +let connectPromise: Promise | undefined; + +const applyEjsonEncoding = (sdk: DDPSDK): void => { + const { ddp } = sdk.client as unknown as { ddp: { encode: unknown; decode: unknown } }; + if (!ddp) return; + ddp.encode = EJSON.stringify; + ddp.decode = EJSON.parse; +}; + +const startConnect = (sdk: DDPSDK): Promise => { + if (connectPromise) return connectPromise; + connectPromise = sdk.connection.connect().catch((err) => { + console.warn('[ddpSdk] connect failed', err); + // Allow a retry on the next call. + connectPromise = undefined; + }); + return connectPromise; +}; + +const waitForConnected = (sdk: DDPSDK): Promise => { + if (sdk.connection.status === 'connected') return Promise.resolve(); + return new Promise((resolve) => { + const stop = sdk.connection.on('connected', () => { + stop(); + resolve(); + }); + }); +}; + +export const getDdpSdk = (): DDPSDK => { + if (!instance) { + if (sdkTransportEnabled) { + instance = DDPSDK.create(computeDdpUrl()); + // TODO: This is a temporary fix to ensure Accounts/Meteor and Update Session On Window Close work together. + try { + instance.storage = createMeteorBackedStorage(); + } catch (error) { + // DDPSDK.create may return a sealed/frozen instance under strict mode; failing + // to attach the storage hook must not abort SDK bootstrap. + console.warn('[ddpSdk] failed to attach storage hook to SDK instance', error); + } + applyEjsonEncoding(instance); + void startConnect(instance); + } else { + // Meteor-backed pass-through. Same DDPSDK shape, but every call + // delegates to Meteor.connection / Meteor.callAsync / Meteor.userId + // — no second WebSocket opened, no auth lifecycle, no Presence + // session duplicated server-side. Lets ServerProvider, presence.ts, + // SDKClient, etc. continue calling getDdpSdk() unconditionally + // without per-call-site flag gates. + instance = createMeteorBackedSdk(); + } + } + return instance; +}; + +const readStoredLoginToken = (): string | null => getStoredItem(STORAGE_KEYS.LOGIN_TOKEN); + +let inflightLogin: Promise | undefined; + +export const ensureConnectedAndAuthenticated = async (): Promise => { + const sdk = getDdpSdk(); + + // IMPORTANT: must wait for the DDP `connected` handshake before issuing + // any wait-method (login uses wait:true). DDPDispatcher serializes wait + // blocks at the queue head, so a login dispatched while connecting + // queues ahead of the connect frame ws.onopen later emits — the connect + // frame ends up wedged in a non-wait block behind the wait block and + // never flushes, leaving the socket open but DDP-unhandshaked. + if ( + sdk.connection.status === 'idle' || + sdk.connection.status === 'closed' || + sdk.connection.status === 'disconnected' || + sdk.connection.status === 'failed' + ) { + void startConnect(sdk); + } + await waitForConnected(sdk); + + const token = readStoredLoginToken(); + if (!token || sdk.account.uid) { + return; + } + + if (inflightLogin) { + await inflightLogin; + return; + } + + // Give Meteor's own login flow (resume routed through stubMeteorStream + // + adoptAccountFromMeteorLoginResult) time to populate sdk.account + // before we issue our own loginWithToken. If adopt fires first, we can + // short-circuit and avoid sending a second login frame on the SDK + // socket — which would otherwise create a duplicate Presence + // connection (processConnectionStatus prefers ONLINE over AWAY in the + // aggregate, breaking the auto-away flow). 500ms covers a single + // server roundtrip in CI; if the stub-routed login hasn't completed by + // then, fall back to issuing our own loginWithToken below. + for (let i = 0; i < 20 && !sdk.account.uid; i++) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + if (sdk.account.uid) { + return; + } + + inflightLogin = (async () => { + try { + await sdk.account.loginWithToken(token); + } finally { + inflightLogin = undefined; + } + })(); + + try { + await inflightLogin; + } catch (error) { + if (isAuthError(error) && readStoredLoginToken() === token) { + // Server rejected the stored token. Without this branch the stored + // token stays in localStorage forever and the router keeps the user + // wedged on /home with no main UI and no login form: ddpOverREST + // routes Meteor's resume login through DDPSDK / REST (not Meteor's + // own WS), and on rejection the resume invoker errors but the + // account state isn't cleared automatically. The token-stable + // guard (readStoredLoginToken() === token) avoids kicking the user + // out when localStorage was updated mid-flight by a parallel flow + // (fresh registration, Meteor's own resume) — the 401 is then on a + // stale token a newer credential already replaced. Drop the local + // credentials manually instead of calling Meteor.logout(): the + // latter dispatches a `logout` method which itself races against + // parallel re-auth flows in CI's parallel-shard environment and + // kicked otherwise-healthy tests out. + clearStoredCredentials(); + return; + } + console.warn('[ddpSdk] loginWithToken failed', error); + } +}; + +/** + * Drop the local session credentials without dispatching Meteor's `logout` + * method. Nulling the connection userId propagates through the + * Accounts.connection.userId() Tracker.autorun (see overrides/userAndUsers.ts) + * into the userIdStore, so `useUserId()` becomes undefined and the router falls + * through to LoginPage. We avoid `Meteor.logout()` on purpose: it dispatches a + * `logout` method that races parallel re-auth flows (fresh registration, + * Meteor's own resume) and has kicked otherwise-healthy sessions/tests out. + */ +export const clearStoredCredentials = (): void => { + removeStoredItem(STORAGE_KEYS.USER_ID); + removeStoredItem(STORAGE_KEYS.LOGIN_TOKEN); + removeStoredItem(STORAGE_KEYS.LOGIN_TOKEN_EXPIRES); + Meteor.connection.setUserId(null); +}; + +export const isAuthError = (error: unknown): boolean => { + if (!error || typeof error !== 'object') return false; + const e = error as { error?: unknown; reason?: unknown; status?: unknown; statusCode?: unknown }; + return ( + e.error === 401 || + e.error === 403 || + // REST-shaped failures (e.g. sdk.rest.get('/v1/me'), userData stream + // `nosub`) surface the HTTP status instead of a DDP `error` code. + e.status === 401 || + e.status === 403 || + e.statusCode === 401 || + e.statusCode === 403 || + e.reason === 'User not found' || + e.reason === 'Login token expired' || + e.reason === 'You are not allowed to use this token' + ); +}; + +/** + * When Meteor.applyAsync('login', ...) is routed through ddpOverREST it lands on + * DDPSDK as `client.callAsync('login', ...)`. The result authenticates the + * underlying DDP socket — server-side the session is now logged in — but + * `sdk.account` is bypassed entirely (only `sdk.account.loginWithToken` populates + * `account.uid` / `account.user`). Without this sync, our userIdStore subscriber + * sees uid set, calls ensureConnectedAndAuthenticated, finds `account.uid` empty, + * and fires a SECOND login on the same socket. The server happily honours both, + * issuing two different login tokens; whichever arrives second wins on the + * server but on the client we end up with `account.user.token !== Meteor.loginToken`, + * which surfaces later as auth-mismatched subscription errors and React crashes + * mid-flow. + * + * Call this from ddpOverREST after a successful 'login' method result so DDPSDK's + * `account` reflects the same credentials Meteor stored, and ensureConnectedAndAuthenticated + * short-circuits its own loginWithToken path. + */ +export const adoptAccountFromMeteorLoginResult = (result: unknown): void => { + if (!result || typeof result !== 'object') return; + const r = result as { id?: unknown; token?: unknown; tokenExpires?: unknown }; + if (typeof r.id !== 'string' || typeof r.token !== 'string') return; + const tokenExpiresRaw = r.tokenExpires; + let tokenExpires: Date | undefined; + if (tokenExpiresRaw instanceof Date) { + tokenExpires = tokenExpiresRaw; + } else if (typeof tokenExpiresRaw === 'object' && tokenExpiresRaw !== null && '$date' in tokenExpiresRaw) { + const d = (tokenExpiresRaw as { $date: number | string }).$date; + tokenExpires = new Date(typeof d === 'string' ? parseInt(d, 10) : d); + } + const sdk = getDdpSdk(); + sdk.account.user = { ...sdk.account.user, token: r.token, tokenExpires, id: r.id } as typeof sdk.account.user; + sdk.account.uid = r.id; +}; + +const teardownAuthenticatedConnection = (): void => { + if (!instance) return; + try { + instance.connection.close(); + } catch { + // ignore + } + instance.account.uid = undefined; + instance.account.user = undefined; + connectPromise = undefined; +}; + +declare global { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface Window { + __rocketChatSdk?: DDPSDK; + } +} + +if (typeof window !== 'undefined' && isSdkTransportEnabled()) { + console.info( + '%c[Rocket.Chat] SDK-over-DDP transport enabled (experimental)', + 'color:#fff;background:#f5455c;padding:2px 6px;border-radius:3px;font-weight:bold', + ); + const sdk = getDdpSdk(); + window.__rocketChatSdk = sdk; + + // DDPSDK auto-fires loginWithToken on every `connected` event using the + // in-memory account.user.token (DDPSDK.create line 115-122). When the + // server force-logs the user out (resetUserE2EKey → + // Users.unsetLoginTokens → meteor.service force_logout listener closes + // the user's WebSocket sessions), the SDK reconnects and immediately + // retries the now-dead token. DDPSDK calls this with `void` so the + // rejection is swallowed; account.user stays populated, Meteor.userId() + // stays set, and the navbar continues to render Home with stale creds. + // + // Wrap account.loginWithToken so we can observe rejections from the + // auto-retry. To avoid breaking the SAML/password login flows where a + // fresh login is concurrently in flight, only act when: + // - the error is auth-shaped (`isAuthError`) AND + // - the token in localStorage still matches the one we tried with + // (nothing rotated it mid-flight) AND + // - the SDK account didn't get refreshed by a successful adopt while + // we were awaiting (sdk.account.uid still maps to this token's user) + // Wrap account.loginWithToken so the SDK's auto-relogin rejection (called + // with `void` in DDPSDK.create) doesn't surface as an unhandled rejection + // (window.onunhandledrejection → pageError). The actual recovery from a + // failed auto-relogin is now driven by Meteor's `DDP.onReconnect` + // callback (registered by `callLoginMethod`), which fires after + // stubMeteorStream re-emits `reset` on each SDK 'connected' event. That + // callback retries login with the latest stored token and calls + // `makeClientLoggedOut` on failure — no need to duplicate that logic. + const account = sdk.account as unknown as { loginWithToken: (token: string) => Promise }; + const originalLogin = account.loginWithToken.bind(sdk.account); + account.loginWithToken = async (token: string) => { + try { + return await originalLogin(token); + } catch (error) { + if (isAuthError(error)) { + // Meteor's onReconnect path will retry through stubMeteorStream + // with the current localStorage token; nothing for us to do here + // beyond not letting the rejection escape. + return undefined; + } + throw error; + } + }; + + // Boot-time auth is now driven by Meteor's login resume routed through + // stubMeteorStream, which calls adoptAccountFromMeteorLoginResult on + // success. Calling ensureConnectedAndAuthenticated here as well would + // fire a *second* loginWithToken on the SDK socket before the Meteor + // resume completes — server-side that ends up as TWO Accounts.onLogin + // fires → TWO Presence.newConnection inserts in usersSessions, with + // duplicate entries that confuse processConnectionStatus (one stays + // online while the other goes away, aggregating to online — auto-away + // never propagates). + + userIdStore.subscribe((uid) => { + if (uid) { + // Subsequent userId transitions (logout → login) still need to + // re-establish auth on the SDK socket; adopt only kicks in for + // login frames going through the stub, not for the post-logout + // re-auth that doesn't necessarily go through Meteor. + void ensureConnectedAndAuthenticated(); + } else { + teardownAuthenticatedConnection(); + } + }); + + // Bridge Meteor's URL-routing-based account events into the SDK so + // sdk.account.onEmailVerificationLink / onPageLoadLogin fire in flag-ON + // mode too. The SDK has no native source for these — they come from + // Meteor's hash-route parser (verification link) and Meteor's first-login + // resolution (page load login). Register one bridge per event; AccountImpl's + // emitter fans out to whatever consumers attached via onEmailVerificationLink + // / onPageLoadLogin. + Accounts.onEmailVerificationLink((token: string) => { + sdk.account.emit('emailVerificationLink', token); + }); + Accounts.onPageLoadLogin((loginAttempt: unknown) => { + sdk.account.emit('pageLoadLogin', loginAttempt); + }); +} diff --git a/apps/meteor/client/lib/sdk/meteorBackedSdk.ts b/apps/meteor/client/lib/sdk/meteorBackedSdk.ts new file mode 100644 index 0000000000000..95585963d5353 --- /dev/null +++ b/apps/meteor/client/lib/sdk/meteorBackedSdk.ts @@ -0,0 +1,342 @@ +import type { DDPSDK } from '@rocket.chat/ddp-client'; +import { Emitter } from '@rocket.chat/emitter'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; + +import { parseDDP } from './ddpProtocol'; +import { setStorageBackend } from './storage'; + +/** + * Meteor-backed pass-through DDPSDK used when the SDK transport is OFF. + * + * Returned by `getDdpSdk()` when `isSdkTransportEnabled()` is false. Satisfies + * the subset of the `DDPSDK` interface that the codebase actually consumes — + * delegating each call to `Meteor.connection`/`Meteor.callAsync`/`Meteor.userId` + * etc. — so consumers don't need their own `if (isSdkTransportEnabled())` + * branches. All operations are no-ops or fall back to Meteor; no second + * WebSocket is opened, no auth lifecycle is run, no Presence session is + * duplicated server-side. + */ +const noopUnsubscribe = (): void => undefined; + +const safeMeteorStatus = (): { status: string; connected: boolean; retryCount?: number; retryTime?: number } | undefined => { + if (typeof Meteor.status !== 'function') return undefined; + try { + return Meteor.status(); + } catch { + return undefined; + } +}; + +const onMeteorStatusChange = (cb: () => void): (() => void) => { + // Drive notifications off Meteor.status's Tracker reactivity. Listening to + // `_stream` events directly misses the `connecting`↔`waiting` transitions + // and the per-second `retryTime` ticks that Meteor mutates internally + // without restarting the socket, leaving ConnectionStatusBar stuck on a + // stale status while reconnection retries run. + if (typeof Meteor.status !== 'function' || typeof Tracker?.autorun !== 'function') { + // Test / SSR environment with a stubbed Meteor — nothing to subscribe to. + return noopUnsubscribe; + } + let firstRun = true; + const computation = Tracker.autorun(() => { + try { + Meteor.status(); + } catch { + return; + } + if (firstRun) { + firstRun = false; + return; + } + cb(); + }); + return () => computation.stop(); +}; + +const meteorStatusToSdkStatus = (): string => { + const s = safeMeteorStatus(); + if (!s) return 'disconnected'; + if (s.connected) return 'connected'; + switch (s.status) { + case 'connecting': + return 'connecting'; + case 'failed': + return 'failed'; + case 'waiting': + return 'disconnected'; + case 'offline': + return 'idle'; + default: + return 'disconnected'; + } +}; + +const createMeteorBackedClient = () => { + const subscribe = (name: string, ...args: Parameters) => { + const sub = Meteor.connection.subscribe(name, ...args); + // Approximate DDPSDK's Subscription shape with Meteor's handle. The + // codebase only reads `stop`/`ready`/`isReady`/`id` from it. + return Object.assign(sub, { + id: '', + isReady: false, + ready: () => Promise.resolve(), + onChange: () => undefined, + }); + }; + + const callAsync = (method: string, ...args: unknown[]): Promise & { id: string } => { + const promise = Meteor.callAsync(method, ...args) as Promise & { id: string }; + // `id` is required on DDPSDK's CallAsync return type; Meteor doesn't + // surface it, but no caller in this codebase reads it from a Meteor- + // routed call. + Object.defineProperty(promise, 'id', { value: '', enumerable: false }); + return promise; + }; + + const onCollection = (id: string, callback: (data: unknown) => void): (() => void) => { + const handler = (rawMsg: string): void => { + let msg: unknown; + try { + msg = parseDDP(rawMsg); + } catch { + return; + } + if (typeof msg !== 'object' || msg === null) return; + if ((msg as { collection?: unknown }).collection !== id) return; + callback(msg); + }; + const stream = Meteor.connection._stream!; + stream.on('message', handler); + // Meteor's stream `on` doesn't expose an off; the listener is harmless + // and lives for the page lifetime. Caller's stop is a no-op. + return noopUnsubscribe; + }; + + // `ddp.onMessage` is reached by code that wants raw DDP frames off the SDK + // socket. In Meteor-backed mode there is no separate SDK socket; the same + // frames are already delivered through `onCollection` above (which listens + // on `Meteor.connection._stream`). Registering a second listener here + // would double-deliver. The Meteor-backed `onCollection` is also the + // temporary bridge used by `sdk.onAnyStreamEvent` until SDK transport + // rollout completes and the Meteor fallback can be removed. + const ddp = { + onMessage: (_cb: (payload: unknown) => void): (() => void) => noopUnsubscribe, + }; + + return { + subscribe, + callAsync, + call: (method: string, ...params: unknown[]) => { + void callAsync(method, ...params); + return ''; + }, + callWithOptions: (method: string, _opts: unknown, ...params: unknown[]) => { + void callAsync(method, ...params); + return ''; + }, + callAsyncWithOptions: (method: string, _opts: unknown, ...params: unknown[]) => callAsync(method, ...params), + unsubscribe: () => Promise.resolve(), + connect: () => Promise.resolve(), + onCollection, + subscriptions: new Map(), + ddp, + // Emitter shape Account inherits from + on: () => () => undefined, + off: () => undefined, + once: () => () => undefined, + emit: () => undefined, + events: () => [], + has: () => false, + } as unknown as DDPSDK['client']; +}; + +const createMeteorBackedConnection = () => { + const emitter = new Emitter<{ connected: void; disconnected: void; connection: void }>(); + let bridgeStarted = false; + let stopBridge: (() => void) | undefined; + const startBridge = () => { + if (bridgeStarted) return; + bridgeStarted = true; + let lastConnected = safeMeteorStatus()?.connected ?? false; + stopBridge = onMeteorStatusChange(() => { + const nowConnected = safeMeteorStatus()?.connected ?? false; + if (nowConnected !== lastConnected) { + lastConnected = nowConnected; + emitter.emit(nowConnected ? 'connected' : 'disconnected'); + } + emitter.emit('connection'); + }); + }; + + return { + get status(): string { + return meteorStatusToSdkStatus(); + }, + on: (event: 'connected' | 'disconnected' | 'connection', cb: () => void): (() => void) => { + // Lazy-start the Meteor.status() autorun on the first subscriber so + // modules importing `getDdpSdk` in non-browser test envs don't crash + // on the absent Meteor.status reactive var. + startBridge(); + emitter.on(event, cb); + return () => emitter.off(event, cb); + }, + off: () => undefined, + connect: () => Promise.resolve(), + close: () => { + if (typeof Meteor.disconnect === 'function') Meteor.disconnect(); + stopBridge?.(); + }, + } as unknown as DDPSDK['connection']; +}; + +const createMeteorBackedAccount = () => { + return { + get uid(): string | undefined { + if (typeof Meteor.userId !== 'function') return undefined; + try { + return Meteor.userId() ?? undefined; + } catch { + return undefined; + } + }, + set uid(_v: string | undefined) { + // no-op — Meteor owns the userId + }, + get user() { + if (typeof Meteor.user !== 'function') return undefined; + let u; + try { + u = Meteor.user(); + } catch { + return undefined; + } + if (!u) return undefined; + return u as unknown as { id: string; username?: string; token?: string; tokenExpires?: Date }; + }, + set user(_v) { + // no-op — Meteor owns the user + }, + loginWithPassword: (username: string, password: string) => + new Promise((resolve, reject) => + (Meteor as unknown as { loginWithPassword: (u: string, p: string, cb: (err?: unknown) => void) => void }).loginWithPassword( + username, + password, + (err) => (err ? reject(err) : resolve()), + ), + ), + loginWithToken: (token: string) => + new Promise<{ id: string; token: string; tokenExpires: Date }>((resolve, reject) => + (Meteor as unknown as { loginWithToken: (t: string, cb: (err?: unknown, res?: unknown) => void) => void }).loginWithToken( + token, + (err, res) => (err ? reject(err) : resolve(res as { id: string; token: string; tokenExpires: Date })), + ), + ), + logout: () => + new Promise((resolve, reject) => + (Meteor as unknown as { logout: (cb: (err?: unknown) => void) => void }).logout((err) => (err ? reject(err) : resolve())), + ), + // Lifecycle handlers delegate to Meteor's accounts-base. The unsubscribe + // shape (returning a () => void) matches the SDK contract; Meteor's + // onLogin/onLogout hand back a `{ stop }` handle, while + // onEmailVerificationLink/onPageLoadLogin don't expose an unsubscribe at + // all — call sites for those are singletons registered at module load, + // so a no-op unsubscribe is acceptable. + onLogin: (fn: () => void): (() => void) => { + const handle = Accounts.onLogin(fn); + return () => handle.stop(); + }, + onLogout: (fn: () => void): (() => void) => { + // @types/meteor declares onLogout's return as void, but at runtime it + // returns the same `{ stop }` handle as onLogin (Meteor source: + // packages/accounts-base/accounts_common.js). + const handle = (Accounts.onLogout as unknown as (fn: () => void) => { stop: () => void })(fn); + return () => handle.stop(); + }, + onEmailVerificationLink: (fn: (token: string) => void): (() => void) => { + Accounts.onEmailVerificationLink(fn); + return () => undefined; + }, + onPageLoadLogin: (fn: (loginAttempt: unknown) => void): (() => void) => { + Accounts.onPageLoadLogin(fn); + return () => undefined; + }, + // Emitter shape Account inherits from + on: () => () => undefined, + off: () => undefined, + once: () => () => undefined, + emit: () => undefined, + events: () => [], + has: () => false, + } as unknown as DDPSDK['account']; +}; + +export const createMeteorBackedStorage = () => { + let appliedBackend: 'local' | 'session' | undefined; + + return { + changeStorageBackend: () => { + const backend: 'local' | 'session' = window[FORGET_SESSION_SETTING_ID] ? 'session' : 'local'; + + if (appliedBackend === backend) { + return; + } + + if (!setStorageBackend(backend)) { + return; + } + + (Meteor._localStorage as unknown as Storage) = backend === 'session' ? window.sessionStorage : window.localStorage; + + try { + Accounts.config({ clientStorage: backend }); + } catch (error) { + // Accounts.config throws when invoked twice with a conflicting value. Meteor's + // own boot may have already set clientStorage; the _localStorage reassign above + // is what actually switches the backend Meteor reads from, so swallow. + console.warn('[storage] Accounts.config(clientStorage) refused at runtime', error); + } + + appliedBackend = backend; + }, + }; +}; + +export const FORGET_SESSION_SETTING_ID = 'Accounts_ForgetUserSessionOnWindowClose'; + +declare global { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface Window { + [FORGET_SESSION_SETTING_ID]?: boolean; + } +} + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface DDPSDK { + storage: { + changeStorageBackend: () => void; + }; + } +} + +export const createMeteorBackedSdk = (): DDPSDK => { + const connection = createMeteorBackedConnection(); + const client = createMeteorBackedClient(); + const account = createMeteorBackedAccount(); + const storage = createMeteorBackedStorage(); + + return { + storage, + connection, + client, + account, + timeoutControl: undefined, + rest: undefined, + call: (method: string, ...params: unknown[]) => client.callAsync(method, ...params), + stream: () => { + throw new Error('SDK transport is disabled — use sdk.publish/sdk.stream from SDKClient.ts instead'); + }, + } as unknown as DDPSDK; +}; diff --git a/apps/meteor/client/lib/sdk/sdkTransportEnabled.ts b/apps/meteor/client/lib/sdk/sdkTransportEnabled.ts new file mode 100644 index 0000000000000..1a9e60a61d4eb --- /dev/null +++ b/apps/meteor/client/lib/sdk/sdkTransportEnabled.ts @@ -0,0 +1,28 @@ +const KEY = 'sdk_transport'; +const META_NAME = 'rc-sdk-transport-enabled'; + +/** + * Runtime flag that gates the SDK-over-DDP transport migration. Three sources, + * checked in order: + * 1. URL parameter `?sdk_transport=on|off` — per-tab override (highest). + * 2. `rc-config-sdk_transport` in localStorage — persisted per-user opt-in. + * 3. `` injected by + * the server from the `SDK_DDP_Transport_Enabled` admin setting — global + * opt-in / kill-switch. + * + * Default is `false` (legacy Meteor DDP transport) when none of the three + * resolves, so the migration ships dormant for staged rollout. + */ +export const isSdkTransportEnabled = (): boolean => { + if (typeof window === 'undefined') return false; + try { + const fromUrl = new URLSearchParams(window.location.search).get(KEY); + if (fromUrl === 'on') return true; + if (fromUrl === 'off') return false; + if (window.localStorage.getItem(`rc-config-${KEY}`) === 'on') return true; + const meta = window.document?.querySelector(`meta[name="${META_NAME}"]`); + return meta?.getAttribute('content') === 'on'; + } catch { + return false; + } +}; diff --git a/apps/meteor/client/lib/sdk/storage.ts b/apps/meteor/client/lib/sdk/storage.ts new file mode 100644 index 0000000000000..b2aa37dd3d5bf --- /dev/null +++ b/apps/meteor/client/lib/sdk/storage.ts @@ -0,0 +1,96 @@ +// Single point of access to the client-side persistent storage that +// Rocket.Chat shares with Meteor's accounts-base. Reads and writes use +// window.localStorage under the hood; the keys mirror the names Meteor +// originally wrote so sessions persist across the Meteor → SDK migration. + +export const STORAGE_KEYS = { + USER_ID: 'Meteor.userId', + LOGIN_TOKEN: 'Meteor.loginToken', + LOGIN_TOKEN_EXPIRES: 'Meteor.loginTokenExpires', + E2EE_PUBLIC_KEY: 'public_key', + E2EE_PRIVATE_KEY: 'private_key', + E2EE_RANDOM_PASSWORD: 'e2e.randomPassword', +} as const; + +export type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS]; + +type StorageBackend = 'local' | 'session'; + +const getStorageForBackend = (backend: StorageBackend): Storage | undefined => { + if (typeof window === 'undefined') { + return undefined; + } + + try { + return backend === 'session' ? window.sessionStorage : window.localStorage; + } catch { + return undefined; + } +}; + +const getStorage = (): Storage | undefined => { + return getStorageForBackend(storageBackend); +}; + +export const getStoredItem = (key: StorageKey): string | null => getStorage()?.getItem(key) ?? null; + +export const setStoredItem = (key: StorageKey, value: string): void => getStorage()?.setItem(key, value); + +export const removeStoredItem = (key: StorageKey): void => getStorage()?.removeItem(key); + +let storageBackend: StorageBackend = 'local'; + +export const setStorageBackend = (backend: StorageBackend): boolean => { + if (backend === storageBackend) { + return true; + } + + if (!moveLoginKeys(backend)) { + return false; + } + + storageBackend = backend; + return true; +}; + +const moveLoginKeys = (backend: StorageBackend): boolean => { + const keys = [ + STORAGE_KEYS.USER_ID, + STORAGE_KEYS.LOGIN_TOKEN, + STORAGE_KEYS.LOGIN_TOKEN_EXPIRES, + STORAGE_KEYS.E2EE_PUBLIC_KEY, + STORAGE_KEYS.E2EE_PRIVATE_KEY, + STORAGE_KEYS.E2EE_RANDOM_PASSWORD, + ]; + + const sourceStorage = getStorageForBackend(backend === 'session' ? 'local' : 'session'); + const targetStorage = getStorageForBackend(backend); + + if (!sourceStorage || !targetStorage) { + console.warn('Unable to switch storage backend because source or target storage is unavailable'); + return false; + } + + for (const key of keys) { + let value: string | null; + try { + value = sourceStorage.getItem(key); + } catch { + continue; + } + + if (value === null) { + continue; + } + + try { + targetStorage.setItem(key, value); + sourceStorage.removeItem(key); + } catch { + continue; + } + } + sourceStorage.clear(); + + return true; +}; diff --git a/apps/meteor/client/lib/settings/settings.ts b/apps/meteor/client/lib/settings/settings.ts index f0026eab41ec2..13363b39279ce 100644 --- a/apps/meteor/client/lib/settings/settings.ts +++ b/apps/meteor/client/lib/settings/settings.ts @@ -1,6 +1,5 @@ import type { SettingValue } from '@rocket.chat/core-typings'; -import { watch } from '../../meteor/watch'; import { PublicSettings } from '../../stores'; type SettingCallback = (key: string, value: SettingValue) => void; @@ -8,11 +7,6 @@ type SettingCallback = (key: string, value: SettingValue) => void; class Settings { private readonly store = PublicSettings.use; - /** Get a setting value Tracker-reactively */ - watch(_id: string): TValue | undefined { - return watch(this.store, (state) => state.get(_id)?.value) as TValue | undefined; - } - /** Get a setting value non-reactively */ peek(_id: string): TValue | undefined { return this.store.getState().get(_id)?.value as TValue | undefined; diff --git a/apps/meteor/client/lib/streamer/ddp.ts b/apps/meteor/client/lib/streamer/ddp.ts deleted file mode 100644 index 1cacd61523c1c..0000000000000 --- a/apps/meteor/client/lib/streamer/ddp.ts +++ /dev/null @@ -1,172 +0,0 @@ -import * as z from 'zod/mini'; - -/** - * DDP Message Specification - * https://github.com/meteor/meteor/blob/devel/packages/ddp/DDP.md - */ - -// Establishing connection -const ConnectSchema = z.strictObject({ - msg: z.literal('connect'), - session: z.optional(z.string()), - version: z.string(), - support: z.array(z.string()), -}); - -const ConnectedSchema = z.strictObject({ - msg: z.literal('connected'), - session: z.string(), -}); - -const FailedSchema = z.strictObject({ - msg: z.literal('failed'), - version: z.string(), -}); - -// Heartbeats -const PingSchema = z.strictObject({ - msg: z.literal('ping'), - id: z.optional(z.string()), -}); - -const PongSchema = z.strictObject({ - msg: z.literal('pong'), - id: z.optional(z.string()), -}); - -// Managing data -const AddedSchema = z.strictObject({ - msg: z.literal('added'), - collection: z.string(), - id: z.json(), - fields: z.optional(z.record(z.string(), z.json())), - // FIXME: Apparently Meteor can send `cleared` in `added`... - cleared: z.optional(z.array(z.string())), -}); - -const AddedBeforeSchema = z.strictObject({ - msg: z.literal('addedBefore'), - collection: z.string(), - id: z.json(), - fields: z.optional(z.record(z.string(), z.json())), - before: z.optional(z.string()), -}); - -const ChangedSchema = z.strictObject({ - msg: z.literal('changed'), - collection: z.string(), - id: z.json(), - fields: z.optional(z.record(z.string(), z.json())), - cleared: z.optional(z.array(z.string())), -}); - -const MovedBeforeSchema = z.strictObject({ - msg: z.literal('movedBefore'), - collection: z.string(), - id: z.json(), - before: z.optional(z.string()), -}); - -const RemovedSchema = z.strictObject({ - msg: z.literal('removed'), - collection: z.string(), - id: z.json(), -}); -// Managing subscriptions -const NosubSchema = z.strictObject({ - msg: z.literal('nosub'), - id: z.string(), - error: z.optional(z.json()), -}); -const ReadySchema = z.strictObject({ - msg: z.literal('ready'), - subs: z.array(z.string()), -}); -const SubSchema = z.strictObject({ - msg: z.literal('sub'), - id: z.string(), - name: z.string(), - params: z.optional(z.array(z.json())), -}); - -const UnsubSchema = z.strictObject({ - msg: z.literal('unsub'), - id: z.string(), -}); - -// Remote procedure calls -const MethodSchema = z.strictObject({ - msg: z.literal('method'), - id: z.string(), - method: z.string(), - params: z.optional(z.array(z.json())), - randomSeed: z.optional(z.string()), -}); - -const ResultSchema = z.strictObject({ - msg: z.literal('result'), - id: z.string(), - error: z.optional(z.json()), - result: z.optional(z.json()), -}); - -const UpdatedSchema = z.strictObject({ - msg: z.literal('updated'), - methods: z.array(z.string()), -}); - -const ServerIdSchema = z.strictObject({ - msg: z.literal('server_id'), - server_id: z.string(), -}); - -/** - * Main DDP Message Schema - */ -const DDPMessageSchema = z.discriminatedUnion('msg', [ - // Connection - ConnectSchema, - ConnectedSchema, - FailedSchema, - - // Heartbeats - PingSchema, - PongSchema, - - // Data - AddedSchema, - AddedBeforeSchema, - ChangedSchema, - MovedBeforeSchema, - RemovedSchema, - - // Subscriptions - NosubSchema, - ReadySchema, - SubSchema, - UnsubSchema, - - // RPC - MethodSchema, - ResultSchema, - UpdatedSchema, - - // Server ID - ServerIdSchema, -]); - -/** - * Inferred TypeScript Type - * This is "erasable" as it compiles away completely. - */ -export type DDPMessage = z.infer; - -/** - * Parses a DDP message from JSON string - * @param json The JSON string to parse - * @returns The parsed DDP message - * @throws If the JSON is invalid or does not conform to the DDP message schema - */ -export const parseDDPMessage = (json: string): DDPMessage => { - return DDPMessageSchema.parse(JSON.parse(json)); -}; diff --git a/apps/meteor/client/lib/streamer/emitter.ts b/apps/meteor/client/lib/streamer/emitter.ts deleted file mode 100644 index de529d4afb28b..0000000000000 --- a/apps/meteor/client/lib/streamer/emitter.ts +++ /dev/null @@ -1,52 +0,0 @@ -export type StreamArgs = unknown[]; -export type EventHandler = (...args: TArgs) => void; - -type HandlerMap = { - [event: string]: EventHandler[] | undefined; -}; - -export class EV { - private handlers: HandlerMap = {}; - - emit(event: string, ...args: StreamArgs): void { - this.handlers[event]?.forEach((handler) => handler.apply(this, args)); - } - - emitWithScope(event: string, scope: unknown, ...args: StreamArgs): void { - this.handlers[event]?.forEach((handler) => handler.apply(scope, args)); - } - - listenerCount(event: string): number { - return this.handlers[event]?.length ?? 0; - } - - on(event: string, callback: EventHandler): void { - if (!this.handlers[event]) { - this.handlers[event] = []; - } - this.handlers[event].push(callback); - } - - once(event: string, callback: EventHandler): void { - const onetimeCallback: EventHandler = (...args) => { - this.removeListener(event, onetimeCallback); - callback.apply(this, args); - }; - this.on(event, onetimeCallback); - } - - removeListener(event: string, callback: EventHandler): void { - const handlers = this.handlers[event]; - if (!handlers) { - return; - } - const index = handlers.indexOf(callback); - if (index > -1) { - handlers.splice(index, 1); - } - } - - removeAllListeners(event: string): void { - this.handlers[event] = undefined; - } -} diff --git a/apps/meteor/client/lib/streamer/index.ts b/apps/meteor/client/lib/streamer/index.ts deleted file mode 100644 index d3c632e94e9d6..0000000000000 --- a/apps/meteor/client/lib/streamer/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { StreamerCentral } from './streamer'; - -export const streamerCentral = new StreamerCentral(); diff --git a/apps/meteor/client/lib/streamer/streamer.ts b/apps/meteor/client/lib/streamer/streamer.ts deleted file mode 100644 index 1eb31015acaf5..0000000000000 --- a/apps/meteor/client/lib/streamer/streamer.ts +++ /dev/null @@ -1,226 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import type { StreamerEvents } from '@rocket.chat/ddp-client'; - -import { parseDDPMessage } from './ddp'; -import { EV, type EventHandler, type StreamArgs } from './emitter'; - -interface SubscriptionHandle { - stop: () => void; -} - -interface StreamerSubscriptionState { - subscription?: SubscriptionHandle; - lastMessage?: StreamArgs; -} - -type SubscriptionMap = Record; - -interface StreamerOptions { - useCollection?: boolean; - ddpConnection: StreamerDDPConnection; -} - -interface StreamerDDPConnection { - _stream: { - on: { - (key: 'message', callback: (data: string) => void): void; - (key: 'reset', callback: () => void): void; - }; - }; - subscribe(name: string, ...args: unknown[]): SubscriptionHandle; - call(methodName: string, ...args: unknown[]): void; - hasMeteorStreamerEventListeners?: boolean; -} - -type EventNames = Extract; - -export class StreamerCentral extends EV { - public instances: { - [N in EventNames]?: Streamer; - } = {}; - - private readonly ddpConnections: Record = {}; - - setupDdpConnection(name: string, ddpConnection: StreamerDDPConnection): void { - if (ddpConnection.hasMeteorStreamerEventListeners) { - return; - } - - ddpConnection._stream.on('message', (rawMessage?: unknown) => { - if (typeof rawMessage !== 'string') { - return; - } - const parsedMessage = parseDDPMessage(rawMessage); - - if ( - 'collection' in parsedMessage && - 'fields' in parsedMessage && - parsedMessage.fields && - 'eventName' in parsedMessage.fields && - 'args' in parsedMessage.fields && - Array.isArray(parsedMessage.fields.args) - ) { - this.emit(parsedMessage.collection, parsedMessage.fields.eventName, ...parsedMessage.fields.args); - } - }); - - this.storeDdpConnection(name, ddpConnection); - } - - private storeDdpConnection(name: string, ddpConnection: StreamerDDPConnection): void { - ddpConnection.hasMeteorStreamerEventListeners = true; - this.ddpConnections[name] = ddpConnection; - } - - getStreamer(name: N, options: StreamerOptions): Streamer { - const existingInstance = this.instances[name]; - if (existingInstance) { - return existingInstance as Streamer; - } - - const streamer = new Streamer(name, options); - this.setupDdpConnection(name, options.ddpConnection); - this.instances[name] = streamer as any; - this.on(streamer.subscriptionName, (eventName: string, ...args: StreamArgs) => { - streamer.receive(eventName, ...args); - }); - return streamer; - } -} - -type StreamerCallback = (...args: StreamArgs) => void; - -export class Streamer extends EV { - private ddpConnection: StreamerDDPConnection; - - public name: N; - - public useCollection = false; - - private readonly subscriptions: SubscriptionMap = {}; - - constructor(name: N, { ddpConnection, useCollection = false }: StreamerOptions) { - super(); - - this.ddpConnection = ddpConnection; - - this.name = name; - this.useCollection = useCollection; - - this.ddpConnection._stream.on('reset', () => { - super.emit('__reconnect__'); - }); - } - - get subscriptionName(): `stream-${N}` { - return `stream-${this.name}`; - } - - stop(eventName: string): void { - this.subscriptions[eventName]?.subscription?.stop(); - } - - stopAll(): void { - Object.keys(this.subscriptions).forEach((eventName) => this.removeAllListeners(eventName)); - } - - unsubscribe(eventName: string): void { - delete this.subscriptions[eventName]; - super.removeAllListeners(eventName); - } - - subscribe(eventName: string, args: StreamArgs = []): Promise { - return new Promise((resolve, reject) => { - if (this.subscriptions[eventName]) { - resolve(); - return; - } - - const subscription = this.ddpConnection.subscribe( - this.subscriptionName, - eventName, - { useCollection: this.useCollection, args }, - { - onStop: () => { - this.unsubscribe(eventName); - reject(new Error('streamer-subscription-stopped')); - }, - onReady: resolve, - }, - ); - - this.subscriptions[eventName] = { subscription }; - }); - } - - onReconnect(callback: StreamerCallback): void { - if (typeof callback === 'function') { - super.on('__reconnect__', callback); - } - } - - getLastMessageFromEvent(eventName: string): StreamArgs | undefined { - return this.subscriptions[eventName]?.lastMessage; - } - - override removeAllListeners(eventName: string): void { - super.removeAllListeners(eventName); - this.stop(eventName); - } - - override removeListener(eventName: string, callback: EventHandler): void { - if (this.listenerCount(eventName) === 1) { - this.stop(eventName); - } - super.removeListener(eventName, callback); - } - - override on(eventName: string, callback: EventHandler, ...args: StreamArgs): Promise { - // check eventName is a non-empty string - if (typeof eventName !== 'string' || eventName.length === 0) { - throw new Error('Event name must be a non-empty string'); - } - - // check callback is a function - if (typeof callback !== 'function') { - throw new Error('Callback must be a function'); - } - - super.on(eventName, callback); - - return this.subscribe(eventName, args); - } - - override once(eventName: string, callback: EventHandler, ...args: StreamArgs): Promise { - // check eventName is a non-empty string - if (typeof eventName !== 'string' || eventName.length === 0) { - throw new Error('Event name must be a non-empty string'); - } - - // check callback is a function - if (typeof callback !== 'function') { - throw new Error('Callback must be a function'); - } - - super.once(eventName, (...cbArgs: StreamArgs) => { - callback(...cbArgs); - if (this.listenerCount(eventName) === 0) { - this.stop(eventName); - } - }); - - return this.subscribe(eventName, args); - } - - override emit(...args: StreamArgs): void { - this.ddpConnection.call(this.subscriptionName, ...args); - } - - public receive(eventName: string, ...args: StreamArgs): void { - const subscription = this.subscriptions[eventName]; - if (subscription) { - subscription.lastMessage = args; - super.emit(eventName, ...args); - } - } -} diff --git a/apps/meteor/client/lib/userStatuses.ts b/apps/meteor/client/lib/userStatuses.ts index 631c1d1ea0442..2efc945d3c34a 100644 --- a/apps/meteor/client/lib/userStatuses.ts +++ b/apps/meteor/client/lib/userStatuses.ts @@ -1,8 +1,6 @@ import { UserStatus } from '@rocket.chat/core-typings'; import type { ICustomUserStatus } from '@rocket.chat/core-typings'; -import { sdk } from '../../app/utils/client/lib/SDKClient'; - export type UserStatusDescriptor = { id: string; name: string; @@ -10,6 +8,10 @@ export type UserStatusDescriptor = { localizeName: boolean; }; +export type UserStatusStreamCallback = (data: { userStatusData: ICustomUserStatus }) => void; +export type UserStatusStreamer = (event: 'updateCustomUserStatus' | 'deleteCustomUserStatus', cb: UserStatusStreamCallback) => () => void; +export type UserStatusLister = () => Promise; + export class UserStatuses implements Iterable { public invisibleAllowed = true; @@ -33,7 +35,7 @@ export class UserStatuses implements Iterable { this.store.set(customUserStatus.id, customUserStatus); } - public createFromCustom(customUserStatus: ICustomUserStatus): UserStatusDescriptor { + public createFromCustom(customUserStatus: Omit