diff --git a/.changeset/add-parser-fuzz-testing.md b/.changeset/add-parser-fuzz-testing.md
new file mode 100644
index 0000000000000..f6b4bb1e147ed
--- /dev/null
+++ b/.changeset/add-parser-fuzz-testing.md
@@ -0,0 +1,5 @@
+---
+"@rocket.chat/message-parser": patch
+---
+
+Add property-based fuzz testing using `fast-check` to continuously evaluate memory limits, structural boundaries, and backtracking behavior against heavily randomized markdown arrays.
diff --git a/.changeset/block-splitter-layer1.md b/.changeset/block-splitter-layer1.md
new file mode 100644
index 0000000000000..32a757bf7705e
--- /dev/null
+++ b/.changeset/block-splitter-layer1.md
@@ -0,0 +1,5 @@
+---
+"@rocket.chat/message-parser": patch
+---
+
+feat(message-parser): implement BlockSplitter PoC (Layer 1)
diff --git a/.changeset/bump-patch-1776981008743.md b/.changeset/bump-patch-1776981008743.md
new file mode 100644
index 0000000000000..e1eaa7980afb1
--- /dev/null
+++ b/.changeset/bump-patch-1776981008743.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/meteor': patch
+---
+
+Bump @rocket.chat/meteor version.
diff --git a/.changeset/bump-patch-1777419612322.md b/.changeset/bump-patch-1777419612322.md
new file mode 100644
index 0000000000000..e1eaa7980afb1
--- /dev/null
+++ b/.changeset/bump-patch-1777419612322.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/meteor': patch
+---
+
+Bump @rocket.chat/meteor version.
diff --git a/.changeset/cool-cups-tickle.md b/.changeset/cool-cups-tickle.md
new file mode 100644
index 0000000000000..d8ab50537942e
--- /dev/null
+++ b/.changeset/cool-cups-tickle.md
@@ -0,0 +1,7 @@
+---
+'@rocket.chat/ui-composer': minor
+'@rocket.chat/ui-client': minor
+'@rocket.chat/meteor': minor
+---
+
+Adds file thumbnails with image preview to the message composer attachments
diff --git a/.changeset/cool-parents-buy.md b/.changeset/cool-parents-buy.md
new file mode 100644
index 0000000000000..d2eff43bbfc5b
--- /dev/null
+++ b/.changeset/cool-parents-buy.md
@@ -0,0 +1,6 @@
+---
+'@rocket.chat/meteor': patch
+'@rocket.chat/mock-providers': patch
+---
+
+Fixed UI becoming unresponsive after clicking "See on Engagement Dashboard" from the workspace info card, which required a manual page refresh to recover.
diff --git a/.changeset/eighty-experts-sort.md b/.changeset/eighty-experts-sort.md
new file mode 100644
index 0000000000000..b71f15e16562d
--- /dev/null
+++ b/.changeset/eighty-experts-sort.md
@@ -0,0 +1,6 @@
+---
+'@rocket.chat/ui-voip': patch
+'@rocket.chat/meteor': patch
+---
+
+Fixes empty notifications sent when a voice call ends
diff --git a/.changeset/empty-rockets-rush.md b/.changeset/empty-rockets-rush.md
new file mode 100644
index 0000000000000..27aa814d2e9ac
--- /dev/null
+++ b/.changeset/empty-rockets-rush.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/media-signaling': major
+---
+
+Publishes the media signaling package to npm
diff --git a/.changeset/empty-spiders-vanish.md b/.changeset/empty-spiders-vanish.md
new file mode 100644
index 0000000000000..8c7a62f6d2dd5
--- /dev/null
+++ b/.changeset/empty-spiders-vanish.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/ddp-streamer': patch
+---
+
+Fixes an issue where some DDP method calls could incorrectly return a 404 error.
diff --git a/.changeset/fair-lions-smell.md b/.changeset/fair-lions-smell.md
new file mode 100644
index 0000000000000..403b673842ee0
--- /dev/null
+++ b/.changeset/fair-lions-smell.md
@@ -0,0 +1,10 @@
+---
+'@rocket.chat/media-calls': minor
+'@rocket.chat/core-services': minor
+'@rocket.chat/model-typings': minor
+'@rocket.chat/models': minor
+'@rocket.chat/meteor': minor
+'@rocket.chat/media-signaling': minor
+---
+
+Adds a new REST endpoint to accept or reject media calls without an active media session
diff --git a/.changeset/famous-socks-clap.md b/.changeset/famous-socks-clap.md
new file mode 100644
index 0000000000000..bcda65ed82c0b
--- /dev/null
+++ b/.changeset/famous-socks-clap.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/meteor': patch
+---
+
+LDAP `sync now` action now syncs user's abac attributes too.
diff --git a/.changeset/forty-dolphins-check.md b/.changeset/forty-dolphins-check.md
new file mode 100644
index 0000000000000..b008352c629cc
--- /dev/null
+++ b/.changeset/forty-dolphins-check.md
@@ -0,0 +1,10 @@
+---
+'@rocket.chat/model-typings': minor
+'@rocket.chat/core-typings': minor
+'@rocket.chat/apps-engine': minor
+'@rocket.chat/omni-core': minor
+'@rocket.chat/models': minor
+'@rocket.chat/meteor': minor
+---
+
+Adds externalIds field to livechat visitors for external platform identification.
diff --git a/.changeset/grumpy-ligers-drum.md b/.changeset/grumpy-ligers-drum.md
new file mode 100644
index 0000000000000..43d7caa24e949
--- /dev/null
+++ b/.changeset/grumpy-ligers-drum.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/server-fetch': minor
+---
+
+Introduces redaction of potentially sensitive data when logging request URLs
diff --git a/.changeset/hot-lies-divide.md b/.changeset/hot-lies-divide.md
new file mode 100644
index 0000000000000..89b596fdba55a
--- /dev/null
+++ b/.changeset/hot-lies-divide.md
@@ -0,0 +1,7 @@
+---
+'@rocket.chat/model-typings': patch
+'@rocket.chat/models': patch
+'@rocket.chat/meteor': patch
+---
+
+Fixes an issue where messages appeared as unread even when all active users had read them. Read receipts now correctly ignore deactivated users.
diff --git a/.changeset/integration-script-transpile-opt-out.md b/.changeset/integration-script-transpile-opt-out.md
new file mode 100644
index 0000000000000..3e6451943d03f
--- /dev/null
+++ b/.changeset/integration-script-transpile-opt-out.md
@@ -0,0 +1,7 @@
+---
+'@rocket.chat/meteor': minor
+'@rocket.chat/core-typings': minor
+'@rocket.chat/rest-typings': minor
+---
+
+Adds a `skipTranspile` flag (default `false`) to webhook integrations. When set to `true`, the integration script is stored as-is without Babel transpilation — matching the 9.0.0 default where Babel is removed entirely. Admins can flip the flag per-integration to validate strict-mode compatibility before upgrading. The field is deprecated and will be removed in 9.0.0.
diff --git a/.changeset/khaki-eggs-provide.md b/.changeset/khaki-eggs-provide.md
new file mode 100644
index 0000000000000..75185c6eee7c5
--- /dev/null
+++ b/.changeset/khaki-eggs-provide.md
@@ -0,0 +1,6 @@
+---
+'@rocket.chat/apps-engine': patch
+'@rocket.chat/meteor': patch
+---
+
+Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates)
diff --git a/.changeset/long-years-flow.md b/.changeset/long-years-flow.md
new file mode 100644
index 0000000000000..21df4d574e44b
--- /dev/null
+++ b/.changeset/long-years-flow.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/meteor': patch
+---
+
+Fixes user status indicator to show disabled state when presence broadcast is turned off
diff --git a/.changeset/loud-ligers-explode.md b/.changeset/loud-ligers-explode.md
new file mode 100644
index 0000000000000..2d8e750e9c412
--- /dev/null
+++ b/.changeset/loud-ligers-explode.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/models': patch
+---
+
+Fixes array resorting when appending visitor emails/phones to ensure chat transcripts are sent to the correct registration email.
diff --git a/.changeset/many-avocados-battle.md b/.changeset/many-avocados-battle.md
new file mode 100644
index 0000000000000..e059961f0eee8
--- /dev/null
+++ b/.changeset/many-avocados-battle.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/meteor': patch
+---
+
+Fixes a bug that could remove all of a user's subscriptions when the user was re-added to a room while still banned.
diff --git a/.changeset/mean-mails-pay.md b/.changeset/mean-mails-pay.md
new file mode 100644
index 0000000000000..b2d32b798e1c1
--- /dev/null
+++ b/.changeset/mean-mails-pay.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/meteor': patch
+---
+
+Fixes Slack messages being incorrectly saved on import
diff --git a/.changeset/message-parser-guards-coverage.md b/.changeset/message-parser-guards-coverage.md
new file mode 100644
index 0000000000000..ccdf8b6e2e3db
--- /dev/null
+++ b/.changeset/message-parser-guards-coverage.md
@@ -0,0 +1,5 @@
+---
+"@rocket.chat/message-parser": patch
+---
+
+Add test coverage for isNodeOfType guard bringing branch coverage to 100%
diff --git a/.changeset/message-parser-joinEmoji-tests.md b/.changeset/message-parser-joinEmoji-tests.md
new file mode 100644
index 0000000000000..825e488eb1a92
--- /dev/null
+++ b/.changeset/message-parser-joinEmoji-tests.md
@@ -0,0 +1,5 @@
+---
+"@rocket.chat/message-parser": patch
+---
+
+Add test coverage for joinEmoji behavior through reducePlainTexts
diff --git a/.changeset/metal-rice-retire.md b/.changeset/metal-rice-retire.md
new file mode 100644
index 0000000000000..0ed9fb757076e
--- /dev/null
+++ b/.changeset/metal-rice-retire.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/meteor': patch
+---
+
+Adds SAML redirect validation by matching request parameters and configured IdP SLO
diff --git a/.changeset/nasty-tomatoes-bake.md b/.changeset/nasty-tomatoes-bake.md
new file mode 100644
index 0000000000000..4320e54fe45e5
--- /dev/null
+++ b/.changeset/nasty-tomatoes-bake.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/meteor': patch
+---
+
+Fixes an issue where the outlook calendar action isn't being reactive based on the setting
diff --git a/.changeset/neat-bananas-behave.md b/.changeset/neat-bananas-behave.md
new file mode 100644
index 0000000000000..9d0f5be6c795a
--- /dev/null
+++ b/.changeset/neat-bananas-behave.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/meteor': patch
+---
+
+Fixes app actions ignoring role filters and i18n translation
diff --git a/.changeset/odd-needles-smash.md b/.changeset/odd-needles-smash.md
new file mode 100644
index 0000000000000..e059f43ca1e50
--- /dev/null
+++ b/.changeset/odd-needles-smash.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/meteor': patch
+---
+
+Fixes "Reply in direct message" action not being shown when user has permission to create DMs but no existing conversation exists.
diff --git a/.changeset/pre.json b/.changeset/pre.json
new file mode 100644
index 0000000000000..51ef38da20b90
--- /dev/null
+++ b/.changeset/pre.json
@@ -0,0 +1,124 @@
+{
+ "mode": "pre",
+ "tag": "rc",
+ "initialVersions": {
+ "@rocket.chat/meteor": "8.4.0-develop",
+ "rocketchat-services": "2.0.44",
+ "@rocket.chat/uikit-playground": "0.7.9",
+ "@rocket.chat/account-service": "0.4.53",
+ "@rocket.chat/authorization-service": "0.5.6",
+ "@rocket.chat/ddp-streamer": "0.3.53",
+ "@rocket.chat/omnichannel-transcript": "0.4.53",
+ "@rocket.chat/presence-service": "0.4.53",
+ "@rocket.chat/queue-worker": "0.4.53",
+ "@rocket.chat/abac": "0.1.6",
+ "@rocket.chat/federation-matrix": "0.1.0",
+ "@rocket.chat/license": "1.1.13",
+ "@rocket.chat/media-calls": "0.3.0",
+ "@rocket.chat/network-broker": "0.2.32",
+ "@rocket.chat/omni-core-ee": "0.0.18",
+ "@rocket.chat/omnichannel-services": "0.3.50",
+ "@rocket.chat/pdf-worker": "0.3.32",
+ "@rocket.chat/presence": "0.2.53",
+ "@rocket.chat/account-utils": "0.0.3",
+ "@rocket.chat/agenda": "0.1.1",
+ "@rocket.chat/api-client": "0.2.53",
+ "@rocket.chat/apps": "0.6.6",
+ "@rocket.chat/apps-engine": "1.61.0",
+ "@rocket.chat/base64": "1.0.14",
+ "@rocket.chat/cas-validate": "0.0.4",
+ "@rocket.chat/core-services": "0.13.2",
+ "@rocket.chat/core-typings": "8.4.0-develop",
+ "@rocket.chat/cron": "0.1.53",
+ "@rocket.chat/ddp-client": "1.0.6",
+ "@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": "29.0.0",
+ "@rocket.chat/gazzodown": "29.0.0",
+ "@rocket.chat/http-router": "7.9.20",
+ "@rocket.chat/i18n": "2.2.0",
+ "@rocket.chat/instance-status": "0.1.53",
+ "@rocket.chat/jest-presets": "0.0.2",
+ "@rocket.chat/jwt": "0.2.1",
+ "@rocket.chat/livechat": "2.1.0",
+ "@rocket.chat/log-format": "0.0.3",
+ "@rocket.chat/logger": "1.0.1",
+ "@rocket.chat/media-signaling": "0.2.0",
+ "@rocket.chat/message-parser": "0.31.35",
+ "@rocket.chat/message-types": "0.1.1",
+ "@rocket.chat/mock-providers": "0.4.13",
+ "@rocket.chat/model-typings": "2.1.2",
+ "@rocket.chat/models": "2.1.2",
+ "@rocket.chat/mongo-adapter": "0.0.3",
+ "@rocket.chat/poplib": "0.0.3",
+ "@rocket.chat/omni-core": "0.0.18",
+ "@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.4.0-develop",
+ "@rocket.chat/server-cloud-communication": "0.0.3",
+ "@rocket.chat/server-fetch": "0.1.2",
+ "@rocket.chat/sha256": "1.0.13",
+ "@rocket.chat/storybook-config": "0.0.3",
+ "@rocket.chat/tools": "0.2.5",
+ "@rocket.chat/tracing": "0.0.2",
+ "@rocket.chat/tsconfig": "0.0.0",
+ "@rocket.chat/ui-avatar": "25.0.0",
+ "@rocket.chat/ui-client": "29.0.0",
+ "@rocket.chat/ui-composer": "0.6.0",
+ "@rocket.chat/ui-contexts": "29.0.0",
+ "@rocket.chat/ui-kit": "1.0.0",
+ "@rocket.chat/ui-video-conf": "29.0.0",
+ "@rocket.chat/ui-voip": "19.0.0",
+ "@rocket.chat/web-ui-registration": "29.0.0"
+ },
+ "changesets": [
+ "add-parser-fuzz-testing",
+ "block-splitter-layer1",
+ "bump-patch-1776981008743",
+ "bump-patch-1777419612322",
+ "cool-cups-tickle",
+ "cool-parents-buy",
+ "eighty-experts-sort",
+ "empty-rockets-rush",
+ "empty-spiders-vanish",
+ "fair-lions-smell",
+ "famous-socks-clap",
+ "forty-dolphins-check",
+ "grumpy-ligers-drum",
+ "hot-lies-divide",
+ "integration-script-transpile-opt-out",
+ "khaki-eggs-provide",
+ "long-years-flow",
+ "loud-ligers-explode",
+ "many-avocados-battle",
+ "mean-mails-pay",
+ "message-parser-guards-coverage",
+ "message-parser-joinEmoji-tests",
+ "metal-rice-retire",
+ "nasty-tomatoes-bake",
+ "neat-bananas-behave",
+ "odd-needles-smash",
+ "purple-boxes-shout",
+ "purple-pillows-prove",
+ "quick-impalas-pump",
+ "red-kings-notice",
+ "serious-eggs-type",
+ "shiny-berries-check",
+ "smart-chicken-repair",
+ "soft-rats-behave",
+ "stale-drinks-report",
+ "tall-flowers-return",
+ "tall-singers-roll",
+ "tender-seas-sparkle",
+ "tender-spies-give",
+ "tidy-pillows-eat",
+ "warm-cups-dress",
+ "wet-pandas-pump",
+ "wicked-drinks-think"
+ ]
+}
diff --git a/.changeset/purple-boxes-shout.md b/.changeset/purple-boxes-shout.md
new file mode 100644
index 0000000000000..9e15fbd28faaf
--- /dev/null
+++ b/.changeset/purple-boxes-shout.md
@@ -0,0 +1,6 @@
+---
+'@rocket.chat/models': minor
+'@rocket.chat/meteor': minor
+---
+
+Updates omnichannel routing so agents with `offline` status are always excluded from assignment. The `Livechat_enabled_when_agent_idle` setting now only affects agents with `away` status.
diff --git a/.changeset/purple-pillows-prove.md b/.changeset/purple-pillows-prove.md
new file mode 100644
index 0000000000000..0ec65c2e0fb05
--- /dev/null
+++ b/.changeset/purple-pillows-prove.md
@@ -0,0 +1,6 @@
+---
+'@rocket.chat/i18n': patch
+'@rocket.chat/meteor': patch
+---
+
+Moves keyboard shortcuts from the contextual bar into a modal accessible from the user menu, and adds a hotkey to open it.
diff --git a/.changeset/quick-impalas-pump.md b/.changeset/quick-impalas-pump.md
new file mode 100644
index 0000000000000..71a0718491dca
--- /dev/null
+++ b/.changeset/quick-impalas-pump.md
@@ -0,0 +1,6 @@
+---
+'@rocket.chat/apps-engine': patch
+'@rocket.chat/meteor': patch
+---
+
+Fixes the issue of the lacking MessageUpdater not being available to apps during runtime
diff --git a/.changeset/red-kings-notice.md b/.changeset/red-kings-notice.md
new file mode 100644
index 0000000000000..45926a2821b44
--- /dev/null
+++ b/.changeset/red-kings-notice.md
@@ -0,0 +1,6 @@
+---
+'@rocket.chat/federation-matrix': patch
+'@rocket.chat/meteor': patch
+---
+
+Fixes a bug preventing users to join a room after being banned and unbanned.
diff --git a/.changeset/serious-eggs-type.md b/.changeset/serious-eggs-type.md
new file mode 100644
index 0000000000000..1a2758a28fe65
--- /dev/null
+++ b/.changeset/serious-eggs-type.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/meteor': patch
+---
+
+Fixes an issue where the apps-engine updateStatusText method isn't updating the app user status text properly
diff --git a/.changeset/shiny-berries-check.md b/.changeset/shiny-berries-check.md
new file mode 100644
index 0000000000000..4073ccdfc12c4
--- /dev/null
+++ b/.changeset/shiny-berries-check.md
@@ -0,0 +1,10 @@
+---
+'@rocket.chat/media-signaling': minor
+'@rocket.chat/media-calls': minor
+'@rocket.chat/core-services': minor
+'@rocket.chat/apps-engine': minor
+'@rocket.chat/ui-voip': minor
+'@rocket.chat/meteor': minor
+---
+
+Adds new API endpoints to load the user's current voice call state from the server
diff --git a/.changeset/smart-chicken-repair.md b/.changeset/smart-chicken-repair.md
new file mode 100644
index 0000000000000..9c9b522563e13
--- /dev/null
+++ b/.changeset/smart-chicken-repair.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/meteor': minor
+---
+
+Introduces redaction of potentially sensitive data in logs related to apps-engine
diff --git a/.changeset/soft-rats-behave.md b/.changeset/soft-rats-behave.md
new file mode 100644
index 0000000000000..d657151c07f01
--- /dev/null
+++ b/.changeset/soft-rats-behave.md
@@ -0,0 +1,6 @@
+---
+'@rocket.chat/rest-typings': minor
+'@rocket.chat/meteor': minor
+---
+
+Adds email search filter to `users.list` and `users.info` endpoints.
diff --git a/.changeset/stale-drinks-report.md b/.changeset/stale-drinks-report.md
new file mode 100644
index 0000000000000..21977873c7f62
--- /dev/null
+++ b/.changeset/stale-drinks-report.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/meteor': patch
+---
+
+Fixes an issue where the expanded thread view was overlapping the navbar
diff --git a/.changeset/tall-flowers-return.md b/.changeset/tall-flowers-return.md
new file mode 100644
index 0000000000000..10638011ac066
--- /dev/null
+++ b/.changeset/tall-flowers-return.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/meteor': patch
+---
+
+Fixes inability to use custom sounds pagination action buttons when the amount exceeds the specified limit
diff --git a/.changeset/tall-singers-roll.md b/.changeset/tall-singers-roll.md
new file mode 100644
index 0000000000000..dcab473089e96
--- /dev/null
+++ b/.changeset/tall-singers-roll.md
@@ -0,0 +1,10 @@
+---
+"@rocket.chat/meteor": minor
+"@rocket.chat/core-services": minor
+"@rocket.chat/core-typings": minor
+"@rocket.chat/i18n": minor
+"@rocket.chat/authorization-service": minor
+"@rocket.chat/abac": minor
+---
+
+Adds support for setting up Virtru as a PDP (Policy Decision Point) for ABAC.
diff --git a/.changeset/tender-seas-sparkle.md b/.changeset/tender-seas-sparkle.md
new file mode 100644
index 0000000000000..9c9819c8c3a39
--- /dev/null
+++ b/.changeset/tender-seas-sparkle.md
@@ -0,0 +1,13 @@
+---
+'@rocket.chat/meteor': minor
+---
+
+Introduces Cold Storage Archiving for Read Receipts to improve performance and scalability in large deployments.
+
+Enterprise workspaces can now archive older read receipts into a dedicated cold storage collection, reducing the size of the primary read receipts dataset and improving query performance in environments with high message volumes.
+
+This feature is disabled by default and can be enabled through the new setting:
+
+**Message → Read Receipts → Enable Read Receipts Cold Storage**
+
+This feature is especially recommended for deployments with high message throughput and long data retention requirements, where reducing the size of hot collections significantly improves overall system responsiveness.
diff --git a/.changeset/tender-spies-give.md b/.changeset/tender-spies-give.md
new file mode 100644
index 0000000000000..e7f729f448cab
--- /dev/null
+++ b/.changeset/tender-spies-give.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/tools': minor
+---
+
+Adds new function for censoring URL components in logs
diff --git a/.changeset/tidy-pillows-eat.md b/.changeset/tidy-pillows-eat.md
new file mode 100644
index 0000000000000..9f88cae17c924
--- /dev/null
+++ b/.changeset/tidy-pillows-eat.md
@@ -0,0 +1,6 @@
+---
+'@rocket.chat/i18n': patch
+'@rocket.chat/meteor': patch
+---
+
+Adds error feedback when clicking on a mentioned room you don't have access to
diff --git a/.changeset/warm-cups-dress.md b/.changeset/warm-cups-dress.md
new file mode 100644
index 0000000000000..ade4599a2c928
--- /dev/null
+++ b/.changeset/warm-cups-dress.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/meteor': minor
+---
+
+Added auto-wrap selected text in composer with matching delimiters
diff --git a/.changeset/wet-pandas-pump.md b/.changeset/wet-pandas-pump.md
new file mode 100644
index 0000000000000..eeababaeb01e3
--- /dev/null
+++ b/.changeset/wet-pandas-pump.md
@@ -0,0 +1,8 @@
+---
+'@rocket.chat/model-typings': patch
+'@rocket.chat/omni-core': patch
+'@rocket.chat/models': patch
+'@rocket.chat/meteor': patch
+---
+
+Fixes an issue where the Omnichannel routing system ignored the `Livechat_accept_chats_with_no_agents` setting. Now, offline agents are correctly considered for assignment when the setting allows it.
diff --git a/.changeset/wicked-drinks-think.md b/.changeset/wicked-drinks-think.md
new file mode 100644
index 0000000000000..096d85f1b933e
--- /dev/null
+++ b/.changeset/wicked-drinks-think.md
@@ -0,0 +1,6 @@
+---
+'@rocket.chat/i18n': minor
+'@rocket.chat/meteor': minor
+---
+
+Adds alternative text field to image uploads to improve accessibility
diff --git a/.github/actionlint-matcher.json b/.github/actionlint-matcher.json
new file mode 100644
index 0000000000000..4613e1617bfe2
--- /dev/null
+++ b/.github/actionlint-matcher.json
@@ -0,0 +1,17 @@
+{
+ "problemMatcher": [
+ {
+ "owner": "actionlint",
+ "pattern": [
+ {
+ "regexp": "^(?:\\x1b\\[\\d+m)?(.+?)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*: (?:\\x1b\\[\\d+m)*(.+?)(?:\\x1b\\[\\d+m)* \\[(.+?)\\]$",
+ "file": 1,
+ "line": 2,
+ "column": 3,
+ "message": 4,
+ "code": 5
+ }
+ ]
+ }
+ ]
+}
diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml
new file mode 100644
index 0000000000000..205a414f4655e
--- /dev/null
+++ b/.github/actionlint.yaml
@@ -0,0 +1,3 @@
+paths:
+ .github/workflows/**/*.yml:
+ ignore: ['file "dist/index\.js" does not exist in .*/packages/release-action']
diff --git a/.github/actions/build-docker/action.yml b/.github/actions/build-docker/action.yml
index 1bc8542e20464..77ebf4aa3ad03 100644
--- a/.github/actions/build-docker/action.yml
+++ b/.github/actions/build-docker/action.yml
@@ -1,14 +1,16 @@
name: 'Build Docker'
+description: 'Build Docker images for Rocket.Chat and related services'
inputs:
CR_USER:
required: true
+ description: 'GitHub Container Registry username'
CR_PAT:
required: true
+ description: 'GitHub Container Registry Personal Access Token'
deno-version:
required: true
description: 'Deno version'
- type: string
arch:
required: false
description: 'Architecture'
@@ -16,7 +18,6 @@ inputs:
service:
required: false
description: 'Container to build'
- type: string
publish-image:
required: false
description: 'Publish image'
@@ -24,7 +25,7 @@ inputs:
setup-docker:
required: false
description: 'Setup Docker'
- default: true
+ default: 'true'
type:
required: false
description: 'production or coverage'
@@ -36,7 +37,7 @@ runs:
steps:
- name: Login to GitHub Container Registry
if: inputs.publish-image == 'true' && github.actor != 'dependabot[bot]' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop')
- uses: docker/login-action@v3
+ uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ inputs.CR_USER }}
@@ -44,7 +45,7 @@ runs:
- name: Restore meteor build
if: inputs.service == 'rocketchat'
- uses: actions/download-artifact@v6
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: build-${{ inputs.type }}
path: /tmp/build
@@ -58,8 +59,8 @@ runs:
rm Rocket.Chat.tar.gz
- name: Set up Docker
- if: inputs.setup-docker == true
- uses: docker/setup-docker-action@v4
+ if: inputs.setup-docker == 'true'
+ uses: docker/setup-docker-action@e43656e248c0bd0647d3f5c195d116aacf6fcaf4 # v4.7.0
with:
daemon-config: |
{
@@ -71,19 +72,27 @@ runs:
- name: Build Docker images
shell: bash
+ env:
+ DENO_VERSION: ${{ inputs.deno-version }}
+ INPUT_ARCH: ${{ inputs.arch }}
+ INPUT_SERVICE: ${{ inputs.service }}
+ INPUT_PUBLISH_IMAGE: ${{ inputs.publish-image }}
+ INPUT_TYPE: ${{ inputs.type }}
+ GITHUB_RUN_ID: ${{ github.run_id }}
+ GITHUB_SERVER_URL: ${{ github.server_url }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ GITHUB_EVENT_NAME: ${{ github.event_name }}
+ GITHUB_REF: ${{ github.ref }}
run: |
set -o xtrace
- export DENO_VERSION="${{ inputs.deno-version }}"
# Removes unnecessary swc cores and sharp binaries to reduce image size
swc_arch='x64'
- if [[ "${{ inputs.service }}" == 'rocketchat' ]]; then
- if [[ "${{ inputs.arch }}" == 'arm64' ]]; then
+ if [[ "$INPUT_SERVICE" == 'rocketchat' ]]; then
+ if [[ "$INPUT_ARCH" == 'arm64' ]]; then
swc_arch='arm64'
fi
- find /tmp/build/bundle/programs/server/npm/node_modules/meteor/babel-compiler/node_modules/@meteorjs/swc-core/.swc/node_modules/@swc -type d -name 'core-*' -not -name "*linux-${swc_arch}-gnu*" -exec rm -rf {} +
-
find /tmp/build/bundle/programs/server/npm/node_modules/@img -type d -name 'sharp-*' -not -name "*-linuxmusl-${swc_arch}" -exec rm -rf {} +
find /tmp/build/bundle/programs/server/npm/node_modules/@napi-rs -type d -name 'pinyin-linux-*' -not -name "*-linux-${swc_arch}-*" -exec rm -rf {} +
@@ -93,79 +102,86 @@ runs:
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 {} +
fi
- if [[ "${{ inputs.publish-image }}" == 'true' ]]; then
+ if [[ "$INPUT_PUBLISH_IMAGE" == 'true' ]]; then
LOAD_OR_PUSH="--push"
else
LOAD_OR_PUSH="--load"
fi
# Get image name from docker-compose-ci.yml since rocketchat image is different from service name (rocket.chat)
- IMAGE=$(docker compose -f docker-compose-ci.yml config --format json 2>/dev/null | jq -r --arg s "${{ inputs.service }}" '.services[$s].image')
+ IMAGE=$(docker compose -f docker-compose-ci.yml config --format json 2>/dev/null | jq -r --arg s "$INPUT_SERVICE" '.services[$s].image')
docker buildx bake \
-f docker-compose-ci.yml \
${LOAD_OR_PUSH} \
--allow=fs.read=/tmp/build \
- --set "*.tags+=${IMAGE}-gha-run-${{ github.run_id }}" \
- --set "*.labels.org.opencontainers.image.description=Build run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
- --set "*.labels.org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}" \
- --set *.platform=linux/${{ inputs.arch }} \
+ --set "*.tags+=${IMAGE}-gha-run-${GITHUB_RUN_ID}" \
+ --set "*.labels.org.opencontainers.image.description=Build run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
+ --set "*.labels.org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \
+ --set "*.platform=linux/${INPUT_ARCH}" \
--set *.cache-from=type=gha \
--set *.cache-to=type=gha,mode=max \
--provenance=false \
--sbom=false \
--metadata-file "/tmp/meta.json" \
- "${{ inputs.service }}"
+ "$INPUT_SERVICE"
echo "Contents of /tmp/meta.json:"
cat /tmp/meta.json
- if [[ "${{ inputs.publish-image }}" == 'true' ]]; then
- SERVICE_SUFFIX=${{ inputs.service == 'rocketchat' && inputs.type == 'coverage' && (github.event_name == 'release' || github.ref == 'refs/heads/develop') && '-cov' || '' }}
+ if [[ "$INPUT_PUBLISH_IMAGE" == 'true' ]]; then
+ SERVICE_SUFFIX=''
+ if [[ "$INPUT_SERVICE" == 'rocketchat' && "$INPUT_TYPE" == 'coverage' ]] && [[ "$GITHUB_EVENT_NAME" == 'release' || "$GITHUB_REF" == 'refs/heads/develop' ]]; then
+ SERVICE_SUFFIX='-cov'
+ fi
- mkdir -p /tmp/manifests/${{ inputs.service }}${SERVICE_SUFFIX}/${{ inputs.arch }}
+ mkdir -p "/tmp/manifests/${INPUT_SERVICE}${SERVICE_SUFFIX}/${INPUT_ARCH}"
# Get digest and image info
- DIGEST=$(jq -r '.["${{ inputs.service }}"].["containerimage.digest"]' "/tmp/meta.json")
+ DIGEST=$(jq -r --arg service "$INPUT_SERVICE" '.[$service].["containerimage.digest"]' "/tmp/meta.json")
IMAGE_NO_TAG=$(echo "$IMAGE" | sed 's/:.*$//')
FULL_IMAGE="${IMAGE_NO_TAG}@${DIGEST}"
echo "Inspecting image: $FULL_IMAGE"
# Inspect the image and save complete manifest with sizes (using -v for verbose)
- docker manifest inspect -v "$FULL_IMAGE" > "/tmp/manifests/${{ inputs.service }}${SERVICE_SUFFIX}/${{ inputs.arch }}/manifest.json"
+ docker manifest inspect -v "$FULL_IMAGE" > "/tmp/manifests/${INPUT_SERVICE}${SERVICE_SUFFIX}/${INPUT_ARCH}/manifest.json"
- echo "Saved manifest to /tmp/manifests/${{ inputs.service }}${SERVICE_SUFFIX}/${{ inputs.arch }}/manifest.json"
- cat "/tmp/manifests/${{ inputs.service }}${SERVICE_SUFFIX}/${{ inputs.arch }}/manifest.json" | jq '.'
+ echo "Saved manifest to /tmp/manifests/${INPUT_SERVICE}${SERVICE_SUFFIX}/${INPUT_ARCH}/manifest.json"
+ cat "/tmp/manifests/${INPUT_SERVICE}${SERVICE_SUFFIX}/${INPUT_ARCH}/manifest.json" | jq '.'
fi
- name: Save Docker image as artifact
if: inputs.publish-image == 'false' && inputs.arch == 'amd64'
shell: bash
+ env:
+ SERVICE: ${{ inputs.service }}
+ ARCH: ${{ inputs.arch }}
+ TYPE: ${{ inputs.type }}
run: |
set -o xtrace
# Get image name from docker-compose-ci.yml
- IMAGE=$(docker compose -f docker-compose-ci.yml config --format json 2>/dev/null | jq -r --arg s "${{ inputs.service }}" '.services[$s].image')
+ IMAGE=$(docker compose -f docker-compose-ci.yml config --format json 2>/dev/null | jq -r --arg s "$SERVICE" '.services[$s].image')
# Create directory for image archives
mkdir -p /tmp/docker-images
# Save the image to a tar file
- docker save "${IMAGE}" -o "/tmp/docker-images/${{ inputs.service }}-${{ inputs.arch }}-${{ inputs.type }}.tar"
+ docker save "${IMAGE}" -o "/tmp/docker-images/${SERVICE}-${ARCH}-${TYPE}.tar"
- echo "Saved image to /tmp/docker-images/${{ inputs.service }}-${{ inputs.arch }}-${{ inputs.type }}.tar"
+ echo "Saved image to /tmp/docker-images/${SERVICE}-${ARCH}-${TYPE}.tar"
ls -lh /tmp/docker-images/
- name: Upload Docker image artifact
if: inputs.publish-image == 'false' && inputs.arch == 'amd64'
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: docker-image-${{ inputs.service }}-${{ inputs.arch }}-${{ inputs.type }}
path: /tmp/docker-images/${{ inputs.service }}-${{ inputs.arch }}-${{ inputs.type }}.tar
retention-days: 1
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: inputs.publish-image == 'true'
with:
name: manifests-${{ inputs.service }}-${{ inputs.arch }}-${{ inputs.type }}
diff --git a/.github/actions/docker-image-size-tracker/action.yml b/.github/actions/docker-image-size-tracker/action.yml
index 5edd90d3bab21..b2ce2339435e4 100644
--- a/.github/actions/docker-image-size-tracker/action.yml
+++ b/.github/actions/docker-image-size-tracker/action.yml
@@ -57,7 +57,7 @@ runs:
using: 'composite'
steps:
- name: Download manifests
- uses: actions/download-artifact@v6
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
pattern: manifests-*
path: /tmp/manifests
diff --git a/.github/actions/meteor-build/action.yml b/.github/actions/meteor-build/action.yml
index 9466a172cebf7..5347013163437 100644
--- a/.github/actions/meteor-build/action.yml
+++ b/.github/actions/meteor-build/action.yml
@@ -1,4 +1,5 @@
name: 'Meteor Build'
+description: 'Build Meteor bundle for production or coverage'
inputs:
type:
@@ -8,36 +9,32 @@ inputs:
reset-meteor:
required: false
description: 'Reset Meteor'
- type: boolean
node-version:
required: true
description: 'Node version'
- type: string
NPM_TOKEN:
required: false
description: 'NPM token'
deno-version:
required: true
description: 'Deno version'
- type: string
source-hash:
required: true
- description: 'Source code hash'
- type: string
+ description: 'RC cache id (e.g. packages-build-cache-key + meteor apps/meteor archive hash from CI)'
runs:
using: composite
steps:
- name: Cache build
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: cache-build
with:
path: /tmp/Rocket.Chat.tar.gz
key: ${{ runner.arch }}-${{ runner.os }}-${{ inputs.type }}-rc-build-${{ inputs.source-hash }}
- name: Set Swap Space
- uses: pierotofy/set-swap-space@master
+ uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
if: steps.cache-build.outputs.cache-hit != 'true'
with:
swap-size-gb: 4
@@ -69,7 +66,7 @@ runs:
# df -h
- name: Cache vite
- uses: actions/cache@v3
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
if: steps.cache-build.outputs.cache-hit != 'true'
with:
path: ./node_modules/.vite
@@ -78,7 +75,7 @@ runs:
vite-local-cache-${{ runner.arch }}-${{ runner.os }}-
- name: Cache meteor local
- uses: actions/cache@v3
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
if: steps.cache-build.outputs.cache-hit != 'true'
with:
path: ./apps/meteor/.meteor/local
@@ -87,7 +84,7 @@ runs:
meteor-local-cache-${{ runner.arch }}-${{ runner.os }}-${{ inputs.type }}-
- name: Cache meteor
- uses: actions/cache@v3
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
if: steps.cache-build.outputs.cache-hit != 'true'
with:
path: ~/.meteor
@@ -128,7 +125,7 @@ runs:
meteor node -v
git version
- - uses: rharkor/caching-for-turbo@v1.8
+ - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13
- name: Reset Meteor
shell: bash
@@ -137,7 +134,7 @@ runs:
run: meteor reset
- name: Restore packages build
- uses: actions/download-artifact@v6
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: packages-build
path: /tmp
@@ -157,7 +154,16 @@ runs:
# check if BABEL_ENV is set to coverage
if [[ $BABEL_ENV == "coverage" ]]; then
echo -e "rocketchat:coverage\n" >> ./apps/meteor/.meteor/packages
- echo "Coverage enabled"
+ # Inject swc-plugin-coverage-instrument into .swcrc for istanbul-compatible coverage
+ node -e "
+ const fs = require('fs');
+ const swcrc = JSON.parse(fs.readFileSync('./apps/meteor/.swcrc', 'utf8'));
+ swcrc.jsc.experimental = swcrc.jsc.experimental || {};
+ swcrc.jsc.experimental.plugins = swcrc.jsc.experimental.plugins || [];
+ swcrc.jsc.experimental.plugins.push(['swc-plugin-coverage-instrument', {}]);
+ fs.writeFileSync('./apps/meteor/.swcrc', JSON.stringify(swcrc, null, 2) + '\n');
+ "
+ echo "Coverage enabled (SWC istanbul plugin injected)"
fi
# Restore original package.json so meteor should not copy devDependencies
@@ -204,7 +210,7 @@ runs:
tar czf /tmp/Rocket.Chat.tar.gz bundle
- name: Store build
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: build-${{ inputs.type }}
path: /tmp/Rocket.Chat.tar.gz
diff --git a/.github/actions/restore-packages/action.yml b/.github/actions/restore-packages/action.yml
index 7e0726d19ef5b..fa9508d092d27 100644
--- a/.github/actions/restore-packages/action.yml
+++ b/.github/actions/restore-packages/action.yml
@@ -5,7 +5,7 @@ runs:
using: 'composite'
steps:
- name: Restore packages build
- uses: actions/download-artifact@v8
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: packages-build
path: /tmp
diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml
index 16eb1317327e5..875fb14bceb39 100644
--- a/.github/actions/setup-node/action.yml
+++ b/.github/actions/setup-node/action.yml
@@ -3,24 +3,21 @@ description: 'Setup NodeJS'
inputs:
node-version:
- required: true
- description: 'Node version'
+ required: false
+ description: 'Node version. If not provided, read from package.json engines.node'
cache-modules:
required: false
description: 'Cache node_modules'
install:
required: false
description: 'Install dependencies'
- type: boolean
type:
required: false
description: 'development or production'
- type: string
default: 'development'
deno-version:
- required: true
- description: 'Deno version'
- type: string
+ required: false
+ description: 'Deno version. If not provided, read from .tool-versions'
NPM_TOKEN:
required: false
description: 'NPM token'
@@ -33,46 +30,65 @@ outputs:
node-version:
description: 'Node version'
value: ${{ steps.node-version.outputs.node-version }}
+ cache-deno-hit:
+ description: 'Exact key hit for Deno cache'
+ value: ${{ steps.cache-deno.outputs.cache-hit }}
runs:
using: composite
steps:
- - name: Cache Node Modules & Deno
+ - name: Resolve tool versions
+ shell: bash
+ run: |
+ NODE_VERSION="${{ inputs.node-version }}"
+ if [ -z "$NODE_VERSION" ]; then
+ NODE_VERSION=$(node -p "require('./package.json').engines.node")
+ fi
+ echo "NODE_VERSION: ${NODE_VERSION}"
+ echo "NODE_VERSION=${NODE_VERSION}" >> $GITHUB_ENV
+
+ DENO_VERSION="${{ inputs.deno-version }}"
+ if [ -z "$DENO_VERSION" ]; then
+ DENO_VERSION=$(awk '$1=="deno"{ print $2 }' .tool-versions)
+ fi
+ echo "DENO_VERSION: ${DENO_VERSION}"
+ echo "DENO_VERSION=${DENO_VERSION}" >> $GITHUB_ENV
+
+ # Turbo local cache: use rharkor/caching-for-turbo in workflows — not stored here to avoid huge redundant keys.
+ - name: Cache Deno (apps-engine)
if: inputs.cache-modules
- id: cache-node-modules
- uses: actions/cache@v3
+ id: cache-deno
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
- # We need to cache node_modules for all workspaces with "hoistingLimits" defined
- path: |
- .turbo/cache
- node_modules
- apps/meteor/node_modules
- apps/meteor/ee/server/services/node_modules
- packages/apps-engine/node_modules
- packages/apps-engine/.deno-cache
- key: node-modules-${{ inputs.type }}-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('package.json') }}-${{ hashFiles('yarn.lock') }}-${{ hashFiles('.yarnrc.yml') }}-deno-v${{ inputs.deno-version }}-${{ hashFiles('packages/apps-engine/deno-runtime/deno.lock') }}-v5
- # key: node-modules-${{ inputs.type }}-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('package.json') }}-${{ hashFiles('yarn.lock') }}-${{ hashFiles('.yarnrc.yml') }}-deno-v${{ inputs.deno-version }}-${{ hashFiles('packages/apps-engine/deno-runtime/deno.lock') }}-v${{ github.run_id }}
- #
- # Could use this command to list all paths to save:
- # find . -name 'node_modules' -prune | grep -v "/\.meteor/" | grep -v "/meteor/packages/"
+ path: packages/apps-engine/.deno-cache
+ key: deno-${{ runner.os }}-${{ runner.arch }}-v${{ env.DENO_VERSION }}-${{ hashFiles('packages/apps-engine/deno-runtime/deno.lock') }}
+ restore-keys: |
+ deno-${{ runner.os }}-${{ runner.arch }}-v${{ env.DENO_VERSION }}-
- - name: Use Node.js ${{ inputs.node-version }}
+ - name: Use Node.js ${{ env.NODE_VERSION }}
id: node-version
- uses: actions/setup-node@v6.0.0
+ uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
- node-version: ${{ inputs.node-version }}
- cache: 'yarn'
+ node-version: ${{ env.NODE_VERSION }}
+ cache: ${{ inputs.cache-modules && 'yarn' || '' }}
- - name: Use Deno ${{ inputs.deno-version }}
- uses: denoland/setup-deno@v1
+ - name: Use Deno ${{ env.DENO_VERSION }}
+ uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4
+ with:
+ deno-version: ${{ env.DENO_VERSION }}
+
+ - name: Cache mongodb-memory-server binary
+ if: inputs.cache-modules
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
- deno-version: ${{ inputs.deno-version }}
+ path: ${{ runner.temp }}/mongodb-memory-server
+ key: mongoms-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
- name: Configure mongodb-memory-server cache
shell: bash
run: |
- CACHE_DIR="${GITHUB_WORKSPACE}/node_modules/.cache/mongodb-memory-server"
+ CACHE_DIR="${{ runner.temp }}/mongodb-memory-server"
mkdir -p "$CACHE_DIR"
echo "MONGOMS_DOWNLOAD_DIR=$CACHE_DIR" >> $GITHUB_ENV
echo "MONGOMS_PREFER_GLOBAL_PATH=false" >> $GITHUB_ENV
diff --git a/.github/actions/setup-playwright/action.yml b/.github/actions/setup-playwright/action.yml
index 1d71e06e2c473..f4fc63834d07d 100644
--- a/.github/actions/setup-playwright/action.yml
+++ b/.github/actions/setup-playwright/action.yml
@@ -1,11 +1,12 @@
name: Setup Playwright
+description: 'Setup Playwright binaries and dependencies'
runs:
using: 'composite'
steps:
- name: Cache Playwright binaries
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: cache-playwright
with:
path: |
diff --git a/.github/actions/update-version-durability/action.yml b/.github/actions/update-version-durability/action.yml
index 84af3f8911e68..07375ac383b87 100644
--- a/.github/actions/update-version-durability/action.yml
+++ b/.github/actions/update-version-durability/action.yml
@@ -5,23 +5,18 @@ inputs:
LTS_VERSIONS:
required: true
description: Comma separated list of LTS versions
- type: string
GH_TOKEN:
required: true
description: GitHub API Token
- type: string
D360_TOKEN:
required: true
description: Document360 API Token
- type: string
D360_ARTICLE_ID:
required: true
description: Document360 Article ID
- type: string
PUBLISH:
required: true
description: Publish Draft
- type: boolean
runs:
using: node20
diff --git a/.github/actions/update-version-durability/package-lock.json b/.github/actions/update-version-durability/package-lock.json
index 5ac99a59a922a..5646f81db24e4 100644
--- a/.github/actions/update-version-durability/package-lock.json
+++ b/.github/actions/update-version-durability/package-lock.json
@@ -194,9 +194,9 @@
}
},
"node_modules/@xmldom/xmldom": {
- "version": "0.8.10",
- "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
- "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
+ "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==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -209,14 +209,14 @@
"license": "MIT"
},
"node_modules/axios": {
- "version": "1.13.5",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
- "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
+ "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
- "proxy-from-env": "^1.1.0"
+ "proxy-from-env": "^2.1.0"
}
},
"node_modules/beauty-html": {
@@ -355,9 +355,9 @@
"license": "MIT"
},
"node_modules/follow-redirects": {
- "version": "1.15.11",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
- "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -518,9 +518,13 @@
}
},
"node_modules/proxy-from-env": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
- "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
},
"node_modules/semver": {
"version": "7.6.2",
diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml
new file mode 100644
index 0000000000000..587d4c2b3c36f
--- /dev/null
+++ b/.github/workflows/actionlint.yml
@@ -0,0 +1,18 @@
+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/auto-close-duplicates.yml b/.github/workflows/auto-close-duplicates.yml
index 6f24ce2028b8a..73f43e3465177 100644
--- a/.github/workflows/auto-close-duplicates.yml
+++ b/.github/workflows/auto-close-duplicates.yml
@@ -1,5 +1,5 @@
name: Auto-close duplicate issues
-description: Auto-closes issues that are duplicates of existing issues
+
on:
schedule:
- cron: '0 9 * * *'
@@ -15,10 +15,10 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Bun
- uses: oven-sh/setup-bun@v2
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
diff --git a/.github/workflows/ci-code-check.yml b/.github/workflows/ci-code-check.yml
index a2eecd5603f3e..916c0ccb6954e 100644
--- a/.github/workflows/ci-code-check.yml
+++ b/.github/workflows/ci-code-check.yml
@@ -26,11 +26,11 @@ jobs:
steps:
- name: Set Swap Space
- uses: pierotofy/set-swap-space@master
+ uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0.0
with:
swap-size-gb: 4
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup NodeJS
uses: ./.github/actions/setup-node
@@ -41,19 +41,27 @@ jobs:
install: true
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- - uses: rharkor/caching-for-turbo@v1.8
+ - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13
- uses: ./.github/actions/restore-packages
- - name: Cache TypeCheck
- uses: actions/cache@v5
+ - name: Restore TypeScript incremental cache
+ id: restore-typecheck
+ if: matrix.check == 'ts'
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ./apps/meteor/tsconfig.typecheck.tsbuildinfo
- key: typecheck-cache-${{ runner.OS }}-${{ hashFiles('yarn.lock') }}-${{ github.event.issue.number }}
+ key: typecheck-cache-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
restore-keys: |
- typecheck-cache-${{ runner.OS }}-${{ hashFiles('yarn.lock') }}
- typecheck-cache-${{ runner.OS }}
- typecheck-cache
+ typecheck-cache-${{ runner.os }}-
+
+ - name: Cache observability (typecheck)
+ if: matrix.check == 'ts'
+ run: |
+ {
+ echo "### TypeScript incremental cache"
+ echo "- **exact hit**: \`${{ steps.restore-typecheck.outputs.cache-hit }}\`"
+ } >> "$GITHUB_STEP_SUMMARY"
- name: Install Meteor
shell: bash
@@ -64,7 +72,7 @@ jobs:
METEOR_TOOL_DIRECTORY=$(dirname "$METEOR_SYMLINK_TARGET")
set -e
LAUNCHER=$HOME/.meteor/$METEOR_TOOL_DIRECTORY/scripts/admin/launch-meteor
- if [ -e $LAUNCHER ]
+ if [ -e "$LAUNCHER" ]
then
echo "Cached Meteor bin found, restoring it"
sudo cp "$LAUNCHER" "/usr/local/bin/meteor"
@@ -79,17 +87,38 @@ jobs:
if: matrix.check == 'ts'
run: yarn turbo run typecheck --concurrency=5
- - name: Cache eslint
- uses: actions/cache@v5
+ - name: Save TypeScript incremental cache
+ if: matrix.check == 'ts' && github.ref == 'refs/heads/develop' && github.event_name == 'push'
+ uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: ./apps/meteor/tsconfig.typecheck.tsbuildinfo
+ key: typecheck-cache-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
+
+ - name: Restore ESLint cache
+ id: restore-eslint
if: matrix.check == 'lint'
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ./apps/meteor/.eslintcache
- key: eslintcache-cache-${{ runner.OS }}-${{ hashFiles('yarn.lock') }}-${{ github.event.issue.number }}
+ key: eslintcache-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
restore-keys: |
- eslintcache-cache-${{ runner.OS }}-${{ hashFiles('yarn.lock') }}
- eslintcache-cache-${{ runner.OS }}
- eslintcache-cache
+ eslintcache-${{ runner.os }}-
+
+ - name: Cache observability (eslint)
+ if: matrix.check == 'lint'
+ run: |
+ {
+ echo "### ESLint cache"
+ echo "- **exact hit**: \`${{ steps.restore-eslint.outputs.cache-hit }}\`"
+ } >> "$GITHUB_STEP_SUMMARY"
- name: Lint
if: matrix.check == 'lint'
run: yarn lint
+
+ - name: Save ESLint cache
+ if: matrix.check == 'lint' && github.ref == 'refs/heads/develop' && github.event_name == 'push'
+ uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: ./apps/meteor/.eslintcache
+ key: eslintcache-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
diff --git a/.github/workflows/ci-deploy-gh-pages.yml b/.github/workflows/ci-deploy-gh-pages.yml
index b8d92d6413158..cd777648842a4 100644
--- a/.github/workflows/ci-deploy-gh-pages.yml
+++ b/.github/workflows/ci-deploy-gh-pages.yml
@@ -11,14 +11,12 @@ jobs:
deploy-preview:
runs-on: ubuntu-24.04-arm
steps:
- - uses: actions/checkout@v6
- - uses: rharkor/caching-for-turbo@v1.8
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13
- name: Setup NodeJS
uses: ./.github/actions/setup-node
with:
- node-version: 22.16.0
- deno-version: 1.43.5
cache-modules: true
install: true
@@ -32,7 +30,7 @@ jobs:
mv ${{ github.ref_name }} .preview
- name: Deploy
- uses: peaceiris/actions-gh-pages@v4
+ uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.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 99395082a9fbc..8917f2e90325c 100644
--- a/.github/workflows/ci-test-e2e.yml
+++ b/.github/workflows/ci-test-e2e.yml
@@ -59,6 +59,8 @@ on:
required: false
REPORTER_JIRA_ROCKETCHAT_API_KEY:
required: false
+ NPM_TOKEN:
+ required: false
env:
MONGO_URL: mongodb://localhost:27017/rocketchat?replicaSet=rs0&directConnection=true
@@ -89,7 +91,7 @@ jobs:
steps:
- name: Collect Workflow Telemetry
if: inputs.type == 'perf'
- uses: catchpoint/workflow-telemetry-action@v2
+ uses: catchpoint/workflow-telemetry-action@94c3c3d9567a0205de6da68a76c428ce4e769af1 # v2.0.0
with:
theme: dark
job_summary: true
@@ -106,13 +108,13 @@ jobs:
- name: Login to GitHub Container Registry
if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') && github.actor != 'dependabot[bot]'
- uses: docker/login-action@v3
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ secrets.CR_USER }}
password: ${{ secrets.CR_PAT }}
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup NodeJS
uses: ./.github/actions/setup-node
@@ -123,13 +125,13 @@ jobs:
install: true
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- - uses: rharkor/caching-for-turbo@v1.8
+ - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13
- uses: ./.github/actions/restore-packages
# Download Docker images from build artifacts
- name: Download Docker images
- uses: actions/download-artifact@v8
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
if: github.event.pull_request.head.repo.full_name != github.repository && github.event_name != 'release' && github.ref != 'refs/heads/develop'
with:
pattern: ${{ inputs.release == 'ce' && 'docker-image-rocketchat-amd64-coverage' || 'docker-image-*-amd64-coverage' }}
@@ -157,7 +159,7 @@ jobs:
- name: Set DEBUG_LOG_LEVEL (debug enabled)
if: runner.debug == '1'
- run: echo "DEBUG_LOG_LEVEL=2" >> $GITHUB_ENV
+ run: echo "DEBUG_LOG_LEVEL=2" >> "$GITHUB_ENV"
- name: Start httpbin container and wait for it to be ready
if: inputs.type == 'api' || inputs.type == 'api-livechat'
@@ -168,8 +170,8 @@ jobs:
run: |
set -o xtrace
- mkdir -p $COVERAGE_DIR
- chmod 777 $COVERAGE_DIR
+ mkdir -p "$COVERAGE_DIR"
+ chmod 777 "$COVERAGE_DIR"
- name: Start containers for CE
if: inputs.release == 'ce'
@@ -182,6 +184,7 @@ jobs:
env:
ENTERPRISE_LICENSE: ${{ inputs.enterprise-license }}
TRANSPORTER: ${{ inputs.transporter }}
+ COMPOSE_PROFILES: ${{ inputs.type == 'api' && 'api' || '' }}
run: |
DEBUG_LOG_LEVEL=${DEBUG_LOG_LEVEL:-0} docker compose -f docker-compose-ci.yml up -d --wait
@@ -193,7 +196,7 @@ jobs:
run: |
docker ps
- until echo "$(docker compose -f docker-compose-ci.yml logs ddp-streamer-service)" | grep -q "NetworkBroker started successfully"; do
+ until docker compose -f docker-compose-ci.yml logs ddp-streamer-service | grep -q "NetworkBroker started successfully"; do
echo "Waiting 'ddp-streamer' to start up"
((c++)) && ((c==10)) && docker compose -f docker-compose-ci.yml logs ddp-streamer-service && exit 1
sleep 10
@@ -211,12 +214,12 @@ jobs:
run: |
set -o xtrace
- npm run testapi
+ npm run testapi || s=$?
docker compose -f ../../docker-compose-ci.yml stop
- ls -la $COVERAGE_DIR
- exit $s
+ ls -la "$COVERAGE_DIR"
+ exit "${s:-0}"
- name: E2E Test API (Livechat)
if: inputs.type == 'api-livechat'
@@ -227,12 +230,12 @@ jobs:
run: |
set -o xtrace
- npm run testapi:livechat
+ npm run testapi:livechat || s=$?
docker compose -f ../../docker-compose-ci.yml stop
- ls -la $COVERAGE_DIR
- exit $s
+ ls -la "$COVERAGE_DIR"
+ exit "${s:-0}"
- name: E2E Test UI (${{ matrix.shard }}/${{ inputs.total-shard }})
if: inputs.type == 'ui'
@@ -267,12 +270,12 @@ jobs:
if: inputs.type == 'ui' && inputs.coverage == matrix.mongodb-version
working-directory: ./apps/meteor
run: |
- npx nyc merge .nyc_output ${COVERAGE_DIR}/${COVERAGE_FILE_NAME}
- ls -la $COVERAGE_DIR || true
+ npx nyc merge .nyc_output "${COVERAGE_DIR}/${COVERAGE_FILE_NAME}"
+ ls -la "$COVERAGE_DIR" || true
- name: Store playwright test trace
if: inputs.type == 'ui' && always()
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-test-trace-${{ inputs.release }}-${{ matrix.mongodb-version }}-${{ matrix.shard }}
path: ./apps/meteor/tests/e2e/.playwright*
@@ -288,7 +291,7 @@ jobs:
- name: Store coverage
if: inputs.coverage == matrix.mongodb-version
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-${{ inputs.type }}-${{ matrix.shard }}
path: /tmp/coverage
diff --git a/.github/workflows/ci-test-storybook.yml b/.github/workflows/ci-test-storybook.yml
index c1adfc760e170..91a39aecc3c5b 100644
--- a/.github/workflows/ci-test-storybook.yml
+++ b/.github/workflows/ci-test-storybook.yml
@@ -13,6 +13,8 @@ on:
secrets:
CODECOV_TOKEN:
required: true
+ NPM_TOKEN:
+ required: false
env:
TOOL_NODE_FLAGS: ${{ vars.TOOL_NODE_FLAGS }}
@@ -24,7 +26,7 @@ jobs:
name: Test Storybook
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup NodeJS
uses: ./.github/actions/setup-node
@@ -35,7 +37,7 @@ jobs:
install: true
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- - uses: rharkor/caching-for-turbo@v1.8
+ - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13
- uses: ./.github/actions/restore-packages
@@ -46,7 +48,7 @@ jobs:
env:
STORYBOOK_DISABLE_TELEMETRY: 1
- - uses: codecov/codecov-action@v5
+ - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
flags: unit
verbose: true
diff --git a/.github/workflows/ci-test-unit.yml b/.github/workflows/ci-test-unit.yml
index 56184ff08aff2..f7c902bcb553d 100644
--- a/.github/workflows/ci-test-unit.yml
+++ b/.github/workflows/ci-test-unit.yml
@@ -15,6 +15,8 @@ on:
secrets:
CODECOV_TOKEN:
required: false
+ NPM_TOKEN:
+ required: false
env:
MONGO_URL: mongodb://localhost:27017/rocketchat?replicaSet=rs0&directConnection=true
@@ -28,7 +30,7 @@ jobs:
name: Unit Tests
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup NodeJS
uses: ./.github/actions/setup-node
@@ -39,14 +41,14 @@ jobs:
install: true
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- - uses: rharkor/caching-for-turbo@v1.8
+ - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13
- uses: ./.github/actions/restore-packages
- name: Unit Test
run: yarn testunit --concurrency=1
- - uses: codecov/codecov-action@v5
+ - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
flags: unit
verbose: true
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d590223204a86..56a124952b9c8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,7 +4,8 @@ on:
release:
types: [published]
pull_request:
- branches: '**'
+ branches:
+ - '**'
paths-ignore:
- '**.md'
merge_group:
@@ -33,6 +34,8 @@ jobs:
node-version: ${{ steps.var.outputs.node-version }}
deno-version: ${{ steps.var.outputs.deno-version }}
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 }}
# 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=
@@ -45,9 +48,9 @@ jobs:
echo "GITHUB_HEAD_REF: $GITHUB_HEAD_REF"
echo "GITHUB_BASE_REF: $GITHUB_BASE_REF"
echo "github.event_name: ${{ github.event_name }}"
- cat $GITHUB_EVENT_PATH
+ cat "$GITHUB_EVENT_PATH"
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# with:
# sparse-checkout: |
# package.json
@@ -72,24 +75,32 @@ jobs:
# Uncomment the following line to include the run ID in the hash and disable caching between runs
# SOURCE_HASH=$(sha256sum /tmp/RocketChat-source.tar | awk '{ print $1 }')-${{ github.run_id }}
- echo hash=${SOURCE_HASH}
+ echo "hash=${SOURCE_HASH}"
- echo hash=${SOURCE_HASH} >> $GITHUB_OUTPUT
+ echo "hash=${SOURCE_HASH}" >> "$GITHUB_OUTPUT"
- id: var
run: |
LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]")
echo "LOWERCASE_REPOSITORY: ${LOWERCASE_REPOSITORY}"
- echo "lowercase-repo=${LOWERCASE_REPOSITORY}" >> $GITHUB_OUTPUT
+ echo "lowercase-repo=${LOWERCASE_REPOSITORY}" >> "$GITHUB_OUTPUT"
NODE_VERSION=$(node -p "require('./package.json').engines.node")
echo "NODE_VERSION: ${NODE_VERSION}"
- echo "node-version=${NODE_VERSION}" >> $GITHUB_OUTPUT
+ echo "node-version=${NODE_VERSION}" >> "$GITHUB_OUTPUT"
DENO_VERSION=$(awk '$1=="deno"{ print $2 }' .tool-versions)
echo "DENO_VERSION: ${DENO_VERSION}"
- echo "deno-version=${DENO_VERSION}" >> $GITHUB_OUTPUT
+ echo "deno-version=${DENO_VERSION}" >> "$GITHUB_OUTPUT"
+
+ - id: ci-cache-keys
+ run: |
+ set -euo pipefail
+ PACKAGES_HASH=$(cat yarn.lock .yarnrc.yml package.json turbo.json | sha256sum | awk '{print $1}')
+ echo "packages-build-cache-key=${PACKAGES_HASH}" >> "$GITHUB_OUTPUT"
+ METEOR_HASH=$(git archive HEAD apps/meteor | sha256sum | awk '{print $1}')
+ echo "meteor-rc-cache-key=${METEOR_HASH}" >> "$GITHUB_OUTPUT"
- id: by-tag
run: |
@@ -99,7 +110,7 @@ jobs:
RELEASE="release-candidate"
fi
echo "RELEASE: ${RELEASE}"
- echo "release=${RELEASE}" >> $GITHUB_OUTPUT
+ echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
- id: latest
run: |
@@ -108,7 +119,7 @@ jobs:
awk -F/ '$NF !~ /rc|beta/ { print $NF; exit }'
)"
echo "LATEST_RELEASE: ${LATEST_RELEASE}"
- echo "latest-release=${LATEST_RELEASE}" >> $GITHUB_OUTPUT
+ echo "latest-release=${LATEST_RELEASE}" >> "$GITHUB_OUTPUT"
- id: docker
run: |
@@ -120,14 +131,14 @@ jobs:
# Docker tags cannot contain '/'; merge queue refs do (e.g. gh-readonly-queue/develop/pr-123-sha)
DOCKER_TAG="${DOCKER_TAG//\//-}"
echo "DOCKER_TAG: ${DOCKER_TAG}"
- echo "gh-docker-tag=${DOCKER_TAG}" >> $GITHUB_OUTPUT
+ echo "gh-docker-tag=${DOCKER_TAG}" >> "$GITHUB_OUTPUT"
notify-draft-services:
name: 🚀 Notify external services - draft
runs-on: ubuntu-24.04-arm
needs: [release-versions]
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: |
package.json
@@ -170,26 +181,32 @@ jobs:
runs-on: ubuntu-24.04-arm
steps:
- name: Cache build
- uses: actions/cache@v5
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: packages-cache-build
with:
path: |
/tmp/RocketChat-packages-build.tar.gz
key: ${{ runner.arch }}-${{ runner.os }}-packages-build-${{ needs.release-versions.outputs.source-hash }}
- - name: Debug cache-hit
- run: echo "cache-hit=${{ steps.packages-cache-build.outputs.cache-hit }}"
+ - name: Cache observability (packages-build)
+ run: |
+ {
+ echo "### 📦 packages-build cache"
+ echo "- **hit**: \`${{ steps.packages-cache-build.outputs.cache-hit }}\`"
+ echo "- **key**: \`${{ needs.release-versions.outputs.packages-build-cache-key }}\`"
+ } >> "$GITHUB_STEP_SUMMARY"
- name: Set Swap Space
- uses: pierotofy/set-swap-space@master
+ uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
if: steps.packages-cache-build.outputs.cache-hit != 'true'
with:
swap-size-gb: 4
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
if: steps.packages-cache-build.outputs.cache-hit != 'true'
- name: Setup NodeJS
+ id: setup-node-packages
uses: ./.github/actions/setup-node
if: steps.packages-cache-build.outputs.cache-hit != 'true'
with:
@@ -200,8 +217,17 @@ jobs:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
HARDENED_MODE: '1'
+ - name: Cache observability (node_modules)
+ if: steps.packages-cache-build.outputs.cache-hit != 'true'
+ run: |
+ {
+ echo "### 🟢 node_modules cache"
+ echo "- **deno cache hit**: \`${{ steps.setup-node-packages.outputs.cache-deno-hit }}\`"
+ echo "- **node_modules size**: \`$(du -sh node_modules 2>/dev/null | cut -f1 || echo 'n/a')\`"
+ } >> "$GITHUB_STEP_SUMMARY"
+
- name: Cache vite
- uses: actions/cache@v5
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
if: steps.packages-cache-build.outputs.cache-hit != 'true'
with:
path: ./node_modules/.vite
@@ -209,7 +235,7 @@ jobs:
restore-keys: |
vite-local-cache-${{ runner.arch }}-${{ runner.os }}-
- - uses: rharkor/caching-for-turbo@v1.8
+ - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13
if: steps.packages-cache-build.outputs.cache-hit != 'true'
- name: Build Rocket.Chat Packages
@@ -219,11 +245,10 @@ jobs:
- name: Archive packages build output
if: steps.packages-cache-build.outputs.cache-hit != 'true'
run: |
- tar -czf /tmp/RocketChat-packages-build.tar.gz \
- $(git ls-files -oi --exclude-standard -- ':(exclude)node_modules/*' ':(exclude)**/node_modules/*' ':(exclude)**/.meteor/*' ':(exclude)**/.turbo/*' ':(exclude).turbo/*' ':(exclude)**/.yarn/*' ':(exclude).yarn/*' ':(exclude).git/*')
+ git ls-files -oi --exclude-standard -- ':(exclude)node_modules/*' ':(exclude)**/node_modules/*' ':(exclude)**/.meteor/*' ':(exclude)**/.turbo/*' ':(exclude).turbo/*' ':(exclude)**/.yarn/*' ':(exclude).yarn/*' ':(exclude).git/*' | tar -czf /tmp/RocketChat-packages-build.tar.gz -T -
- name: Upload packages build artifact
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: packages-build
path: /tmp/RocketChat-packages-build.tar.gz
@@ -231,7 +256,7 @@ jobs:
- name: Store turbo build
if: steps.packages-cache-build.outputs.cache-hit != 'true'
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: turbo-build
path: .turbo/cache
@@ -253,13 +278,13 @@ jobs:
- type: ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'production' || '' }}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/meteor-build
with:
node-version: ${{ needs.release-versions.outputs.node-version }}
deno-version: ${{ needs.release-versions.outputs.deno-version }}
- source-hash: ${{ needs.release-versions.outputs.source-hash }}
+ source-hash: ${{ needs.release-versions.outputs.packages-build-cache-key }}-${{ needs.release-versions.outputs.meteor-rc-cache-key }}
type: ${{ matrix.type }}
build-gh-docker:
@@ -294,7 +319,7 @@ jobs:
type: coverage
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/restore-packages
@@ -369,7 +394,7 @@ jobs:
LOWERCASE_REPOSITORY: ${{ needs.release-versions.outputs.lowercase-repo }}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
if: github.actor != 'dependabot[bot]' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop')
with:
sparse-checkout: |
@@ -379,7 +404,7 @@ jobs:
- name: Login to GitHub Container Registry
if: github.actor != 'dependabot[bot]' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop')
- uses: docker/login-action@v3
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ secrets.CR_USER }}
@@ -387,7 +412,7 @@ jobs:
- name: Download manifests
if: github.actor != 'dependabot[bot]' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop')
- uses: actions/download-artifact@v8
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: manifests-*
path: /tmp/manifests
@@ -405,12 +430,13 @@ jobs:
echo "Creating manifest for $service"
# Extract digests from manifest.json files
+ # shellcheck disable=SC2016
mapfile -t refs < <(
find "$service_dir" -type f -name 'manifest.json' -print0 \
| xargs -0 -I{} jq -r '.Descriptor.digest as $digest | .Ref | split("@")[0] + "@" + $digest' {}
)
- echo "Digest for ${service}: ${refs[@]}"
+ echo "Digest for ${service}: ${refs[*]}"
# Get image name from docker-compose-ci.yml since rocketchat image is different from service name (rocket.chat)
if [ "$service" == "rocketchat-cov" ]; then
@@ -419,14 +445,14 @@ jobs:
IMAGE=$(docker compose -f docker-compose-ci.yml config --format json 2>/dev/null | jq -r --arg s "$service" '.services[$s].image')
fi
- echo $IMAGE
+ echo "$IMAGE"
docker buildx imagetools create \
--debug \
--annotation "manifest-descriptor:org.opencontainers.image.description=Build run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
--tag "${IMAGE}" \
--tag "${IMAGE}-gha-run-${{ github.run_id }}" \
- ${refs[@]}
+ "${refs[@]}"
done
track-image-sizes:
@@ -439,7 +465,7 @@ jobs:
contents: write
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Track Docker image sizes
uses: ./.github/actions/docker-image-size-tracker
@@ -500,6 +526,7 @@ jobs:
secrets:
CR_USER: ${{ secrets.CR_USER }}
CR_PAT: ${{ secrets.CR_PAT }}
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
test-api-livechat:
name: 🔨 Test API Livechat (CE)
@@ -516,6 +543,7 @@ jobs:
secrets:
CR_USER: ${{ secrets.CR_USER }}
CR_PAT: ${{ secrets.CR_PAT }}
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
test-ui:
name: 🔨 Test UI (CE)
@@ -537,6 +565,7 @@ jobs:
secrets:
CR_USER: ${{ secrets.CR_USER }}
CR_PAT: ${{ secrets.CR_PAT }}
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
QASE_API_TOKEN: ${{ secrets.QASE_API_TOKEN }}
REPORTER_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_ROCKETCHAT_API_KEY }}
REPORTER_ROCKETCHAT_URL: ${{ secrets.REPORTER_ROCKETCHAT_URL }}
@@ -561,6 +590,7 @@ jobs:
secrets:
CR_USER: ${{ secrets.CR_USER }}
CR_PAT: ${{ secrets.CR_PAT }}
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
test-api-livechat-ee:
name: 🔨 Test API Livechat (EE)
@@ -581,6 +611,7 @@ jobs:
secrets:
CR_USER: ${{ secrets.CR_USER }}
CR_PAT: ${{ secrets.CR_PAT }}
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
test-ui-ee:
name: 🔨 Test UI (EE)
@@ -604,6 +635,7 @@ jobs:
secrets:
CR_USER: ${{ secrets.CR_USER }}
CR_PAT: ${{ secrets.CR_PAT }}
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
QASE_API_TOKEN: ${{ secrets.QASE_API_TOKEN }}
REPORTER_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_ROCKETCHAT_API_KEY }}
REPORTER_ROCKETCHAT_URL: ${{ secrets.REPORTER_ROCKETCHAT_URL }}
@@ -616,7 +648,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup NodeJS
uses: ./.github/actions/setup-node
@@ -626,10 +658,10 @@ jobs:
cache-modules: true
install: true
- - uses: rharkor/caching-for-turbo@v1.8
+ - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13
- name: Restore turbo build
- uses: actions/download-artifact@v8
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
name: turbo-build
@@ -640,7 +672,7 @@ jobs:
- name: Login to GitHub Container Registry
if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') && github.actor != 'dependabot[bot]'
- uses: docker/login-action@v3
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ secrets.CR_USER }}
@@ -653,7 +685,7 @@ jobs:
# Download Docker images from build artifacts
- name: Download Docker images
- uses: actions/download-artifact@v8
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
if: github.event.pull_request.head.repo.full_name != github.repository && github.event_name != 'release' && github.ref != 'refs/heads/develop'
with:
pattern: 'docker-image-rocketchat-amd64-coverage'
@@ -711,15 +743,15 @@ jobs:
needs: [release-versions, test-api-ee, test-api-livechat-ee, test-ui-ee]
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Use Node.js
- uses: actions/setup-node@v6.2.0
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ needs.release-versions.outputs.node-version }}
- name: Restore coverage folder
- uses: actions/download-artifact@v8
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: coverage-*
path: /tmp/coverage
@@ -733,14 +765,14 @@ jobs:
npx nyc report --reporter=lcovonly --report-dir=/tmp/coverage_report/ui --temp-dir=/tmp/coverage/ui
- name: Store coverage-reports
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: reports-coverage
path: /tmp/coverage_report
include-hidden-files: true
- name: Report API coverage
- uses: codecov/codecov-action@v5
+ uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
files: /tmp/coverage_report/api/lcov.info
working-directory: .
@@ -749,7 +781,7 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Report UI coverage
- uses: codecov/codecov-action@v5
+ uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
files: /tmp/coverage_report/ui/lcov.info
working-directory: .
@@ -810,7 +842,7 @@ jobs:
needs: [build-gh-docker-publish, release-versions]
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: |
package.json
@@ -818,7 +850,7 @@ jobs:
ref: ${{ github.ref }}
- name: Restore build
- uses: actions/download-artifact@v8
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: build-production
path: /tmp/build
@@ -832,30 +864,28 @@ jobs:
run: |
REPO_VERSION=$(node -p "require('./package.json').version")
- if [[ '${{ github.event_name }}' = 'release' ]]; then
- GIT_TAG="${GITHUB_REF#*tags/}"
+ if [[ "${{ github.event_name }}" = 'release' ]]; then
ARTIFACT_NAME="${REPO_VERSION}"
else
- GIT_TAG=""
ARTIFACT_NAME="${REPO_VERSION}.$GITHUB_SHA"
fi;
ROCKET_DEPLOY_DIR="/tmp/deploy"
- FILENAME="$ROCKET_DEPLOY_DIR/rocket.chat-$ARTIFACT_NAME.tgz";
+ FILENAME="$ROCKET_DEPLOY_DIR/rocket.chat-$ARTIFACT_NAME.tgz"
aws s3 cp s3://rocketchat/sign.key.gpg .github/sign.key.gpg
- mkdir -p $ROCKET_DEPLOY_DIR
+ mkdir -p "$ROCKET_DEPLOY_DIR"
cp .github/sign.key.gpg /tmp
- gpg --yes --batch --passphrase=$GPG_PASSWORD /tmp/sign.key.gpg
+ gpg --yes --batch --passphrase="$GPG_PASSWORD" /tmp/sign.key.gpg
gpg --allow-secret-key-import --import /tmp/sign.key
rm /tmp/sign.key
ln -s /tmp/build/Rocket.Chat.tar.gz "$FILENAME"
gpg --armor --detach-sign "$FILENAME"
- aws s3 cp $ROCKET_DEPLOY_DIR/ s3://download.rocket.chat/build/ --recursive
+ aws s3 cp "$ROCKET_DEPLOY_DIR/" s3://download.rocket.chat/build/ --recursive
docker-image-publish:
name: 🚀 Publish Docker Images (DockerHub)
@@ -867,7 +897,7 @@ jobs:
LOWERCASE_REPOSITORY: ${{ needs.release-versions.outputs.lowercase-repo }}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: |
docker-compose-ci.yml
@@ -875,13 +905,13 @@ jobs:
ref: ${{ github.ref }}
- name: Login to DockerHub
- uses: docker/login-action@v3
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
- name: Login to GitHub Container Registry
- uses: docker/login-action@v3
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ secrets.CR_USER }}
@@ -889,7 +919,7 @@ jobs:
- name: Download manifests
if: github.actor != 'dependabot[bot]' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop')
- uses: actions/download-artifact@v8
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: manifests-*
path: /tmp/manifests
@@ -918,8 +948,8 @@ jobs:
echo "RELEASE: $RELEASE"
- if [[ $RELEASE == 'latest' ]]; then
- if [[ '${{ needs.release-versions.outputs.latest-release }}' == $GITHUB_REF_NAME ]]; then
+ if [[ "$RELEASE" == 'latest' ]]; then
+ if [[ '${{ needs.release-versions.outputs.latest-release }}' == "$GITHUB_REF_NAME" ]]; then
TAGS+=("$RELEASE")
fi
else
@@ -981,7 +1011,7 @@ jobs:
- docker-image-publish
- release-versions
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: |
package.json
@@ -1018,7 +1048,7 @@ jobs:
https://releases.rocket.chat/update
# Makes build fail if the release isn't there
- curl --fail https://releases.rocket.chat/$RC_VERSION/info
+ curl --fail "https://releases.rocket.chat/$RC_VERSION/info"
docs-update:
name: Update Version Durability
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 3fdfebd584f75..0671ab9084bd1 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -28,7 +28,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@v4
+ uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
# 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@v4
+ uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
# ℹ️ 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@v4
+ uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
diff --git a/.github/workflows/dedupe-issues.yml b/.github/workflows/dedupe-issues.yml
index 3c44d475ef671..fe4565001a6e5 100644
--- a/.github/workflows/dedupe-issues.yml
+++ b/.github/workflows/dedupe-issues.yml
@@ -1,5 +1,5 @@
name: Rocket.Chat Issue Dedupe
-description: Automatically dedupe GitHub issues using AI
+
on:
issues:
types: [opened]
@@ -20,14 +20,14 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run Claude Code slash command
- uses: anthropics/claude-code-base-action@beta
+ uses: anthropics/claude-code-base-action@e8132bc5e637a42c27763fc757faa37e1ee43b34 # v0.0.63beta
with:
prompt: '/dedupe ${{ github.repository }}/issues/${{ github.event.issue.number || inputs.issue_number }}'
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
- claude_args: '--model claude-sonnet-4-5-20250929'
+ model: 'claude-sonnet-4-5-20250929'
claude_env: |
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/new-release.yml b/.github/workflows/new-release.yml
index 49266b3dd5042..1c19f6bf6b018 100644
--- a/.github/workflows/new-release.yml
+++ b/.github/workflows/new-release.yml
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.inputs.base-ref }}
fetch-depth: 0
@@ -34,13 +34,11 @@ jobs:
- name: Setup NodeJS
uses: ./.github/actions/setup-node
with:
- node-version: 22.16.0
- deno-version: 1.43.5
cache-modules: true
install: true
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- - uses: rharkor/caching-for-turbo@v1.8
+ - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13
- name: Build packages
run: yarn build
diff --git a/.github/workflows/pr-update-description.yml b/.github/workflows/pr-update-description.yml
index eb1ecbf6b9497..af1c0902a99c1 100644
--- a/.github/workflows/pr-update-description.yml
+++ b/.github/workflows/pr-update-description.yml
@@ -13,7 +13,7 @@ jobs:
if: startsWith(github.head_ref, 'release-')
steps:
- name: Checkout Repo
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
token: ${{ secrets.CI_PAT }}
@@ -21,13 +21,11 @@ jobs:
- name: Setup NodeJS
uses: ./.github/actions/setup-node
with:
- node-version: 22.16.0
- deno-version: 1.43.5
cache-modules: true
install: true
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- - uses: rharkor/caching-for-turbo@v1.8
+ - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13
- name: Build packages
run: yarn build
diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml
index 0465375a1727e..407891b1897b2 100644
--- a/.github/workflows/publish-release.yml
+++ b/.github/workflows/publish-release.yml
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout Repo
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
token: ${{ secrets.CI_PAT }}
@@ -24,13 +24,11 @@ jobs:
- name: Setup NodeJS
uses: ./.github/actions/setup-node
with:
- node-version: 22.16.0
- deno-version: 1.43.5
cache-modules: true
install: true
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- - uses: rharkor/caching-for-turbo@v1.8
+ - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13
- name: Build packages
run: yarn build
diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml
index b8add61628c5b..8647d15591f52 100644
--- a/.github/workflows/release-candidate.yml
+++ b/.github/workflows/release-candidate.yml
@@ -6,7 +6,7 @@ jobs:
new-release:
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.ref_name }}
fetch-depth: 0
@@ -15,13 +15,11 @@ jobs:
- name: Setup NodeJS
uses: ./.github/actions/setup-node
with:
- node-version: 22.16.0
- deno-version: 1.43.5
cache-modules: true
install: true
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- - uses: rharkor/caching-for-turbo@v1.8
+ - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13
- name: Build packages
run: yarn build
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 552b8b5b6917d..db848f3c5ac26 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -9,7 +9,7 @@ jobs:
permissions:
issues: write
steps:
- - uses: actions/stale@v10
+ - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
days-before-issue-stale: 14
days-before-issue-close: 14
diff --git a/.github/workflows/todo.yml b/.github/workflows/todo.yml
index cee1e97a864e0..e1b72eeed170b 100644
--- a/.github/workflows/todo.yml
+++ b/.github/workflows/todo.yml
@@ -33,10 +33,10 @@ jobs:
timeout-minutes: 10
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Bun
- uses: oven-sh/setup-bun@v2
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
diff --git a/.github/workflows/update-version-durability.yml b/.github/workflows/update-version-durability.yml
index 46f9fbb0c3b0e..6b4190af35f03 100644
--- a/.github/workflows/update-version-durability.yml
+++ b/.github/workflows/update-version-durability.yml
@@ -18,10 +18,10 @@ jobs:
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Use Node.js
- uses: actions/setup-node@v6.2.0
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.16.0
diff --git a/.tool-versions b/.tool-versions
index cd5eb1b4aaa53..1ba63f17c22a7 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1 +1 @@
-deno 1.43.5
+deno 2.3.1
diff --git a/.yarn/patches/@react-aria-i18n-npm-3.12.5-435edff786.patch b/.yarn/patches/@react-aria-i18n-npm-3.12.5-435edff786.patch
new file mode 100644
index 0000000000000..476a2468aaab0
--- /dev/null
+++ b/.yarn/patches/@react-aria-i18n-npm-3.12.5-435edff786.patch
@@ -0,0 +1,144 @@
+diff --git a/dist/import.mjs b/dist/import.mjs
+index 668b87f9fbf77162f4baa4b02fe3586c3a2c0b26..6b6dc85b39196597504df1a287495a24ce3c0831 100644
+--- a/dist/import.mjs
++++ b/dist/import.mjs
+@@ -1,31 +1,7 @@
+-import {I18nProvider as $18f2051aff69b9bf$export$a54013f0d02a8f82, useLocale as $18f2051aff69b9bf$export$43bb16f9c6d9e3f7} from "./context.mjs";
+-import {useMessageFormatter as $321bc95feeb923dd$export$ec23bf898b1eed85} from "./useMessageFormatter.mjs";
+-import {useLocalizedStringDictionary as $fca6afa0e843324b$export$87b761675e8eaa10, useLocalizedStringFormatter as $fca6afa0e843324b$export$f12b703ca79dfbb1} from "./useLocalizedStringFormatter.mjs";
+-import {useListFormatter as $33bf17300c498528$export$a2f47a3d2973640} from "./useListFormatter.mjs";
+-import {useDateFormatter as $896ba0a80a8f4d36$export$85fd5fdf27bacc79} from "./useDateFormatter.mjs";
+-import {useNumberFormatter as $a916eb452884faea$export$b7a616150fdb9f44} from "./useNumberFormatter.mjs";
+-import {useCollator as $325a3faab7a68acd$export$a16aca283550c30d} from "./useCollator.mjs";
+-import {useFilter as $bb77f239b46e8c72$export$3274cf84b703fff} from "./useFilter.mjs";
+-
+-/*
+- * Copyright 2020 Adobe. All rights reserved.
+- * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+- * you may not use this file except in compliance with the License. You may obtain a copy
+- * of the License at http://www.apache.org/licenses/LICENSE-2.0
+- *
+- * Unless required by applicable law or agreed to in writing, software distributed under
+- * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+- * OF ANY KIND, either express or implied. See the License for the specific language
+- * governing permissions and limitations under the License.
+- */
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-export {$18f2051aff69b9bf$export$a54013f0d02a8f82 as I18nProvider, $18f2051aff69b9bf$export$43bb16f9c6d9e3f7 as useLocale, $321bc95feeb923dd$export$ec23bf898b1eed85 as useMessageFormatter, $fca6afa0e843324b$export$f12b703ca79dfbb1 as useLocalizedStringFormatter, $fca6afa0e843324b$export$87b761675e8eaa10 as useLocalizedStringDictionary, $33bf17300c498528$export$a2f47a3d2973640 as useListFormatter, $896ba0a80a8f4d36$export$85fd5fdf27bacc79 as useDateFormatter, $a916eb452884faea$export$b7a616150fdb9f44 as useNumberFormatter, $325a3faab7a68acd$export$a16aca283550c30d as useCollator, $bb77f239b46e8c72$export$3274cf84b703fff as useFilter};
+-//# sourceMappingURL=module.js.map
++import {I18nProvider as $18f2051aff69b9bf$export$a54013f0d02a8f82, useLocale as $18f2051aff69b9bf$export$43bb16f9c6d9e3f7} from "./context.module.js";
++import {useLocalizedStringDictionary as $fca6afa0e843324b$export$87b761675e8eaa10, useLocalizedStringFormatter as $fca6afa0e843324b$export$f12b703ca79dfbb1} from "./useLocalizedStringFormatter.module.js";
++import {useNumberFormatter as $a916eb452884faea$export$b7a616150fdb9f44} from "./useNumberFormatter.module.js";
++import {useCollator as $325a3faab7a68acd$export$a16aca283550c30d} from "./useCollator.module.js";
++import {useFilter as $bb77f239b46e8c72$export$3274cf84b703fff} from "./useFilter.module.js";
++// Slim barrel: removed useDateFormatter (@internationalized/date ~40KB) and useMessageFormatter (@formatjs ~62KB)
++export {$18f2051aff69b9bf$export$a54013f0d02a8f82 as I18nProvider, $18f2051aff69b9bf$export$43bb16f9c6d9e3f7 as useLocale, $fca6afa0e843324b$export$f12b703ca79dfbb1 as useLocalizedStringFormatter, $fca6afa0e843324b$export$87b761675e8eaa10 as useLocalizedStringDictionary, $a916eb452884faea$export$b7a616150fdb9f44 as useNumberFormatter, $325a3faab7a68acd$export$a16aca283550c30d as useCollator, $bb77f239b46e8c72$export$3274cf84b703fff as useFilter};
+diff --git a/dist/main.js b/dist/main.js
+index 62b1f5e2054df93a1f3e851f191c42441cf33699..820e10e6bf275b396a46e26a8e80f46b59bffb5c 100644
+--- a/dist/main.js
++++ b/dist/main.js
+@@ -1,45 +1,13 @@
+ var $47fa5ec5ff482271$exports = require("./context.main.js");
+-var $c376aa482226bf60$exports = require("./useMessageFormatter.main.js");
+-var $fc53663969a3d00a$exports = require("./useLocalizedStringFormatter.main.js");
+-var $cb6a3e7d490e97a4$exports = require("./useListFormatter.main.js");
+-var $b80c530ff2e20243$exports = require("./useDateFormatter.main.js");
+-var $fea93c5b7c90d9f4$exports = require("./useNumberFormatter.main.js");
+-var $27a5ce66022270ad$exports = require("./useCollator.main.js");
+-var $832d079b867c7223$exports = require("./useFilter.main.js");
+-
+-
+-function $parcel$export(e, n, v, s) {
+- Object.defineProperty(e, n, {get: v, set: s, enumerable: true, configurable: true});
+-}
+-
+-$parcel$export(module.exports, "I18nProvider", () => $47fa5ec5ff482271$exports.I18nProvider);
+-$parcel$export(module.exports, "useLocale", () => $47fa5ec5ff482271$exports.useLocale);
+-$parcel$export(module.exports, "useMessageFormatter", () => $c376aa482226bf60$exports.useMessageFormatter);
+-$parcel$export(module.exports, "useLocalizedStringFormatter", () => $fc53663969a3d00a$exports.useLocalizedStringFormatter);
+-$parcel$export(module.exports, "useLocalizedStringDictionary", () => $fc53663969a3d00a$exports.useLocalizedStringDictionary);
+-$parcel$export(module.exports, "useListFormatter", () => $cb6a3e7d490e97a4$exports.useListFormatter);
+-$parcel$export(module.exports, "useDateFormatter", () => $b80c530ff2e20243$exports.useDateFormatter);
+-$parcel$export(module.exports, "useNumberFormatter", () => $fea93c5b7c90d9f4$exports.useNumberFormatter);
+-$parcel$export(module.exports, "useCollator", () => $27a5ce66022270ad$exports.useCollator);
+-$parcel$export(module.exports, "useFilter", () => $832d079b867c7223$exports.useFilter);
+-/*
+- * Copyright 2020 Adobe. All rights reserved.
+- * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+- * you may not use this file except in compliance with the License. You may obtain a copy
+- * of the License at http://www.apache.org/licenses/LICENSE-2.0
+- *
+- * Unless required by applicable law or agreed to in writing, software distributed under
+- * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+- * OF ANY KIND, either express or implied. See the License for the specific language
+- * governing permissions and limitations under the License.
+- */
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-//# sourceMappingURL=main.js.map
++var $fc53663969a3d00a$exports = require("./useLocalizedStringFormatter.main.js");
++var $8c5e0e4c6ca34e89$exports = require("./useNumberFormatter.main.js");
++var $22f23e74d8a5e30d$exports = require("./useCollator.main.js");
++var $7d0e351af20babe4$exports = require("./useFilter.main.js");
++// Slim barrel: removed useDateFormatter, useMessageFormatter, useListFormatter
++exports.I18nProvider = $47fa5ec5ff482271$exports.I18nProvider;
++exports.useLocale = $47fa5ec5ff482271$exports.useLocale;
++exports.useLocalizedStringFormatter = $fc53663969a3d00a$exports.useLocalizedStringFormatter;
++exports.useLocalizedStringDictionary = $fc53663969a3d00a$exports.useLocalizedStringDictionary;
++exports.useNumberFormatter = $8c5e0e4c6ca34e89$exports.useNumberFormatter;
++exports.useCollator = $22f23e74d8a5e30d$exports.useCollator;
++exports.useFilter = $7d0e351af20babe4$exports.useFilter;
+diff --git a/dist/module.js b/dist/module.js
+index fb8c9b0ce2c72d49866c95c3fd6e73cd26149775..6b6dc85b39196597504df1a287495a24ce3c0831 100644
+--- a/dist/module.js
++++ b/dist/module.js
+@@ -1,31 +1,7 @@
+ import {I18nProvider as $18f2051aff69b9bf$export$a54013f0d02a8f82, useLocale as $18f2051aff69b9bf$export$43bb16f9c6d9e3f7} from "./context.module.js";
+-import {useMessageFormatter as $321bc95feeb923dd$export$ec23bf898b1eed85} from "./useMessageFormatter.module.js";
+-import {useLocalizedStringDictionary as $fca6afa0e843324b$export$87b761675e8eaa10, useLocalizedStringFormatter as $fca6afa0e843324b$export$f12b703ca79dfbb1} from "./useLocalizedStringFormatter.module.js";
+-import {useListFormatter as $33bf17300c498528$export$a2f47a3d2973640} from "./useListFormatter.module.js";
+-import {useDateFormatter as $896ba0a80a8f4d36$export$85fd5fdf27bacc79} from "./useDateFormatter.module.js";
++import {useLocalizedStringDictionary as $fca6afa0e843324b$export$87b761675e8eaa10, useLocalizedStringFormatter as $fca6afa0e843324b$export$f12b703ca79dfbb1} from "./useLocalizedStringFormatter.module.js";
+ import {useNumberFormatter as $a916eb452884faea$export$b7a616150fdb9f44} from "./useNumberFormatter.module.js";
+ import {useCollator as $325a3faab7a68acd$export$a16aca283550c30d} from "./useCollator.module.js";
+ import {useFilter as $bb77f239b46e8c72$export$3274cf84b703fff} from "./useFilter.module.js";
+-
+-/*
+- * Copyright 2020 Adobe. All rights reserved.
+- * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+- * you may not use this file except in compliance with the License. You may obtain a copy
+- * of the License at http://www.apache.org/licenses/LICENSE-2.0
+- *
+- * Unless required by applicable law or agreed to in writing, software distributed under
+- * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+- * OF ANY KIND, either express or implied. See the License for the specific language
+- * governing permissions and limitations under the License.
+- */
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-export {$18f2051aff69b9bf$export$a54013f0d02a8f82 as I18nProvider, $18f2051aff69b9bf$export$43bb16f9c6d9e3f7 as useLocale, $321bc95feeb923dd$export$ec23bf898b1eed85 as useMessageFormatter, $fca6afa0e843324b$export$f12b703ca79dfbb1 as useLocalizedStringFormatter, $fca6afa0e843324b$export$87b761675e8eaa10 as useLocalizedStringDictionary, $33bf17300c498528$export$a2f47a3d2973640 as useListFormatter, $896ba0a80a8f4d36$export$85fd5fdf27bacc79 as useDateFormatter, $a916eb452884faea$export$b7a616150fdb9f44 as useNumberFormatter, $325a3faab7a68acd$export$a16aca283550c30d as useCollator, $bb77f239b46e8c72$export$3274cf84b703fff as useFilter};
+-//# sourceMappingURL=module.js.map
++// Slim barrel: removed useDateFormatter (@internationalized/date ~40KB) and useMessageFormatter (@formatjs ~62KB)
++export {$18f2051aff69b9bf$export$a54013f0d02a8f82 as I18nProvider, $18f2051aff69b9bf$export$43bb16f9c6d9e3f7 as useLocale, $fca6afa0e843324b$export$f12b703ca79dfbb1 as useLocalizedStringFormatter, $fca6afa0e843324b$export$87b761675e8eaa10 as useLocalizedStringDictionary, $a916eb452884faea$export$b7a616150fdb9f44 as useNumberFormatter, $325a3faab7a68acd$export$a16aca283550c30d as useCollator, $bb77f239b46e8c72$export$3274cf84b703fff as useFilter};
diff --git a/.yarn/patches/react-aria-npm-3.37.0-83959bd2fa.patch b/.yarn/patches/react-aria-npm-3.37.0-83959bd2fa.patch
new file mode 100644
index 0000000000000..4cceb4611139a
--- /dev/null
+++ b/.yarn/patches/react-aria-npm-3.37.0-83959bd2fa.patch
@@ -0,0 +1,316 @@
+diff --git a/dist/import.mjs b/dist/import.mjs
+index a13ba8b37e27230ec01d4c31140127912208666c..97d6aa506f273263c2a99a9bc80731ac2c38eea6 100644
+--- a/dist/import.mjs
++++ b/dist/import.mjs
+@@ -1,50 +1,14 @@
+-/*
+- * Copyright 2020 Adobe. All rights reserved.
+- * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+- * you may not use this file except in compliance with the License. You may obtain a copy
+- * of the License at http://www.apache.org/licenses/LICENSE-2.0
+- *
+- * Unless required by applicable law or agreed to in writing, software distributed under
+- * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+- * OF ANY KIND, either express or implied. See the License for the specific language
+- * governing permissions and limitations under the License.
+- */
+-
+-export {useBreadcrumbItem, useBreadcrumbs} from '@react-aria/breadcrumbs';
+-export {useButton, useToggleButton, useToggleButtonGroup, useToggleButtonGroupItem} from '@react-aria/button';
+-export {useCalendar, useCalendarCell, useCalendarGrid, useRangeCalendar} from '@react-aria/calendar';
+-export {useCheckbox, useCheckboxGroup, useCheckboxGroupItem} from '@react-aria/checkbox';
+-export {useColorArea, useColorChannelField, useColorField, useColorSlider, useColorSwatch, useColorWheel} from '@react-aria/color';
+-export {useComboBox} from '@react-aria/combobox';
+-export {useDateField, useDatePicker, useDateRangePicker, useDateSegment, useTimeField} from '@react-aria/datepicker';
+-export {useDialog} from '@react-aria/dialog';
+-export {useDisclosure} from '@react-aria/disclosure';
+-export {useDrag, useDrop, useDraggableCollection, useDroppableCollection, useDroppableItem, useDropIndicator, useDraggableItem, useClipboard, DragPreview, ListDropTargetDelegate, DIRECTORY_DRAG_TYPE, isDirectoryDropItem, isFileDropItem, isTextDropItem} from '@react-aria/dnd';
++// Slim barrel: only exports actually used
++export {useButton} from '@react-aria/button';
+ export {FocusRing, FocusScope, useFocusManager, useFocusRing, useFocusable} from '@react-aria/focus';
+-export {I18nProvider, useCollator, useDateFormatter, useFilter, useLocale, useLocalizedStringFormatter, useMessageFormatter, useNumberFormatter} from '@react-aria/i18n';
++export {useNumberFormatter} from '@react-aria/i18n';
+ export {useFocus, useFocusVisible, useFocusWithin, useHover, useInteractOutside, useKeyboard, useMove, usePress, useLongPress} from '@react-aria/interactions';
+-export {useField, useLabel} from '@react-aria/label';
+-export {useGridList, useGridListItem, useGridListSelectionCheckbox} from '@react-aria/gridlist';
+-export {useLink} from '@react-aria/link';
+ export {useListBox, useListBoxSection, useOption} from '@react-aria/listbox';
+-export {useMenu, useMenuItem, useMenuSection, useMenuTrigger, useSubmenuTrigger} from '@react-aria/menu';
+-export {useMeter} from '@react-aria/meter';
+-export {useNumberField} from '@react-aria/numberfield';
+-export {DismissButton, ModalProvider, Overlay, OverlayContainer, OverlayProvider, useModal, useModalOverlay, useModalProvider, useOverlay, useOverlayPosition, useOverlayTrigger, usePopover, usePreventScroll} from '@react-aria/overlays';
+-export {useProgressBar} from '@react-aria/progress';
+-export {useRadio, useRadioGroup} from '@react-aria/radio';
+-export {useSearchField} from '@react-aria/searchfield';
+-export {HiddenSelect, useHiddenSelect, useSelect} from '@react-aria/select';
+-export {ListKeyboardDelegate} from '@react-aria/selection';
++export {useMenu, useMenuItem, useMenuSection, useMenuTrigger} from '@react-aria/menu';
++export {DismissButton, Overlay, useOverlayTrigger, usePopover} from '@react-aria/overlays';
++export {HiddenSelect, useSelect} from '@react-aria/select';
+ export {useSeparator} from '@react-aria/separator';
+-export {SSRProvider, useIsSSR} from '@react-aria/ssr';
+ export {useSlider, useSliderThumb} from '@react-aria/slider';
+-export {useSwitch} from '@react-aria/switch';
+-export {useTable, useTableCell, useTableColumnHeader, useTableColumnResize, useTableHeaderRow, useTableRow, useTableRowGroup, useTableSelectAllCheckbox, useTableSelectionCheckbox} from '@react-aria/table';
+-export {useTab, useTabList, useTabPanel} from '@react-aria/tabs';
+-export {useTag, useTagGroup} from '@react-aria/tag';
+-export {useTextField} from '@react-aria/textfield';
+-export {useTooltip, useTooltipTrigger} from '@react-aria/tooltip';
+-export {chain, mergeProps, useId, useObjectRef, RouterProvider} from '@react-aria/utils';
++export {chain, mergeProps, useId, useObjectRef} from '@react-aria/utils';
+ export {VisuallyHidden, useVisuallyHidden} from '@react-aria/visually-hidden';
+-
++export {useDialog} from '@react-aria/dialog';
+diff --git a/dist/main.js b/dist/main.js
+index b6a2ce3a780a09aeb416d7330ffcf6fd0b36b218..98e720a4660bdcd0384509786d04681a8ba98886 100644
+--- a/dist/main.js
++++ b/dist/main.js
+@@ -1,172 +1,15 @@
+ "use strict";
+-
+ exports.__esModule = true;
+-exports.useRadioGroup = exports.useRadio = exports.useProgressBar = exports.usePreventScroll = exports.usePress = exports.usePopover = exports.useOverlayTrigger = exports.useOverlayPosition = exports.useOverlay = exports.useOption = exports.useObjectRef = exports.useNumberFormatter = exports.useNumberField = exports.useMove = exports.useModalProvider = exports.useModalOverlay = exports.useModal = exports.useMeter = exports.useMessageFormatter = exports.useMenuTrigger = exports.useMenuSection = exports.useMenuItem = exports.useMenu = exports.useLongPress = exports.useLocalizedStringFormatter = exports.useLocale = exports.useListBoxSection = exports.useListBox = exports.useLink = exports.useLabel = exports.useKeyboard = exports.useIsSSR = exports.useInteractOutside = exports.useId = exports.useHover = exports.useHiddenSelect = exports.useGridListSelectionCheckbox = exports.useGridListItem = exports.useGridList = exports.useFocusable = exports.useFocusWithin = exports.useFocusVisible = exports.useFocusRing = exports.useFocusManager = exports.useFocus = exports.useFilter = exports.useField = exports.useDroppableItem = exports.useDroppableCollection = exports.useDropIndicator = exports.useDrop = exports.useDraggableItem = exports.useDraggableCollection = exports.useDrag = exports.useDisclosure = exports.useDialog = exports.useDateSegment = exports.useDateRangePicker = exports.useDatePicker = exports.useDateFormatter = exports.useDateField = exports.useComboBox = exports.useColorWheel = exports.useColorSwatch = exports.useColorSlider = exports.useColorField = exports.useColorChannelField = exports.useColorArea = exports.useCollator = exports.useClipboard = exports.useCheckboxGroupItem = exports.useCheckboxGroup = exports.useCheckbox = exports.useCalendarGrid = exports.useCalendarCell = exports.useCalendar = exports.useButton = exports.useBreadcrumbs = exports.useBreadcrumbItem = exports.mergeProps = exports.isTextDropItem = exports.isFileDropItem = exports.isDirectoryDropItem = exports.chain = exports.VisuallyHidden = exports.SSRProvider = exports.RouterProvider = exports.OverlayProvider = exports.OverlayContainer = exports.Overlay = exports.ModalProvider = exports.ListKeyboardDelegate = exports.ListDropTargetDelegate = exports.I18nProvider = exports.HiddenSelect = exports.FocusScope = exports.FocusRing = exports.DragPreview = exports.DismissButton = exports.DIRECTORY_DRAG_TYPE = void 0;
+-exports.useVisuallyHidden = exports.useTooltipTrigger = exports.useTooltip = exports.useToggleButtonGroupItem = exports.useToggleButtonGroup = exports.useToggleButton = exports.useTimeField = exports.useTextField = exports.useTagGroup = exports.useTag = exports.useTableSelectionCheckbox = exports.useTableSelectAllCheckbox = exports.useTableRowGroup = exports.useTableRow = exports.useTableHeaderRow = exports.useTableColumnResize = exports.useTableColumnHeader = exports.useTableCell = exports.useTable = exports.useTabPanel = exports.useTabList = exports.useTab = exports.useSwitch = exports.useSubmenuTrigger = exports.useSliderThumb = exports.useSlider = exports.useSeparator = exports.useSelect = exports.useSearchField = exports.useRangeCalendar = void 0;
+-var _breadcrumbs = require("@react-aria/breadcrumbs");
+-exports.useBreadcrumbItem = _breadcrumbs.useBreadcrumbItem;
+-exports.useBreadcrumbs = _breadcrumbs.useBreadcrumbs;
+-var _button = require("@react-aria/button");
+-exports.useButton = _button.useButton;
+-exports.useToggleButton = _button.useToggleButton;
+-exports.useToggleButtonGroup = _button.useToggleButtonGroup;
+-exports.useToggleButtonGroupItem = _button.useToggleButtonGroupItem;
+-var _calendar = require("@react-aria/calendar");
+-exports.useCalendar = _calendar.useCalendar;
+-exports.useCalendarCell = _calendar.useCalendarCell;
+-exports.useCalendarGrid = _calendar.useCalendarGrid;
+-exports.useRangeCalendar = _calendar.useRangeCalendar;
+-var _checkbox = require("@react-aria/checkbox");
+-exports.useCheckbox = _checkbox.useCheckbox;
+-exports.useCheckboxGroup = _checkbox.useCheckboxGroup;
+-exports.useCheckboxGroupItem = _checkbox.useCheckboxGroupItem;
+-var _color = require("@react-aria/color");
+-exports.useColorArea = _color.useColorArea;
+-exports.useColorChannelField = _color.useColorChannelField;
+-exports.useColorField = _color.useColorField;
+-exports.useColorSlider = _color.useColorSlider;
+-exports.useColorSwatch = _color.useColorSwatch;
+-exports.useColorWheel = _color.useColorWheel;
+-var _combobox = require("@react-aria/combobox");
+-exports.useComboBox = _combobox.useComboBox;
+-var _datepicker = require("@react-aria/datepicker");
+-exports.useDateField = _datepicker.useDateField;
+-exports.useDatePicker = _datepicker.useDatePicker;
+-exports.useDateRangePicker = _datepicker.useDateRangePicker;
+-exports.useDateSegment = _datepicker.useDateSegment;
+-exports.useTimeField = _datepicker.useTimeField;
+-var _dialog = require("@react-aria/dialog");
+-exports.useDialog = _dialog.useDialog;
+-var _disclosure = require("@react-aria/disclosure");
+-exports.useDisclosure = _disclosure.useDisclosure;
+-var _dnd = require("@react-aria/dnd");
+-exports.useDrag = _dnd.useDrag;
+-exports.useDrop = _dnd.useDrop;
+-exports.useDraggableCollection = _dnd.useDraggableCollection;
+-exports.useDroppableCollection = _dnd.useDroppableCollection;
+-exports.useDroppableItem = _dnd.useDroppableItem;
+-exports.useDropIndicator = _dnd.useDropIndicator;
+-exports.useDraggableItem = _dnd.useDraggableItem;
+-exports.useClipboard = _dnd.useClipboard;
+-exports.DragPreview = _dnd.DragPreview;
+-exports.ListDropTargetDelegate = _dnd.ListDropTargetDelegate;
+-exports.DIRECTORY_DRAG_TYPE = _dnd.DIRECTORY_DRAG_TYPE;
+-exports.isDirectoryDropItem = _dnd.isDirectoryDropItem;
+-exports.isFileDropItem = _dnd.isFileDropItem;
+-exports.isTextDropItem = _dnd.isTextDropItem;
+-var _focus = require("@react-aria/focus");
+-exports.FocusRing = _focus.FocusRing;
+-exports.FocusScope = _focus.FocusScope;
+-exports.useFocusManager = _focus.useFocusManager;
+-exports.useFocusRing = _focus.useFocusRing;
+-exports.useFocusable = _focus.useFocusable;
+-var _i18n = require("@react-aria/i18n");
+-exports.I18nProvider = _i18n.I18nProvider;
+-exports.useCollator = _i18n.useCollator;
+-exports.useDateFormatter = _i18n.useDateFormatter;
+-exports.useFilter = _i18n.useFilter;
+-exports.useLocale = _i18n.useLocale;
+-exports.useLocalizedStringFormatter = _i18n.useLocalizedStringFormatter;
+-exports.useMessageFormatter = _i18n.useMessageFormatter;
+-exports.useNumberFormatter = _i18n.useNumberFormatter;
+-var _interactions = require("@react-aria/interactions");
+-exports.useFocus = _interactions.useFocus;
+-exports.useFocusVisible = _interactions.useFocusVisible;
+-exports.useFocusWithin = _interactions.useFocusWithin;
+-exports.useHover = _interactions.useHover;
+-exports.useInteractOutside = _interactions.useInteractOutside;
+-exports.useKeyboard = _interactions.useKeyboard;
+-exports.useMove = _interactions.useMove;
+-exports.usePress = _interactions.usePress;
+-exports.useLongPress = _interactions.useLongPress;
+-var _label = require("@react-aria/label");
+-exports.useField = _label.useField;
+-exports.useLabel = _label.useLabel;
+-var _gridlist = require("@react-aria/gridlist");
+-exports.useGridList = _gridlist.useGridList;
+-exports.useGridListItem = _gridlist.useGridListItem;
+-exports.useGridListSelectionCheckbox = _gridlist.useGridListSelectionCheckbox;
+-var _link = require("@react-aria/link");
+-exports.useLink = _link.useLink;
+-var _listbox = require("@react-aria/listbox");
+-exports.useListBox = _listbox.useListBox;
+-exports.useListBoxSection = _listbox.useListBoxSection;
+-exports.useOption = _listbox.useOption;
+-var _menu = require("@react-aria/menu");
+-exports.useMenu = _menu.useMenu;
+-exports.useMenuItem = _menu.useMenuItem;
+-exports.useMenuSection = _menu.useMenuSection;
+-exports.useMenuTrigger = _menu.useMenuTrigger;
+-exports.useSubmenuTrigger = _menu.useSubmenuTrigger;
+-var _meter = require("@react-aria/meter");
+-exports.useMeter = _meter.useMeter;
+-var _numberfield = require("@react-aria/numberfield");
+-exports.useNumberField = _numberfield.useNumberField;
+-var _overlays = require("@react-aria/overlays");
+-exports.DismissButton = _overlays.DismissButton;
+-exports.ModalProvider = _overlays.ModalProvider;
+-exports.Overlay = _overlays.Overlay;
+-exports.OverlayContainer = _overlays.OverlayContainer;
+-exports.OverlayProvider = _overlays.OverlayProvider;
+-exports.useModal = _overlays.useModal;
+-exports.useModalOverlay = _overlays.useModalOverlay;
+-exports.useModalProvider = _overlays.useModalProvider;
+-exports.useOverlay = _overlays.useOverlay;
+-exports.useOverlayPosition = _overlays.useOverlayPosition;
+-exports.useOverlayTrigger = _overlays.useOverlayTrigger;
+-exports.usePopover = _overlays.usePopover;
+-exports.usePreventScroll = _overlays.usePreventScroll;
+-var _progress = require("@react-aria/progress");
+-exports.useProgressBar = _progress.useProgressBar;
+-var _radio = require("@react-aria/radio");
+-exports.useRadio = _radio.useRadio;
+-exports.useRadioGroup = _radio.useRadioGroup;
+-var _searchfield = require("@react-aria/searchfield");
+-exports.useSearchField = _searchfield.useSearchField;
+-var _select = require("@react-aria/select");
+-exports.HiddenSelect = _select.HiddenSelect;
+-exports.useHiddenSelect = _select.useHiddenSelect;
+-exports.useSelect = _select.useSelect;
+-var _selection = require("@react-aria/selection");
+-exports.ListKeyboardDelegate = _selection.ListKeyboardDelegate;
+-var _separator = require("@react-aria/separator");
+-exports.useSeparator = _separator.useSeparator;
+-var _ssr = require("@react-aria/ssr");
+-exports.SSRProvider = _ssr.SSRProvider;
+-exports.useIsSSR = _ssr.useIsSSR;
+-var _slider = require("@react-aria/slider");
+-exports.useSlider = _slider.useSlider;
+-exports.useSliderThumb = _slider.useSliderThumb;
+-var _switch = require("@react-aria/switch");
+-exports.useSwitch = _switch.useSwitch;
+-var _table = require("@react-aria/table");
+-exports.useTable = _table.useTable;
+-exports.useTableCell = _table.useTableCell;
+-exports.useTableColumnHeader = _table.useTableColumnHeader;
+-exports.useTableColumnResize = _table.useTableColumnResize;
+-exports.useTableHeaderRow = _table.useTableHeaderRow;
+-exports.useTableRow = _table.useTableRow;
+-exports.useTableRowGroup = _table.useTableRowGroup;
+-exports.useTableSelectAllCheckbox = _table.useTableSelectAllCheckbox;
+-exports.useTableSelectionCheckbox = _table.useTableSelectionCheckbox;
+-var _tabs = require("@react-aria/tabs");
+-exports.useTab = _tabs.useTab;
+-exports.useTabList = _tabs.useTabList;
+-exports.useTabPanel = _tabs.useTabPanel;
+-var _tag = require("@react-aria/tag");
+-exports.useTag = _tag.useTag;
+-exports.useTagGroup = _tag.useTagGroup;
+-var _textfield = require("@react-aria/textfield");
+-exports.useTextField = _textfield.useTextField;
+-var _tooltip = require("@react-aria/tooltip");
+-exports.useTooltip = _tooltip.useTooltip;
+-exports.useTooltipTrigger = _tooltip.useTooltipTrigger;
+-var _utils = require("@react-aria/utils");
+-exports.chain = _utils.chain;
+-exports.mergeProps = _utils.mergeProps;
+-exports.useId = _utils.useId;
+-exports.useObjectRef = _utils.useObjectRef;
+-exports.RouterProvider = _utils.RouterProvider;
+-var _visuallyHidden = require("@react-aria/visually-hidden");
+-exports.VisuallyHidden = _visuallyHidden.VisuallyHidden;
+-exports.useVisuallyHidden = _visuallyHidden.useVisuallyHidden;
++var _button = require("@react-aria/button"); exports.useButton = _button.useButton;
++var _focus = require("@react-aria/focus"); exports.FocusRing = _focus.FocusRing; exports.FocusScope = _focus.FocusScope; exports.useFocusManager = _focus.useFocusManager; exports.useFocusRing = _focus.useFocusRing; exports.useFocusable = _focus.useFocusable;
++var _i18n = require("@react-aria/i18n"); exports.useNumberFormatter = _i18n.useNumberFormatter;
++var _interactions = require("@react-aria/interactions"); exports.useFocus = _interactions.useFocus; exports.useFocusVisible = _interactions.useFocusVisible; exports.useFocusWithin = _interactions.useFocusWithin; exports.useHover = _interactions.useHover; exports.useInteractOutside = _interactions.useInteractOutside; exports.useKeyboard = _interactions.useKeyboard; exports.useMove = _interactions.useMove; exports.usePress = _interactions.usePress; exports.useLongPress = _interactions.useLongPress;
++var _listbox = require("@react-aria/listbox"); exports.useListBox = _listbox.useListBox; exports.useListBoxSection = _listbox.useListBoxSection; exports.useOption = _listbox.useOption;
++var _menu = require("@react-aria/menu"); exports.useMenu = _menu.useMenu; exports.useMenuItem = _menu.useMenuItem; exports.useMenuSection = _menu.useMenuSection; exports.useMenuTrigger = _menu.useMenuTrigger;
++var _overlays = require("@react-aria/overlays"); exports.DismissButton = _overlays.DismissButton; exports.Overlay = _overlays.Overlay; exports.useOverlayTrigger = _overlays.useOverlayTrigger; exports.usePopover = _overlays.usePopover;
++var _select = require("@react-aria/select"); exports.HiddenSelect = _select.HiddenSelect; exports.useSelect = _select.useSelect;
++var _separator = require("@react-aria/separator"); exports.useSeparator = _separator.useSeparator;
++var _slider = require("@react-aria/slider"); exports.useSlider = _slider.useSlider; exports.useSliderThumb = _slider.useSliderThumb;
++var _utils = require("@react-aria/utils"); exports.chain = _utils.chain; exports.mergeProps = _utils.mergeProps; exports.useId = _utils.useId; exports.useObjectRef = _utils.useObjectRef;
++var _visuallyHidden = require("@react-aria/visually-hidden"); exports.VisuallyHidden = _visuallyHidden.VisuallyHidden; exports.useVisuallyHidden = _visuallyHidden.useVisuallyHidden;
++var _dialog = require("@react-aria/dialog"); exports.useDialog = _dialog.useDialog;
+diff --git a/dist/module.js b/dist/module.js
+index a13ba8b37e27230ec01d4c31140127912208666c..97d6aa506f273263c2a99a9bc80731ac2c38eea6 100644
+--- a/dist/module.js
++++ b/dist/module.js
+@@ -1,50 +1,14 @@
+-/*
+- * Copyright 2020 Adobe. All rights reserved.
+- * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+- * you may not use this file except in compliance with the License. You may obtain a copy
+- * of the License at http://www.apache.org/licenses/LICENSE-2.0
+- *
+- * Unless required by applicable law or agreed to in writing, software distributed under
+- * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+- * OF ANY KIND, either express or implied. See the License for the specific language
+- * governing permissions and limitations under the License.
+- */
+-
+-export {useBreadcrumbItem, useBreadcrumbs} from '@react-aria/breadcrumbs';
+-export {useButton, useToggleButton, useToggleButtonGroup, useToggleButtonGroupItem} from '@react-aria/button';
+-export {useCalendar, useCalendarCell, useCalendarGrid, useRangeCalendar} from '@react-aria/calendar';
+-export {useCheckbox, useCheckboxGroup, useCheckboxGroupItem} from '@react-aria/checkbox';
+-export {useColorArea, useColorChannelField, useColorField, useColorSlider, useColorSwatch, useColorWheel} from '@react-aria/color';
+-export {useComboBox} from '@react-aria/combobox';
+-export {useDateField, useDatePicker, useDateRangePicker, useDateSegment, useTimeField} from '@react-aria/datepicker';
+-export {useDialog} from '@react-aria/dialog';
+-export {useDisclosure} from '@react-aria/disclosure';
+-export {useDrag, useDrop, useDraggableCollection, useDroppableCollection, useDroppableItem, useDropIndicator, useDraggableItem, useClipboard, DragPreview, ListDropTargetDelegate, DIRECTORY_DRAG_TYPE, isDirectoryDropItem, isFileDropItem, isTextDropItem} from '@react-aria/dnd';
++// Slim barrel: only exports actually used
++export {useButton} from '@react-aria/button';
+ export {FocusRing, FocusScope, useFocusManager, useFocusRing, useFocusable} from '@react-aria/focus';
+-export {I18nProvider, useCollator, useDateFormatter, useFilter, useLocale, useLocalizedStringFormatter, useMessageFormatter, useNumberFormatter} from '@react-aria/i18n';
++export {useNumberFormatter} from '@react-aria/i18n';
+ export {useFocus, useFocusVisible, useFocusWithin, useHover, useInteractOutside, useKeyboard, useMove, usePress, useLongPress} from '@react-aria/interactions';
+-export {useField, useLabel} from '@react-aria/label';
+-export {useGridList, useGridListItem, useGridListSelectionCheckbox} from '@react-aria/gridlist';
+-export {useLink} from '@react-aria/link';
+ export {useListBox, useListBoxSection, useOption} from '@react-aria/listbox';
+-export {useMenu, useMenuItem, useMenuSection, useMenuTrigger, useSubmenuTrigger} from '@react-aria/menu';
+-export {useMeter} from '@react-aria/meter';
+-export {useNumberField} from '@react-aria/numberfield';
+-export {DismissButton, ModalProvider, Overlay, OverlayContainer, OverlayProvider, useModal, useModalOverlay, useModalProvider, useOverlay, useOverlayPosition, useOverlayTrigger, usePopover, usePreventScroll} from '@react-aria/overlays';
+-export {useProgressBar} from '@react-aria/progress';
+-export {useRadio, useRadioGroup} from '@react-aria/radio';
+-export {useSearchField} from '@react-aria/searchfield';
+-export {HiddenSelect, useHiddenSelect, useSelect} from '@react-aria/select';
+-export {ListKeyboardDelegate} from '@react-aria/selection';
++export {useMenu, useMenuItem, useMenuSection, useMenuTrigger} from '@react-aria/menu';
++export {DismissButton, Overlay, useOverlayTrigger, usePopover} from '@react-aria/overlays';
++export {HiddenSelect, useSelect} from '@react-aria/select';
+ export {useSeparator} from '@react-aria/separator';
+-export {SSRProvider, useIsSSR} from '@react-aria/ssr';
+ export {useSlider, useSliderThumb} from '@react-aria/slider';
+-export {useSwitch} from '@react-aria/switch';
+-export {useTable, useTableCell, useTableColumnHeader, useTableColumnResize, useTableHeaderRow, useTableRow, useTableRowGroup, useTableSelectAllCheckbox, useTableSelectionCheckbox} from '@react-aria/table';
+-export {useTab, useTabList, useTabPanel} from '@react-aria/tabs';
+-export {useTag, useTagGroup} from '@react-aria/tag';
+-export {useTextField} from '@react-aria/textfield';
+-export {useTooltip, useTooltipTrigger} from '@react-aria/tooltip';
+-export {chain, mergeProps, useId, useObjectRef, RouterProvider} from '@react-aria/utils';
++export {chain, mergeProps, useId, useObjectRef} from '@react-aria/utils';
+ export {VisuallyHidden, useVisuallyHidden} from '@react-aria/visually-hidden';
+-
++export {useDialog} from '@react-aria/dialog';
diff --git a/.yarn/patches/react-stately-npm-3.17.0-264cc7a43c.patch b/.yarn/patches/react-stately-npm-3.17.0-264cc7a43c.patch
new file mode 100644
index 0000000000000..53c82149f04aa
--- /dev/null
+++ b/.yarn/patches/react-stately-npm-3.17.0-264cc7a43c.patch
@@ -0,0 +1,149 @@
+diff --git a/dist/main.js b/dist/main.js
+index 8e3aafa76ccbbc4491666e9593220378c3e79c18..2ed9b74c20b63fb1f9e8552d61343b26540f78d9 100644
+--- a/dist/main.js
++++ b/dist/main.js
+@@ -1,98 +1,8 @@
+ "use strict";
+-
+ exports.__esModule = true;
+-exports.useTreeState = exports.useTooltipTriggerState = exports.useToggleState = exports.useTabListState = exports.Cell = exports.Row = exports.Column = exports.TableBody = exports.TableHeader = exports.useTableState = exports.useMultipleSelectionState = exports.useSliderState = exports.useSelectState = exports.useSearchFieldState = exports.useRadioGroupState = exports.useOverlayTriggerState = exports.useNumberFieldState = exports.useMenuTriggerState = exports.useSingleSelectListState = exports.useListState = exports.useTreeData = exports.useListData = exports.useAsyncList = exports.useCollection = exports.Section = exports.Item = exports.useTimeFieldState = exports.useDateRangePickerState = exports.useDatePickerState = exports.useDateFieldState = exports.useComboBoxState = exports.useCheckboxGroupState = exports.useRangeCalendarState = exports.useCalendarState = void 0;
+-
+-var _calendar = require("@react-stately/calendar");
+-
+-exports.useCalendarState = _calendar.useCalendarState;
+-exports.useRangeCalendarState = _calendar.useRangeCalendarState;
+-
+-var _checkbox = require("@react-stately/checkbox");
+-
+-exports.useCheckboxGroupState = _checkbox.useCheckboxGroupState;
+-
+-var _combobox = require("@react-stately/combobox");
+-
+-exports.useComboBoxState = _combobox.useComboBoxState;
+-
+-var _datepicker = require("@react-stately/datepicker");
+-
+-exports.useDateFieldState = _datepicker.useDateFieldState;
+-exports.useDatePickerState = _datepicker.useDatePickerState;
+-exports.useDateRangePickerState = _datepicker.useDateRangePickerState;
+-exports.useTimeFieldState = _datepicker.useTimeFieldState;
+-
+-var _collections = require("@react-stately/collections");
+-
+-exports.Item = _collections.Item;
+-exports.Section = _collections.Section;
+-exports.useCollection = _collections.useCollection;
+-
+-var _data = require("@react-stately/data");
+-
+-exports.useAsyncList = _data.useAsyncList;
+-exports.useListData = _data.useListData;
+-exports.useTreeData = _data.useTreeData;
+-
+-var _list = require("@react-stately/list");
+-
+-exports.useListState = _list.useListState;
+-exports.useSingleSelectListState = _list.useSingleSelectListState;
+-
+-var _menu = require("@react-stately/menu");
+-
+-exports.useMenuTriggerState = _menu.useMenuTriggerState;
+-
+-var _numberfield = require("@react-stately/numberfield");
+-
+-exports.useNumberFieldState = _numberfield.useNumberFieldState;
+-
+-var _overlays = require("@react-stately/overlays");
+-
+-exports.useOverlayTriggerState = _overlays.useOverlayTriggerState;
+-
+-var _radio = require("@react-stately/radio");
+-
+-exports.useRadioGroupState = _radio.useRadioGroupState;
+-
+-var _searchfield = require("@react-stately/searchfield");
+-
+-exports.useSearchFieldState = _searchfield.useSearchFieldState;
+-
+-var _select = require("@react-stately/select");
+-
+-exports.useSelectState = _select.useSelectState;
+-
+-var _slider = require("@react-stately/slider");
+-
+-exports.useSliderState = _slider.useSliderState;
+-
+-var _selection = require("@react-stately/selection");
+-
+-exports.useMultipleSelectionState = _selection.useMultipleSelectionState;
+-
+-var _table = require("@react-stately/table");
+-
+-exports.useTableState = _table.useTableState;
+-exports.TableHeader = _table.TableHeader;
+-exports.TableBody = _table.TableBody;
+-exports.Column = _table.Column;
+-exports.Row = _table.Row;
+-exports.Cell = _table.Cell;
+-
+-var _tabs = require("@react-stately/tabs");
+-
+-exports.useTabListState = _tabs.useTabListState;
+-
+-var _toggle = require("@react-stately/toggle");
+-
+-exports.useToggleState = _toggle.useToggleState;
+-
+-var _tooltip = require("@react-stately/tooltip");
+-
+-exports.useTooltipTriggerState = _tooltip.useTooltipTriggerState;
+-
+-var _tree = require("@react-stately/tree");
+-
+-exports.useTreeState = _tree.useTreeState;
++var _collections = require("@react-stately/collections"); exports.Item = _collections.Item; exports.Section = _collections.Section;
++var _menu = require("@react-stately/menu"); exports.useMenuTriggerState = _menu.useMenuTriggerState;
++var _overlays = require("@react-stately/overlays"); exports.useOverlayTriggerState = _overlays.useOverlayTriggerState;
++var _select = require("@react-stately/select"); exports.useSelectState = _select.useSelectState;
++var _slider = require("@react-stately/slider"); exports.useSliderState = _slider.useSliderState;
++var _tree = require("@react-stately/tree"); exports.useTreeState = _tree.useTreeState;
+diff --git a/dist/module.js b/dist/module.js
+index 37bdb24af4a8310cc702b2d9c1effe6aac261424..c098533fc2bbeb6b203d4350c2dcdd6e6663afd3 100644
+--- a/dist/module.js
++++ b/dist/module.js
+@@ -1,33 +1,7 @@
+-/*
+- * Copyright 2020 Adobe. All rights reserved.
+- * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+- * you may not use this file except in compliance with the License. You may obtain a copy
+- * of the License at http://www.apache.org/licenses/LICENSE-2.0
+- *
+- * Unless required by applicable law or agreed to in writing, software distributed under
+- * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+- * OF ANY KIND, either express or implied. See the License for the specific language
+- * governing permissions and limitations under the License.
+- */
+-
+-
+-export {useCalendarState, useRangeCalendarState} from '@react-stately/calendar';
+-export {useCheckboxGroupState} from '@react-stately/checkbox';
+-export {useComboBoxState} from '@react-stately/combobox';
+-export {useDateFieldState, useDatePickerState, useDateRangePickerState, useTimeFieldState} from '@react-stately/datepicker';
+-export {Item, Section, useCollection} from '@react-stately/collections';
+-export {useAsyncList, useListData, useTreeData} from '@react-stately/data';
+-export {useListState, useSingleSelectListState} from '@react-stately/list';
++// Slim barrel: only exports actually used
++export {Item, Section} from '@react-stately/collections';
+ export {useMenuTriggerState} from '@react-stately/menu';
+-export {useNumberFieldState} from '@react-stately/numberfield';
+ export {useOverlayTriggerState} from '@react-stately/overlays';
+-export {useRadioGroupState} from '@react-stately/radio';
+-export {useSearchFieldState} from '@react-stately/searchfield';
+ export {useSelectState} from '@react-stately/select';
+ export {useSliderState} from '@react-stately/slider';
+-export {useMultipleSelectionState} from '@react-stately/selection';
+-export {useTableState, TableHeader, TableBody, Column, Row, Cell} from '@react-stately/table';
+-export {useTabListState} from '@react-stately/tabs';
+-export {useToggleState} from '@react-stately/toggle';
+-export {useTooltipTriggerState} from '@react-stately/tooltip';
+ export {useTreeState} from '@react-stately/tree';
diff --git a/.yarn/patches/typia-npm-9.7.2-5c5d9c80b4.patch b/.yarn/patches/typia-npm-9.7.2-5c5d9c80b4.patch
new file mode 100644
index 0000000000000..b6f291d1a58d7
--- /dev/null
+++ b/.yarn/patches/typia-npm-9.7.2-5c5d9c80b4.patch
@@ -0,0 +1,17 @@
+diff --git a/lib/index.mjs b/lib/index.mjs
+index 35c18fc129e349205d3db64bfb7b9c589c726d35..7fe10a375d6e6d4d46bb1c4a776fdc75f062c3d3 100644
+--- a/lib/index.mjs
++++ b/lib/index.mjs
+@@ -1,4 +1,4 @@
+-import * as module from './module.mjs';
++import * as _module from './module.mjs';
+ export { assert, assertEquals, assertGuard, assertGuardEquals, createAssert, createAssertEquals, createAssertGuard, createAssertGuardEquals, createEquals, createIs, createRandom, createValidate, createValidateEquals, equals, is, random, validate, validateEquals } from './module.mjs';
+ import * as functional from './functional.mjs';
+ export { functional };
+@@ -22,5 +22,5 @@ export { TypeGuardError } from './TypeGuardError.mjs';
+
+
+
+-export { module as default };
++export { _module as default };
+ //# sourceMappingURL=index.mjs.map
diff --git a/.yarn/patches/zod-npm-4.3.6-a096e305e6.patch b/.yarn/patches/zod-npm-4.3.6-a096e305e6.patch
new file mode 100644
index 0000000000000..f409b5b624039
--- /dev/null
+++ b/.yarn/patches/zod-npm-4.3.6-a096e305e6.patch
@@ -0,0 +1,105 @@
+diff --git a/v4/classic/external.cjs b/v4/classic/external.cjs
+index ee133825d44ab44b86dd8aef6bd985653ebde06c..ea74468d20c2371f9c4d8941467e3b7fe93a00ef 100644
+--- a/v4/classic/external.cjs
++++ b/v4/classic/external.cjs
+@@ -29,7 +29,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
+ return (mod && mod.__esModule) ? mod : { "default": mod };
+ };
+ Object.defineProperty(exports, "__esModule", { value: true });
+-exports.coerce = exports.iso = exports.ZodISODuration = exports.ZodISOTime = exports.ZodISODate = exports.ZodISODateTime = exports.locales = exports.fromJSONSchema = exports.toJSONSchema = exports.NEVER = exports.util = exports.TimePrecision = exports.flattenError = exports.formatError = exports.prettifyError = exports.treeifyError = exports.regexes = exports.clone = exports.$brand = exports.$input = exports.$output = exports.config = exports.registry = exports.globalRegistry = exports.core = void 0;
++exports.coerce = exports.iso = exports.ZodISODuration = exports.ZodISOTime = exports.ZodISODate = exports.ZodISODateTime = exports.fromJSONSchema = exports.toJSONSchema = exports.NEVER = exports.util = exports.TimePrecision = exports.flattenError = exports.formatError = exports.prettifyError = exports.treeifyError = exports.regexes = exports.clone = exports.$brand = exports.$input = exports.$output = exports.config = exports.registry = exports.globalRegistry = exports.core = void 0;
+ exports.core = __importStar(require("../core/index.cjs"));
+ __exportStar(require("./schemas.cjs"), exports);
+ __exportStar(require("./checks.cjs"), exports);
+@@ -60,7 +60,7 @@ var json_schema_processors_js_1 = require("../core/json-schema-processors.cjs");
+ Object.defineProperty(exports, "toJSONSchema", { enumerable: true, get: function () { return json_schema_processors_js_1.toJSONSchema; } });
+ var from_json_schema_js_1 = require("./from-json-schema.cjs");
+ Object.defineProperty(exports, "fromJSONSchema", { enumerable: true, get: function () { return from_json_schema_js_1.fromJSONSchema; } });
+-exports.locales = __importStar(require("../locales/index.cjs"));
++// locales removed from barrel
+ // iso
+ // must be exported from top-level
+ // https://github.com/colinhacks/zod/issues/4491
+diff --git a/v4/classic/external.js b/v4/classic/external.js
+index 9567900bbb57099481c0ea5864fbf697d62021a5..98ee2af29038deca6c38c46c24d5fb14725e40f0 100644
+--- a/v4/classic/external.js
++++ b/v4/classic/external.js
+@@ -11,7 +11,7 @@ config(en());
+ export { globalRegistry, registry, config, $output, $input, $brand, clone, regexes, treeifyError, prettifyError, formatError, flattenError, TimePrecision, util, NEVER, } from "../core/index.js";
+ export { toJSONSchema } from "../core/json-schema-processors.js";
+ export { fromJSONSchema } from "./from-json-schema.js";
+-export * as locales from "../locales/index.js";
++// locales removed from barrel
+ // iso
+ // must be exported from top-level
+ // https://github.com/colinhacks/zod/issues/4491
+diff --git a/v4/core/index.cjs b/v4/core/index.cjs
+index c3fa303104a191d142a5212f69e7954cba3606a7..44bdc3437c6a88971c1c85b63024213880fe0fff 100644
+--- a/v4/core/index.cjs
++++ b/v4/core/index.cjs
+@@ -26,7 +26,7 @@ var __importStar = (this && this.__importStar) || function (mod) {
+ return result;
+ };
+ Object.defineProperty(exports, "__esModule", { value: true });
+-exports.JSONSchema = exports.JSONSchemaGenerator = exports.toJSONSchema = exports.locales = exports.regexes = exports.util = void 0;
++exports.JSONSchema = exports.JSONSchemaGenerator = exports.toJSONSchema = exports.regexes = exports.util = void 0;
+ __exportStar(require("./core.cjs"), exports);
+ __exportStar(require("./parse.cjs"), exports);
+ __exportStar(require("./errors.cjs"), exports);
+@@ -35,7 +35,7 @@ __exportStar(require("./checks.cjs"), exports);
+ __exportStar(require("./versions.cjs"), exports);
+ exports.util = __importStar(require("./util.cjs"));
+ exports.regexes = __importStar(require("./regexes.cjs"));
+-exports.locales = __importStar(require("../locales/index.cjs"));
++// locales removed from barrel
+ __exportStar(require("./registries.cjs"), exports);
+ __exportStar(require("./doc.cjs"), exports);
+ __exportStar(require("./api.cjs"), exports);
+diff --git a/v4/core/index.js b/v4/core/index.js
+index d334515bf004600f2e55ff85e3730f1b2f2ff703..4ff979844ba18d012893064b01889bf70ec245c8 100644
+--- a/v4/core/index.js
++++ b/v4/core/index.js
+@@ -6,7 +6,7 @@ export * from "./checks.js";
+ export * from "./versions.js";
+ export * as util from "./util.js";
+ export * as regexes from "./regexes.js";
+-export * as locales from "../locales/index.js";
++// locales removed from barrel
+ export * from "./registries.js";
+ export * from "./doc.js";
+ export * from "./api.js";
+diff --git a/v4/mini/external.cjs b/v4/mini/external.cjs
+index 0d0079d94d94010b51abe23042418adbc6077b7f..fd3acb42a12dde65f383eb2c3f46a9108dd8c865 100644
+--- a/v4/mini/external.cjs
++++ b/v4/mini/external.cjs
+@@ -26,7 +26,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
+ };
+ Object.defineProperty(exports, "__esModule", { value: true });
+-exports.coerce = exports.ZodMiniISODuration = exports.ZodMiniISOTime = exports.ZodMiniISODate = exports.ZodMiniISODateTime = exports.iso = exports.locales = exports.toJSONSchema = exports.NEVER = exports.util = exports.TimePrecision = exports.flattenError = exports.formatError = exports.prettifyError = exports.treeifyError = exports.regexes = exports.clone = exports.$brand = exports.$input = exports.$output = exports.config = exports.registry = exports.globalRegistry = exports.core = void 0;
++exports.coerce = exports.ZodMiniISODuration = exports.ZodMiniISOTime = exports.ZodMiniISODate = exports.ZodMiniISODateTime = exports.iso = exports.toJSONSchema = exports.NEVER = exports.util = exports.TimePrecision = exports.flattenError = exports.formatError = exports.prettifyError = exports.treeifyError = exports.regexes = exports.clone = exports.$brand = exports.$input = exports.$output = exports.config = exports.registry = exports.globalRegistry = exports.core = void 0;
+ exports.core = __importStar(require("../core/index.cjs"));
+ __exportStar(require("./parse.cjs"), exports);
+ __exportStar(require("./schemas.cjs"), exports);
+@@ -49,7 +49,7 @@ Object.defineProperty(exports, "util", { enumerable: true, get: function () { re
+ Object.defineProperty(exports, "NEVER", { enumerable: true, get: function () { return index_js_1.NEVER; } });
+ var json_schema_processors_js_1 = require("../core/json-schema-processors.cjs");
+ Object.defineProperty(exports, "toJSONSchema", { enumerable: true, get: function () { return json_schema_processors_js_1.toJSONSchema; } });
+-exports.locales = __importStar(require("../locales/index.cjs"));
++// locales removed from barrel
+ /** A special constant with type `never` */
+ // export const NEVER = {} as never;
+ // iso
+diff --git a/v4/mini/external.js b/v4/mini/external.js
+index d6523c153e213be110c21b370164b5eca1cdf744..7a1addf9ce6e331e56c943867bf04cf199745145 100644
+--- a/v4/mini/external.js
++++ b/v4/mini/external.js
+@@ -4,7 +4,7 @@ export * from "./schemas.js";
+ export * from "./checks.js";
+ export { globalRegistry, registry, config, $output, $input, $brand, clone, regexes, treeifyError, prettifyError, formatError, flattenError, TimePrecision, util, NEVER, } from "../core/index.js";
+ export { toJSONSchema } from "../core/json-schema-processors.js";
+-export * as locales from "../locales/index.js";
++// locales removed from barrel
+ /** A special constant with type `never` */
+ // export const NEVER = {} as never;
+ // iso
diff --git a/README.md b/README.md
index 09b4595440480..5c8f065f1849b 100644
--- a/README.md
+++ b/README.md
@@ -25,13 +25,13 @@ Every day, tens of millions of users in over 150 countries and in organizations
Rocket.Chat provides endless possibilities. Here’s an overview of the features you benefit from:
-💬 [**Team collaboration**](https://docs.rocket.chat/docs/collaborate-using-rocketchat): A single point for secure internal and cross-company collaboration with role-based access control.
+- [**Deployment and workspace management**](https://docs.rocket.chat/docs/deploy-rocketchat): Flexible and reliable self-hosted, cloud, and air-gapped deployment options with scalable architecture and performance monitoring.
-🎯 [**Omnichannel citizen engagement**](https://docs.rocket.chat/docs/omnichannel): Seamless digital interactions with your citizens and customers across platforms like WhatsApp, SMS, and more.
+- [**Security and sovereignty**](https://docs.rocket.chat/docs/secure-rocketchat): Rocket.Chat is secure by design, with features such as identity management, end-to-end encryption, and role and attribute-based access control.
-🦾 [**Optimization**](https://docs.rocket.chat/docs/optimize-your-workspace): Enhance productivity with self-hosted AI and a customizable and accessible workspace.
+- [**Team collaboration**](https://docs.rocket.chat/docs/collaborate-using-rocketchat): Unified collaboration experience for asynchronous and real-time communication with seamless messaging options, voice calls, and federation.
-🤖 [**Extend and integrate**](https://docs.rocket.chat/docs/extend-and-integrate-rocketchat-capabilities): Install and use public apps from the Rocket.Chat Marketplace, build your own apps, and embed the workspace into your platform.
+- [**Extend and integrate**](https://docs.rocket.chat/docs/extend-and-integrate-rocketchat-capabilities): Install public apps from the Rocket.Chat Marketplace, build your own apps with Apps-Engine, and integrate with external systems.
diff --git a/apps/meteor/.babelrc b/apps/meteor/.babelrc
index d57e624148cca..67b35228620e0 100644
--- a/apps/meteor/.babelrc
+++ b/apps/meteor/.babelrc
@@ -1,7 +1,7 @@
{
"presets": [
"@babel/preset-env",
- "@babel/preset-react",
+ ["@babel/preset-react", { "runtime": "automatic" }],
[
"@babel/preset-typescript",
{
@@ -9,14 +9,6 @@
}
]
],
- "plugins": [
- [
- "@babel/plugin-transform-react-jsx",
- {
- "runtime": "automatic"
- }
- ]
- ],
"env": {
"coverage": {
"plugins": [
diff --git a/apps/meteor/.docker/Dockerfile.alpine b/apps/meteor/.docker/Dockerfile.alpine
index 3761e501dcb00..bcea47623c3ba 100644
--- a/apps/meteor/.docker/Dockerfile.alpine
+++ b/apps/meteor/.docker/Dockerfile.alpine
@@ -1,4 +1,4 @@
-FROM node:22.16.0-alpine3.20 AS builder
+FROM node:22.16.0-alpine3.22 AS builder
ENV LANG=C.UTF-8
@@ -9,9 +9,18 @@ COPY . /app
ENV NODE_ENV=production
RUN cd /app/bundle/programs/server \
- && npm install --omit=dev
-
-FROM node:22.16.0-alpine3.20
+ && npm install --omit=dev \
+ && npm cache clean --force \
+ # Drop sourcemaps from third-party prod deps and the client bundle:
+ # - server/npm/node_modules: .map files shipped inside packages
+ # (openpgp, date-fns, etc.). Node does not read them at runtime.
+ # - web.browser: client-side bundle, maps have no runtime value in the image.
+ # Maps under /app/bundle/programs/server/{app,packages,node_modules/meteor}
+ # are loaded by Meteor's source-map-support at boot, so they are kept.
+ && 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.16.0-alpine3.22
LABEL maintainer="buildmaster@rocket.chat"
@@ -31,7 +40,7 @@ ENV LANG=C.UTF-8
# """
RUN apk add --no-cache shadow deno ttf-dejavu \
# Update OpenSSL
- # CVE -> https://scout.docker.com/vulnerabilities/id/CVE-2025-9230?s=alpine&n=openssl&ns=alpine&t=apk&osn=alpine&osv=3.21
+ # CVE -> https://scout.docker.com/vulnerabilities/id/CVE-2025-9230?s=alpine&n=openssl&ns=alpine&t=apk&osn=alpine&osv=3.22
&& apk upgrade --no-cache openssl \
&& groupmod -n rocketchat nogroup \
&& useradd -u 65533 -r -g rocketchat rocketchat
diff --git a/apps/meteor/.docker/Dockerfile.debian b/apps/meteor/.docker/Dockerfile.debian
index 27836bc5eacdf..51fd4e429bcf0 100644
--- a/apps/meteor/.docker/Dockerfile.debian
+++ b/apps/meteor/.docker/Dockerfile.debian
@@ -36,10 +36,18 @@ RUN aptMark="$(apt-mark showmanual)" \
USER rocketchat
RUN cd /app/bundle/programs/server \
- && npm install \
+ && npm install --omit=dev \
&& cd npm/node_modules/isolated-vm \
- && npm install \
- && npm cache clear --force
+ && npm install --omit=dev \
+ && npm cache clear --force \
+ # Drop sourcemaps from third-party prod deps and the client bundle:
+ # - server/npm/node_modules: .map files shipped inside packages
+ # (openpgp, date-fns, etc.). Node does not read them at runtime.
+ # - web.browser: client-side bundle, maps have no runtime value in the image.
+ # Maps under /app/bundle/programs/server/{app,packages,node_modules/meteor}
+ # are loaded by Meteor's source-map-support at boot, so they are kept.
+ && find /app/bundle/programs/server/npm/node_modules -type f -name '*.map' -delete \
+ && find /app/bundle/programs/web.browser -type f -name '*.map' -delete
USER root
diff --git a/apps/meteor/.meteor/packages b/apps/meteor/.meteor/packages
index 0b6c97b193712..5b78dccdc9eb1 100644
--- a/apps/meteor/.meteor/packages
+++ b/apps/meteor/.meteor/packages
@@ -7,33 +7,33 @@ rocketchat:mongo-config
rocketchat:livechat
rocketchat:version
-accounts-base@3.1.2
+accounts-base@3.2.0
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.1
+accounts-password@3.2.2
accounts-twitter@1.5.2
google-oauth@1.4.5
oauth@3.0.2
oauth2@1.3.3
-check@1.4.4
+check@1.5.0
ddp-rate-limiter@1.2.2
rate-limit@1.1.2
email@3.1.2
meteor-base@1.5.2
ddp-common@1.4.4
-webapp@2.0.7
+webapp@2.1.0
-mongo@2.1.4
+mongo@2.2.0
reload@1.3.2
service-configuration@1.3.5
session@1.2.2
-shell-server@0.6.2
+shell-server@0.7.0
dispatch:run-as-user
ostrio:cookies
@@ -50,15 +50,14 @@ tracker@1.3.4
reactive-dict@1.3.2
reactive-var@1.0.13
-babel-compiler@7.12.2
-standard-minifier-css@1.9.3
+standard-minifier-css@1.10.0
dynamic-import@0.7.4
-ecmascript@0.16.13
-typescript@5.6.6
+ecmascript@0.17.0
+typescript@5.9.3
autoupdate@2.0.1
# photoswipe
zodern:types
-zodern:standard-minifier-js
+standard-minifier-js
diff --git a/apps/meteor/.meteor/platforms b/apps/meteor/.meteor/platforms
index 8a3a35f9f6279..62ba921a0c051 100644
--- a/apps/meteor/.meteor/platforms
+++ b/apps/meteor/.meteor/platforms
@@ -1,2 +1,3 @@
-browser
server
+browser
+modern
\ No newline at end of file
diff --git a/apps/meteor/.meteor/release b/apps/meteor/.meteor/release
index 4876d6ff64c16..703a72252657a 100644
--- a/apps/meteor/.meteor/release
+++ b/apps/meteor/.meteor/release
@@ -1 +1 @@
-METEOR@3.3.2
+METEOR@3.4
diff --git a/apps/meteor/.meteor/versions b/apps/meteor/.meteor/versions
index 91adcc4d4e5f2..7ea703c812dd1 100644
--- a/apps/meteor/.meteor/versions
+++ b/apps/meteor/.meteor/versions
@@ -1,19 +1,19 @@
-accounts-base@3.1.2
+accounts-base@3.2.0
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.1
+accounts-password@3.2.2
accounts-twitter@1.5.2
allow-deny@2.1.0
autoupdate@2.0.1
-babel-compiler@7.12.2
+babel-compiler@7.13.0
babel-runtime@1.5.2
base64@1.0.13
binary-heap@1.0.12
-boilerplate-generator@2.0.2
+boilerplate-generator@2.1.0
callback-hook@1.6.1
-check@1.4.4
+check@1.5.0
core-runtime@1.0.0
ddp@1.4.2
ddp-client@3.1.1
@@ -23,7 +23,7 @@ ddp-server@3.1.2
diff-sequence@1.1.3
dispatch:run-as-user@1.1.1
dynamic-import@0.7.4
-ecmascript@0.16.13
+ecmascript@0.17.0
ecmascript-runtime@0.8.3
ecmascript-runtime-client@0.12.3
ecmascript-runtime-server@0.11.1
@@ -41,16 +41,17 @@ id-map@1.2.0
inter-process-messaging@0.1.2
localstorage@1.2.1
logging@1.3.6
-meteor@2.1.1
+meteor@2.2.0
meteor-base@1.5.2
meteor-developer-oauth@1.3.3
meteorhacks:inject-initial@1.0.5
minifier-css@2.0.1
-minimongo@2.0.4
+minifier-js@3.1.0
+minimongo@2.0.5
modern-browsers@0.2.3
modules@0.20.3
modules-runtime@0.13.2
-mongo@2.1.4
+mongo@2.2.0
mongo-decimal@0.2.0
mongo-dev-server@1.1.1
mongo-id@1.0.9
@@ -63,7 +64,7 @@ ostrio:cookies@2.7.2
promise@1.0.0
random@1.2.2
rate-limit@1.1.2
-react-fast-refresh@0.2.9
+react-fast-refresh@0.3.0
reactive-dict@1.3.2
reactive-var@1.0.13
reload@1.3.2
@@ -75,16 +76,15 @@ routepolicy@1.1.2
service-configuration@1.3.5
session@1.2.2
sha@1.0.10
-shell-server@0.6.2
+shell-server@0.7.0
socket-stream-client@0.6.1
-standard-minifier-css@1.9.3
+standard-minifier-css@1.10.0
tracker@1.3.4
twitter-oauth@1.3.4
-typescript@5.6.6
+typescript@5.9.3
underscore@1.6.4
url@1.3.5
-webapp@2.0.7
+webapp@2.1.0
webapp-hashing@1.1.2
-zodern:caching-minifier@0.5.0
-zodern:standard-minifier-js@5.3.1
+standard-minifier-js@3.2.0
zodern:types@1.0.13
diff --git a/apps/meteor/.storybook/main.ts b/apps/meteor/.storybook/main.ts
index 1cceaff3ff602..4a2a93be5e7bc 100644
--- a/apps/meteor/.storybook/main.ts
+++ b/apps/meteor/.storybook/main.ts
@@ -43,6 +43,14 @@ export default {
type: 'json',
});
+ // Strip the `env` option that addon-webpack5-compiler-swc injects on swc-loader;
+ // it conflicts with `jsc.target` from `.swcrc` (Meteor's Modern Build Stack).
+ for (const rule of (config.module?.rules ?? []) as any[]) {
+ for (const use of Array.isArray(rule?.use) ? rule.use : []) {
+ if (use?.loader?.includes?.('swc-loader') && use.options) delete use.options.env;
+ }
+ }
+
config.plugins?.push(
new webpack.NormalModuleReplacementPlugin(/^meteor/, require.resolve('./mocks/meteor.js')),
new webpack.NormalModuleReplacementPlugin(/(app)\/*.*\/(server)\/*/, require.resolve('./mocks/empty.ts')),
diff --git a/apps/meteor/.swcrc b/apps/meteor/.swcrc
new file mode 100644
index 0000000000000..003417d6c0619
--- /dev/null
+++ b/apps/meteor/.swcrc
@@ -0,0 +1,10 @@
+{
+ "jsc": {
+ "target": "es2022",
+ "transform": {
+ "react": {
+ "runtime": "automatic"
+ }
+ }
+ }
+}
diff --git a/apps/meteor/CHANGELOG.md b/apps/meteor/CHANGELOG.md
index f8e1d6ccfd84a..ee5923eeb063f 100644
--- a/apps/meteor/CHANGELOG.md
+++ b/apps/meteor/CHANGELOG.md
@@ -1,5 +1,90 @@
# @rocket.chat/meteor
+## 8.4.0-rc.2
+
+### Patch Changes
+
+- Bump @rocket.chat/meteor version.
+
+- Updated dependencies []:
+
+ - @rocket.chat/core-typings@8.4.0-rc.2
+ - @rocket.chat/rest-typings@8.4.0-rc.2
+ - @rocket.chat/abac@0.2.0-rc.2
+ - @rocket.chat/federation-matrix@0.1.3-rc.2
+ - @rocket.chat/license@1.1.16-rc.2
+ - @rocket.chat/media-calls@0.4.0-rc.2
+ - @rocket.chat/omnichannel-services@0.3.53-rc.2
+ - @rocket.chat/pdf-worker@0.3.35-rc.2
+ - @rocket.chat/presence@0.2.56-rc.2
+ - @rocket.chat/api-client@0.2.56-rc.2
+ - @rocket.chat/apps@0.6.9-rc.2
+ - @rocket.chat/core-services@0.14.0-rc.2
+ - @rocket.chat/cron@0.1.56-rc.2
+ - @rocket.chat/fuselage-ui-kit@30.0.0-rc.2
+ - @rocket.chat/gazzodown@30.0.0-rc.2
+ - @rocket.chat/http-router@7.9.23-rc.2
+ - @rocket.chat/message-types@0.1.1
+ - @rocket.chat/model-typings@2.2.0-rc.2
+ - @rocket.chat/models@2.2.0-rc.2
+ - @rocket.chat/ui-avatar@26.0.0-rc.2
+ - @rocket.chat/ui-client@30.0.0-rc.2
+ - @rocket.chat/ui-contexts@30.0.0-rc.2
+ - @rocket.chat/ui-voip@20.0.0-rc.2
+ - @rocket.chat/web-ui-registration@30.0.0-rc.2
+ - @rocket.chat/server-cloud-communication@0.0.3
+ - @rocket.chat/network-broker@0.2.35-rc.2
+ - @rocket.chat/omni-core-ee@0.0.21-rc.2
+ - @rocket.chat/instance-status@0.1.56-rc.2
+ - @rocket.chat/omni-core@0.1.0-rc.2
+ - @rocket.chat/server-fetch@0.2.0-rc.2
+ - @rocket.chat/ui-video-conf@30.0.0-rc.2
+ - @rocket.chat/ui-composer@1.0.0-rc.2
+
+
+## 8.4.0-rc.1
+
+### Patch Changes
+
+- Bump @rocket.chat/meteor version.
+
+- Updated dependencies [2632182e429d337325fe3b1e28ea52ab12d99591]:
+
+ - @rocket.chat/media-signaling@1.0.0-rc.1
+ - @rocket.chat/media-calls@0.4.0-rc.1
+ - @rocket.chat/core-services@0.14.0-rc.1
+ - @rocket.chat/ui-voip@20.0.0-rc.1
+ - @rocket.chat/abac@0.2.0-rc.1
+ - @rocket.chat/federation-matrix@0.1.3-rc.1
+ - @rocket.chat/network-broker@0.2.35-rc.1
+ - @rocket.chat/omni-core-ee@0.0.21-rc.1
+ - @rocket.chat/omnichannel-services@0.3.53-rc.1
+ - @rocket.chat/presence@0.2.56-rc.1
+ - @rocket.chat/ui-contexts@30.0.0-rc.1
+ - @rocket.chat/fuselage-ui-kit@30.0.0-rc.1
+ - @rocket.chat/gazzodown@30.0.0-rc.1
+ - @rocket.chat/ui-avatar@26.0.0-rc.1
+ - @rocket.chat/ui-client@30.0.0-rc.1
+ - @rocket.chat/ui-video-conf@30.0.0-rc.1
+ - @rocket.chat/web-ui-registration@30.0.0-rc.1
+ - @rocket.chat/ui-composer@1.0.0-rc.1
+ - @rocket.chat/core-typings@8.4.0-rc.1
+ - @rocket.chat/rest-typings@8.4.0-rc.1
+ - @rocket.chat/license@1.1.16-rc.1
+ - @rocket.chat/pdf-worker@0.3.35-rc.1
+ - @rocket.chat/api-client@0.2.56-rc.1
+ - @rocket.chat/apps@0.6.9-rc.1
+ - @rocket.chat/cron@0.1.56-rc.1
+ - @rocket.chat/http-router@7.9.23-rc.1
+ - @rocket.chat/message-types@0.1.1
+ - @rocket.chat/model-typings@2.2.0-rc.1
+ - @rocket.chat/models@2.2.0-rc.1
+ - @rocket.chat/server-cloud-communication@0.0.3
+ - @rocket.chat/instance-status@0.1.56-rc.1
+ - @rocket.chat/omni-core@0.1.0-rc.1
+ - @rocket.chat/server-fetch@0.2.0-rc.1
+
+
## 8.3.2
### Patch Changes
@@ -91,6 +176,125 @@
- @rocket.chat/server-fetch@0.1.3
+## 8.4.0-rc.0
+
+### Minor Changes
+
+- ([#40181](https://github.com/RocketChat/Rocket.Chat/pull/40181)) Adds file thumbnails with image preview to the message composer attachments
+
+- ([#40141](https://github.com/RocketChat/Rocket.Chat/pull/40141)) Adds a new REST endpoint to accept or reject media calls without an active media session
+
+- ([#39535](https://github.com/RocketChat/Rocket.Chat/pull/39535)) Adds externalIds field to livechat visitors for external platform identification.
+
+- ([#40160](https://github.com/RocketChat/Rocket.Chat/pull/40160)) Adds a `skipTranspile` flag (default `false`) to webhook integrations. When set to `true`, the integration script is stored as-is without Babel transpilation — matching the 9.0.0 default where Babel is removed entirely. Admins can flip the flag per-integration to validate strict-mode compatibility before upgrading. The field is deprecated and will be removed in 9.0.0.
+
+- ([#39495](https://github.com/RocketChat/Rocket.Chat/pull/39495)) Updates omnichannel routing so agents with `offline` status are always excluded from assignment. The `Livechat_enabled_when_agent_idle` setting now only affects agents with `away` status.
+
+- ([#40093](https://github.com/RocketChat/Rocket.Chat/pull/40093)) Adds new API endpoints to load the user's current voice call state from the server
+
+- ([#40096](https://github.com/RocketChat/Rocket.Chat/pull/40096)) Introduces redaction of potentially sensitive data in logs related to apps-engine
+
+- ([#39989](https://github.com/RocketChat/Rocket.Chat/pull/39989)) Adds email search filter to `users.list` and `users.info` endpoints.
+
+- ([#39845](https://github.com/RocketChat/Rocket.Chat/pull/39845)) Adds support for setting up Virtru as a PDP (Policy Decision Point) for ABAC.
+
+- ([#38623](https://github.com/RocketChat/Rocket.Chat/pull/38623) by [@copilot-swe-agent](https://github.com/copilot-swe-agent)) Introduces Cold Storage Archiving for Read Receipts to improve performance and scalability in large deployments.
+
+ Enterprise workspaces can now archive older read receipts into a dedicated cold storage collection, reducing the size of the primary read receipts dataset and improving query performance in environments with high message volumes.
+
+ This feature is disabled by default and can be enabled through the new setting:
+
+ **Message → Read Receipts → Enable Read Receipts Cold Storage**
+
+ This feature is especially recommended for deployments with high message throughput and long data retention requirements, where reducing the size of hot collections significantly improves overall system responsiveness.
+
+- ([#39393](https://github.com/RocketChat/Rocket.Chat/pull/39393) by [@copilot-swe-agent](https://github.com/copilot-swe-agent)) Added auto-wrap selected text in composer with matching delimiters
+
+- ([#40075](https://github.com/RocketChat/Rocket.Chat/pull/40075)) Adds alternative text field to image uploads to improve accessibility
+
+### Patch Changes
+
+- ([#40058](https://github.com/RocketChat/Rocket.Chat/pull/40058)) Fixed UI becoming unresponsive after clicking "See on Engagement Dashboard" from the workspace info card, which required a manual page refresh to recover.
+
+- ([#39741](https://github.com/RocketChat/Rocket.Chat/pull/39741)) Fixes empty notifications sent when a voice call ends
+
+- ([#40095](https://github.com/RocketChat/Rocket.Chat/pull/40095)) LDAP `sync now` action now syncs user's abac attributes too.
+
+- ([#39246](https://github.com/RocketChat/Rocket.Chat/pull/39246)) Fixes an issue where messages appeared as unread even when all active users had read them. Read receipts now correctly ignore deactivated users.
+
+- ([#40125](https://github.com/RocketChat/Rocket.Chat/pull/40125)) Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates)
+
+- ([#40051](https://github.com/RocketChat/Rocket.Chat/pull/40051)) Fixes user status indicator to show disabled state when presence broadcast is turned off
+
+- ([#40061](https://github.com/RocketChat/Rocket.Chat/pull/40061)) Fixes a bug that could remove all of a user's subscriptions when the user was re-added to a room while still banned.
+
+- ([#40053](https://github.com/RocketChat/Rocket.Chat/pull/40053)) Fixes Slack messages being incorrectly saved on import
+
+- ([#38994](https://github.com/RocketChat/Rocket.Chat/pull/38994)) Adds SAML redirect validation by matching request parameters and configured IdP SLO
+
+- ([#39986](https://github.com/RocketChat/Rocket.Chat/pull/39986)) Fixes an issue where the outlook calendar action isn't being reactive based on the setting
+
+- ([#39868](https://github.com/RocketChat/Rocket.Chat/pull/39868)) Fixes app actions ignoring role filters and i18n translation
+
+- ([#40078](https://github.com/RocketChat/Rocket.Chat/pull/40078)) Fixes "Reply in direct message" action not being shown when user has permission to create DMs but no existing conversation exists.
+
+- ([#40169](https://github.com/RocketChat/Rocket.Chat/pull/40169)) Moves keyboard shortcuts from the contextual bar into a modal accessible from the user menu, and adds a hotkey to open it.
+
+- ([#39883](https://github.com/RocketChat/Rocket.Chat/pull/39883)) Fixes the issue of the lacking MessageUpdater not being available to apps during runtime
+
+- ([#40114](https://github.com/RocketChat/Rocket.Chat/pull/40114)) Fixes a bug preventing users to join a room after being banned and unbanned.
+
+- ([#39823](https://github.com/RocketChat/Rocket.Chat/pull/39823)) Fixes an issue where the apps-engine updateStatusText method isn't updating the app user status text properly
+
+- ([#39892](https://github.com/RocketChat/Rocket.Chat/pull/39892)) Fixes an issue where the expanded thread view was overlapping the navbar
+
+- ([#40113](https://github.com/RocketChat/Rocket.Chat/pull/40113)) Fixes inability to use custom sounds pagination action buttons when the amount exceeds the specified limit
+
+- ([#39985](https://github.com/RocketChat/Rocket.Chat/pull/39985)) Adds error feedback when clicking on a mentioned room you don't have access to
+
+- ([#40175](https://github.com/RocketChat/Rocket.Chat/pull/40175)) Fixes an issue where the Omnichannel routing system ignored the `Livechat_accept_chats_with_no_agents` setting. Now, offline agents are correctly considered for assignment when the setting allows it.
+
+- Updated dependencies [53e32c7df1bf40598d65d170fd50c55f752f2951, 53e32c7df1bf40598d65d170fd50c55f752f2951, 543b6c8cbde0d084a3a923acf4845b68db80206a, 43642220a5cc8124ecc6f3956150c42c5b390c90, 21cd54f87de5837fe6c999a44bd15be34b9fe905, 278b84f78360e53792a2e5d7620615039a0e15e9, 95a82f72dd45fc51d54bb1beed295315facf9109, 12c44d2db65af9f90c741da621164f5738fc19f5, 24b3671fe61b8b09c6a1b5dc6401b503b3fb92a0, 5cff1f41d388b036c77a8f774f233b1921e9066e, 9713af36f5c0d673f2d2093015f322341706bab0, 53e32c7df1bf40598d65d170fd50c55f752f2951, 53e32c7df1bf40598d65d170fd50c55f752f2951, f3fa3ee2f2e53b777de9abd466f1b76a1ec1b96c, 32f67f20fbcfdab051c7d2f99d8a7a3d18ebf474, e792aba7f56f1b2ece62951087591819995a1446, f3649b22edfe6497ec2d7f3d7b8ec655466ad4a6, c544b805d1c03f8eae9d061cd48838206207a7c9, 4e99ff6c1a99ac569cf444f9de9b5fe5ac3262b2, 8c0e16ca29b393cfa50b425520db48ba5a74f678, 95a82f72dd45fc51d54bb1beed295315facf9109, 9f38b54c671ba3f0583c0d248a2afe09652fcdf4, f4dfb8ddc2049692371aeb084110b5768151b5df, 2356c889ed82507e1dd1208fab6d4ab186701fef]:
+
+ - @rocket.chat/message-parser@0.31.36-rc.0
+ - @rocket.chat/ui-composer@1.0.0-rc.0
+ - @rocket.chat/ui-client@30.0.0-rc.0
+ - @rocket.chat/ui-voip@20.0.0-rc.0
+ - @rocket.chat/media-calls@0.4.0-rc.0
+ - @rocket.chat/core-services@0.14.0-rc.0
+ - @rocket.chat/model-typings@2.2.0-rc.0
+ - @rocket.chat/models@2.2.0-rc.0
+ - @rocket.chat/media-signaling@0.3.0-rc.0
+ - @rocket.chat/core-typings@8.4.0-rc.0
+ - @rocket.chat/apps-engine@1.62.0-rc.0
+ - @rocket.chat/omni-core@0.1.0-rc.0
+ - @rocket.chat/server-fetch@0.2.0-rc.0
+ - @rocket.chat/rest-typings@8.4.0-rc.0
+ - @rocket.chat/i18n@3.0.0-rc.0
+ - @rocket.chat/federation-matrix@0.1.1-rc.0
+ - @rocket.chat/abac@0.2.0-rc.0
+ - @rocket.chat/tools@0.3.0-rc.0
+ - @rocket.chat/pdf-worker@0.3.33-rc.0
+ - @rocket.chat/gazzodown@30.0.0-rc.0
+ - @rocket.chat/web-ui-registration@30.0.0-rc.0
+ - @rocket.chat/fuselage-ui-kit@30.0.0-rc.0
+ - @rocket.chat/network-broker@0.2.33-rc.0
+ - @rocket.chat/omni-core-ee@0.0.19-rc.0
+ - @rocket.chat/omnichannel-services@0.3.51-rc.0
+ - @rocket.chat/presence@0.2.54-rc.0
+ - @rocket.chat/apps@0.6.7-rc.0
+ - @rocket.chat/cron@0.1.54-rc.0
+ - @rocket.chat/instance-status@0.1.54-rc.0
+ - @rocket.chat/license@1.1.14-rc.0
+ - @rocket.chat/api-client@0.2.54-rc.0
+ - @rocket.chat/http-router@7.9.21-rc.0
+ - @rocket.chat/message-types@0.1.1
+ - @rocket.chat/ui-avatar@26.0.0-rc.0
+ - @rocket.chat/ui-contexts@30.0.0-rc.0
+ - @rocket.chat/server-cloud-communication@0.0.3
+ - @rocket.chat/ui-video-conf@30.0.0-rc.0
+
+
## 8.3.0
### Minor Changes
diff --git a/apps/meteor/app/api/server/ApiClass.ts b/apps/meteor/app/api/server/ApiClass.ts
index 2610bc2903911..d51726049e0dd 100644
--- a/apps/meteor/app/api/server/ApiClass.ts
+++ b/apps/meteor/app/api/server/ApiClass.ts
@@ -37,6 +37,7 @@ import type {
UnavailableResult,
GenericRouteExecutionContext,
TooManyRequestsResult,
+ SuccessStatusCodes,
} from './definition';
import { getUserInfo } from './helpers/getUserInfo';
import { parseJsonQuery } from './helpers/parseJsonQuery';
@@ -266,15 +267,15 @@ export class APIClass;
- public success(result: T): SuccessResult;
+ public success(result: T, statusCode?: SuccessStatusCodes): SuccessResult;
- public success(result: T = {} as T): SuccessResult {
+ public success(result: T = {} as T, statusCode: SuccessStatusCodes = 200): SuccessResult {
if (isObject(result)) {
(result as Record).success = true;
}
const finalResult = {
- statusCode: 200,
+ statusCode,
body: result,
} as SuccessResult;
@@ -288,6 +289,8 @@ export class APIClass;
+
public failure(result?: T): FailureResult;
public failure(
@@ -363,6 +366,10 @@ export class APIClass;
+
+ public unauthorized(msg: T): UnauthorizedResult;
+
public unauthorized(msg?: T): UnauthorizedResult {
return {
statusCode: 401,
@@ -373,6 +380,10 @@ export class APIClass;
+
+ public forbidden(msg: T): ForbiddenResult;
+
public forbidden(msg?: T): ForbiddenResult {
return {
statusCode: 403,
diff --git a/apps/meteor/app/api/server/ajv.ts b/apps/meteor/app/api/server/ajv.ts
index 00944159988ee..10556de26582f 100644
--- a/apps/meteor/app/api/server/ajv.ts
+++ b/apps/meteor/app/api/server/ajv.ts
@@ -3,6 +3,13 @@ import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
const components = schemas.components?.schemas;
if (components) {
+ // Patch MessageAttachmentDefault to reject unknown properties so the oneOf
+ // discriminator works correctly (otherwise it matches every attachment).
+ const mad = components.MessageAttachmentDefault;
+ if (mad && typeof mad === 'object' && 'type' in mad) {
+ (mad as Record).additionalProperties = false;
+ }
+
for (const key in components) {
if (Object.prototype.hasOwnProperty.call(components, key)) {
const uri = `#/components/schemas/${key}`;
diff --git a/apps/meteor/app/api/server/default/info.ts b/apps/meteor/app/api/server/default/info.ts
index 173a733d13b47..71d570382b47e 100644
--- a/apps/meteor/app/api/server/default/info.ts
+++ b/apps/meteor/app/api/server/default/info.ts
@@ -1,12 +1,28 @@
+import type { IWorkspaceInfo } from '@rocket.chat/core-typings';
+import { ajv } from '@rocket.chat/rest-typings';
+
import { API } from '../api';
import { getServerInfo } from '../lib/getServerInfo';
-API.default.addRoute(
+const infoResponseSchema = ajv.compile({
+ type: 'object',
+ properties: {
+ version: { type: 'string' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['success'],
+ additionalProperties: true,
+});
+
+API.default.get(
'info',
- { authRequired: false },
{
- async get() {
- return API.v1.success(await getServerInfo(this.userId));
+ authRequired: false,
+ response: {
+ 200: infoResponseSchema,
},
},
+ async function action() {
+ return API.v1.success(await getServerInfo(this.userId));
+ },
);
diff --git a/apps/meteor/app/api/server/default/openApi.ts b/apps/meteor/app/api/server/default/openApi.ts
index d59e6b8b08c22..c4218da076d7e 100644
--- a/apps/meteor/app/api/server/default/openApi.ts
+++ b/apps/meteor/app/api/server/default/openApi.ts
@@ -1,6 +1,6 @@
import { schemas } from '@rocket.chat/core-typings';
import type { Route } from '@rocket.chat/http-router';
-import { isOpenAPIJSONEndpoint } from '@rocket.chat/rest-typings';
+import { ajv, isOpenAPIJSONEndpoint } from '@rocket.chat/rest-typings';
import express from 'express';
import { WebApp } from 'meteor/webapp';
import swaggerUi from 'swagger-ui-express';
@@ -72,16 +72,35 @@ const makeOpenAPIResponse = (paths: Record>) => ({
paths,
});
-API.default.addRoute(
+const openApiResponseSchema = ajv.compile>({
+ type: 'object',
+ properties: {
+ openapi: { type: 'string' },
+ info: { type: 'object' },
+ servers: { type: 'array' },
+ components: { type: 'object' },
+ paths: { type: 'object' },
+ schemas: { type: 'object' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['openapi', 'info', 'paths', 'success'],
+ additionalProperties: false,
+});
+
+API.default.get(
'docs/json',
- { authRequired: false, validateParams: isOpenAPIJSONEndpoint },
{
- get() {
- const { withUndocumented = false } = this.queryParams;
-
- return API.default.success(makeOpenAPIResponse(getTypedRoutes(API.api.typedRoutes, { withUndocumented })));
+ authRequired: false,
+ query: isOpenAPIJSONEndpoint,
+ response: {
+ 200: openApiResponseSchema,
},
},
+ function action() {
+ const { withUndocumented = false } = this.queryParams;
+
+ return API.default.success(makeOpenAPIResponse(getTypedRoutes(API.api.typedRoutes, { withUndocumented })));
+ },
);
app.use(
diff --git a/apps/meteor/app/api/server/definition.ts b/apps/meteor/app/api/server/definition.ts
index fc33864687885..9898a33ae27af 100644
--- a/apps/meteor/app/api/server/definition.ts
+++ b/apps/meteor/app/api/server/definition.ts
@@ -318,6 +318,16 @@ export type TypedThis
requestIp?: string;
route: string;
response: Response;
+ readonly queryOperations: TOptions extends { queryOperations: infer T } ? T : never;
+ readonly queryFields: TOptions extends { queryFields: infer T } ? T : never;
+ readonly connection: {
+ token: string;
+ id: string;
+ close: () => void;
+ clientAddress: string;
+ httpHeaders: Record;
+ };
+ readonly twoFactorChecked: boolean;
} & (TOptions['authRequired'] extends true
? {
user: TOptions extends { userWithoutUsername: true } ? IUser : RequiredField;
diff --git a/apps/meteor/app/api/server/helpers/getUserInfo.ts b/apps/meteor/app/api/server/helpers/getUserInfo.ts
index e567d7b468546..0e3eee9c3384f 100644
--- a/apps/meteor/app/api/server/helpers/getUserInfo.ts
+++ b/apps/meteor/app/api/server/helpers/getUserInfo.ts
@@ -1,4 +1,4 @@
-import { isOAuthUser, type IUser, type IUserEmail, type IUserCalendar } from '@rocket.chat/core-typings';
+import { isOAuthUser, type IMeApiUser, type IUser, type IUserEmail, type IUserCalendar } from '@rocket.chat/core-typings';
import semver from 'semver';
import { settings } from '../../../settings/server';
@@ -84,15 +84,7 @@ const getUserCalendar = (email: false | IUserEmail | undefined): IUserCalendar =
return calendarSettings;
};
-export async function getUserInfo(
- me: IUser,
- pullPreferences = true,
-): Promise<
- IUser & {
- email?: string;
- avatarUrl: string;
- }
-> {
+export async function getUserInfo(me: IUser, pullPreferences = true): Promise {
const verifiedEmail = isVerifiedEmail(me);
const userPreferences = me.settings?.preferences ?? {};
@@ -110,8 +102,8 @@ export async function getUserInfo(
isOAuthUser: isOAuthUser(me),
...(me.services && {
services: {
- ...(me.services.github && { github: me.services.github }),
- ...(me.services.gitlab && { gitlab: me.services.gitlab }),
+ ...(me.services.github && { github: me.services.github as Record }),
+ ...(me.services.gitlab && { gitlab: me.services.gitlab as Record }),
...(me.services.email2fa?.enabled && { email2fa: { enabled: me.services.email2fa.enabled } }),
...(me.services.totp?.enabled && { totp: { enabled: me.services.totp.enabled } }),
password: {
@@ -120,5 +112,6 @@ export async function getUserInfo(
},
},
}),
- };
+ // Cast needed: spread of full IUser produces a superset; runtime response schema validates the actual shape
+ } as IMeApiUser;
}
diff --git a/apps/meteor/app/api/server/index.ts b/apps/meteor/app/api/server/index.ts
index 176141af83e08..5a6a6f06cbbab 100644
--- a/apps/meteor/app/api/server/index.ts
+++ b/apps/meteor/app/api/server/index.ts
@@ -21,6 +21,7 @@ import './v1/integrations';
import './v1/invites';
import './v1/import';
import './v1/ldap';
+import './v1/media-calls';
import './v1/misc';
import './v1/permissions';
import './v1/presence';
diff --git a/apps/meteor/app/api/server/lib/rooms.ts b/apps/meteor/app/api/server/lib/rooms.ts
index 14b43c1e83d2d..d2bac79d6f790 100644
--- a/apps/meteor/app/api/server/lib/rooms.ts
+++ b/apps/meteor/app/api/server/lib/rooms.ts
@@ -153,7 +153,7 @@ export async function findChannelAndPrivateAutocompleteWithPagination({
};
}
-export async function findRoomsAvailableForTeams({ uid, name }: { uid: string; name: string }): Promise<{
+export async function findRoomsAvailableForTeams({ uid, name }: { uid: string; name?: string }): Promise<{
items: IRoom[];
}> {
const options: FindOptions = {
diff --git a/apps/meteor/app/api/server/lib/users.ts b/apps/meteor/app/api/server/lib/users.ts
index 00dfc3e8f5292..19e9ccba16585 100644
--- a/apps/meteor/app/api/server/lib/users.ts
+++ b/apps/meteor/app/api/server/lib/users.ts
@@ -126,11 +126,11 @@ type FindPaginatedUsersByStatusProps = {
offset: number;
count: number;
sort: Record;
- status: 'active' | 'deactivated';
- roles: string[] | null;
- searchTerm: string;
- hasLoggedIn: boolean;
- type: string;
+ status?: 'active' | 'deactivated';
+ roles?: string[] | null;
+ searchTerm?: string;
+ hasLoggedIn?: boolean;
+ type?: string;
inactiveReason?: ('deactivated' | 'pending_approval' | 'idle_too_long')[];
};
diff --git a/apps/meteor/app/api/server/middlewares/logger.ts b/apps/meteor/app/api/server/middlewares/logger.ts
index 6c56de6cfceb0..17759687390df 100644
--- a/apps/meteor/app/api/server/middlewares/logger.ts
+++ b/apps/meteor/app/api/server/middlewares/logger.ts
@@ -1,4 +1,5 @@
import type { Logger } from '@rocket.chat/logger';
+import { censorUrl } from '@rocket.chat/tools';
import type { MiddlewareHandler } from 'hono';
import { getRestPayload } from '../../../../server/lib/logger/logPayloads';
@@ -11,7 +12,7 @@ export const loggerMiddleware =
const log = logger.logger.child(
{
method: c.req.method,
- url: c.req.url,
+ url: censorUrl(c.req.url),
userId: c.req.header('x-user-id'),
userAgent: c.req.header('user-agent'),
length: c.req.header('content-length'),
diff --git a/apps/meteor/app/api/server/v1/autotranslate.ts b/apps/meteor/app/api/server/v1/autotranslate.ts
index 74d857f5b63fa..4d9c734daaae1 100644
--- a/apps/meteor/app/api/server/v1/autotranslate.ts
+++ b/apps/meteor/app/api/server/v1/autotranslate.ts
@@ -91,16 +91,8 @@ const autotranslateEndpoints = API.v1
return API.v1.failure('AutoTranslate is disabled.');
}
- if (!roomId) {
- return API.v1.failure('The bodyParam "roomId" is required.');
- }
- if (!field) {
- return API.v1.failure('The bodyParam "field" is required.');
- }
- if (value === undefined) {
- return API.v1.failure('The bodyParam "value" is required.');
- }
- if (field === 'autoTranslate' && typeof value !== 'boolean') {
+ // ajv 2020-12 with coerceTypes coerces booleans to strings, so check for both
+ if (field === 'autoTranslate' && value !== true && value !== 'true' && value !== false && value !== 'false') {
return API.v1.failure('The bodyParam "autoTranslate" must be a boolean.');
}
@@ -108,7 +100,7 @@ const autotranslateEndpoints = API.v1
return API.v1.failure('The bodyParam "autoTranslateLanguage" must be a string.');
}
- await saveAutoTranslateSettings(this.userId, roomId, field, value === true ? '1' : String(value).valueOf(), {
+ await saveAutoTranslateSettings(this.userId, roomId, field, value === true || value === 'true' ? '1' : String(value), {
defaultLanguage: defaultLanguage || '',
});
diff --git a/apps/meteor/app/api/server/v1/call-history.ts b/apps/meteor/app/api/server/v1/call-history.ts
index 33412c6ae58c7..b14c5621e9149 100644
--- a/apps/meteor/app/api/server/v1/call-history.ts
+++ b/apps/meteor/app/api/server/v1/call-history.ts
@@ -19,7 +19,7 @@ import { getPaginationItems } from '../helpers/getPaginationItems';
type CallHistoryList = PaginatedRequest<{
filter?: string;
direction?: CallHistoryItem['direction'];
- state?: CallHistoryItemState[] | CallHistoryItemState;
+ state?: CallHistoryItemState[];
}>;
const CallHistoryListSchema = {
@@ -42,20 +42,10 @@ const CallHistoryListSchema = {
enum: ['inbound', 'outbound'],
},
state: {
- // our clients serialize arrays as `state=value1&state=value2`, but if there's a single value the parser doesn't know it is an array, so we need to support both arrays and direct values
- // if a client tries to send a JSON array, our parser will treat it as a string and the type validation will reject it
- // This means this param won't work from Swagger UI
- oneOf: [
- {
- type: 'array',
- items: {
- $ref: '#/components/schemas/CallHistoryItemState',
- },
- },
- {
- $ref: '#/components/schemas/CallHistoryItemState',
- },
- ],
+ type: 'array',
+ items: {
+ $ref: '#/components/schemas/CallHistoryItemState',
+ },
},
},
required: [],
diff --git a/apps/meteor/app/api/server/v1/channels.ts b/apps/meteor/app/api/server/v1/channels.ts
index eaeab9a9bb108..84cd4ebbf03fc 100644
--- a/apps/meteor/app/api/server/v1/channels.ts
+++ b/apps/meteor/app/api/server/v1/channels.ts
@@ -640,7 +640,7 @@ API.v1.addRoute(
const lm = room.lm ? room.lm : room._updatedAt;
if (subscription?.open) {
- unreads = await Messages.countVisibleByRoomIdBetweenTimestampsInclusive(subscription.rid, subscription.ls, lm);
+ unreads = await Messages.countVisibleByRoomIdBetweenTimestampsInclusive(subscription.rid, subscription.ls ?? subscription.ts, lm);
unreadsFrom = subscription.ls || subscription.ts;
userMentions = subscription.userMentions;
joined = true;
diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts
index a00d57e46ae72..383f4312c53b5 100644
--- a/apps/meteor/app/api/server/v1/chat.ts
+++ b/apps/meteor/app/api/server/v1/chat.ts
@@ -127,118 +127,6 @@ const isChatFollowMessageLocalProps = ajv.compile(ChatFo
const isChatUnfollowMessageLocalProps = ajv.compile(ChatUnfollowMessageLocalSchema);
-API.v1.addRoute(
- 'chat.delete',
- { authRequired: true, validateParams: isChatDeleteProps },
- {
- async post() {
- const msg = await Messages.findOneById(this.bodyParams.msgId, { projection: { u: 1, rid: 1 } });
-
- if (!msg) {
- return API.v1.failure(`No message found with the id of "${this.bodyParams.msgId}".`);
- }
-
- if (this.bodyParams.roomId !== msg.rid) {
- return API.v1.failure('The room id provided does not match where the message is from.');
- }
-
- if (
- this.bodyParams.asUser &&
- msg.u._id !== this.userId &&
- !(await hasPermissionAsync(this.userId, 'force-delete-message', msg.rid))
- ) {
- return API.v1.failure('Unauthorized. You must have the permission "force-delete-message" to delete other\'s message as them.');
- }
-
- const userId = this.bodyParams.asUser ? msg.u._id : this.userId;
- const user = await Users.findOneById(userId, { projection: { _id: 1 } });
-
- if (!user) {
- return API.v1.failure('User not found');
- }
-
- await deleteMessageValidatingPermission(msg, user._id);
-
- return API.v1.success({
- _id: msg._id,
- ts: Date.now().toString(),
- message: msg,
- });
- },
- },
-);
-
-API.v1.addRoute(
- 'chat.syncMessages',
- { authRequired: true, validateParams: isChatSyncMessagesProps },
- {
- async get() {
- const { roomId, lastUpdate, count, next, previous, type } = this.queryParams;
-
- if (!roomId) {
- throw new Meteor.Error('error-param-required', 'The required "roomId" query param is missing');
- }
-
- if (!lastUpdate && !type) {
- throw new Meteor.Error('error-param-required', 'The "type" or "lastUpdate" parameters must be provided');
- }
-
- if (lastUpdate && isNaN(Date.parse(lastUpdate))) {
- throw new Meteor.Error('error-lastUpdate-param-invalid', 'The "lastUpdate" query parameter must be a valid date');
- }
-
- const getMessagesQuery = {
- ...(lastUpdate && { lastUpdate: new Date(lastUpdate) }),
- ...(next && { next }),
- ...(previous && { previous }),
- ...(count && { count }),
- ...(type && { type }),
- };
-
- const result = await getMessageHistory(roomId, this.userId, getMessagesQuery);
-
- if (!result) {
- return API.v1.failure();
- }
-
- return API.v1.success({
- result: {
- updated: 'updated' in result ? await normalizeMessagesForUser(result.updated, this.userId) : [],
- deleted: 'deleted' in result ? result.deleted : [],
- cursor: 'cursor' in result ? result.cursor : undefined,
- },
- });
- },
- },
-);
-
-API.v1.addRoute(
- 'chat.getMessage',
- {
- authRequired: true,
- validateParams: isChatGetMessageProps,
- },
- {
- async get() {
- if (!this.queryParams.msgId) {
- return API.v1.failure('The "msgId" query parameter must be provided.');
- }
-
- const msg = await getSingleMessage(this.userId, this.queryParams.msgId);
-
- if (!msg) {
- return API.v1.failure();
- }
-
- const [message] = await normalizeMessagesForUser([msg], this.userId);
-
- return API.v1.success({
- message,
- });
- },
- },
-);
-
type ChatPinMessage = {
messageId: IMessage['_id'];
};
@@ -359,7 +247,7 @@ const chatEndpoints = API.v1
200: ajv.compile<{ message: IMessage }>({
type: 'object',
properties: {
- message: { type: 'object' },
+ message: { $ref: '#/components/schemas/IMessage' },
success: {
type: 'boolean',
enum: [true],
@@ -558,106 +446,29 @@ const chatEndpoints = API.v1
return API.v1.success();
},
- );
-
-API.v1.addRoute(
- 'chat.postMessage',
- { authRequired: true, validateParams: isChatPostMessageProps },
- {
- async post() {
- const { text, attachments } = this.bodyParams;
- const maxAllowedSize = settings.get('Message_MaxAllowedSize') ?? 0;
-
- if (text && text.length > maxAllowedSize) {
- return API.v1.failure('error-message-size-exceeded');
- }
-
- if (attachments && attachments.length > 0) {
- for (const attachment of attachments) {
- if (attachment.text && attachment.text.length > maxAllowedSize) {
- return API.v1.failure('error-message-size-exceeded');
- }
- }
- }
-
- const messageReturn = (await applyAirGappedRestrictionsValidation(() => processWebhookMessage(this.bodyParams, this.user)))[0];
-
- if (!messageReturn?.message) {
- return API.v1.failure('unknown-error');
- }
-
- const [message] = await normalizeMessagesForUser([messageReturn.message], this.userId);
-
- return API.v1.success({
- ts: Date.now(),
- channel: messageReturn.channel,
- message,
- });
- },
- },
-);
-
-API.v1.addRoute(
- 'chat.search',
- { authRequired: true, validateParams: isChatSearchProps },
- {
- async get() {
- const { roomId, searchText } = this.queryParams;
- const { offset, count } = await getPaginationItems(this.queryParams);
-
- if (!roomId) {
- throw new Meteor.Error('error-roomId-param-not-provided', 'The required "roomId" query param is missing.');
- }
-
- if (!searchText) {
- throw new Meteor.Error('error-searchText-param-not-provided', 'The required "searchText" query param is missing.');
- }
-
- const searchResult = await messageSearch(this.userId, searchText, roomId, count, offset);
- if (searchResult === false) {
- return API.v1.failure();
- }
- if (!searchResult.message) {
- return API.v1.failure();
- }
- const result = searchResult.message.docs;
-
- return API.v1.success({
- messages: await normalizeMessagesForUser(result, this.userId),
- });
- },
- },
-);
-
-// The difference between `chat.postMessage` and `chat.sendMessage` is that `chat.sendMessage` allows
-// for passing a value for `_id` and the other one doesn't. Also, `chat.sendMessage` only sends it to
-// one channel whereas the other one allows for sending to more than one channel at a time.
-API.v1.addRoute(
- 'chat.sendMessage',
- { authRequired: true, validateParams: isChatSendMessageProps },
- {
- async post() {
- if (MessageTypes.isSystemMessage(this.bodyParams.message)) {
- throw new Error("Cannot send system messages using 'chat.sendMessage'");
- }
-
- const sent = await applyAirGappedRestrictionsValidation(() =>
- executeSendMessage(this.user, this.bodyParams.message as Pick, { previewUrls: this.bodyParams.previewUrls }),
- );
- const [message] = await normalizeMessagesForUser([sent], this.userId);
-
- return API.v1.success({
- message,
- });
+ )
+ .post(
+ 'chat.react',
+ {
+ authRequired: true,
+ body: isChatReactProps,
+ response: {
+ 200: ajv.compile({
+ type: 'object',
+ properties: {
+ success: {
+ type: 'boolean',
+ enum: [true],
+ },
+ },
+ required: ['success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
},
- },
-);
-
-API.v1.addRoute(
- 'chat.react',
- { authRequired: true, validateParams: isChatReactProps },
- {
- async post() {
+ async function action() {
const msg = await Messages.findOneById(this.bodyParams.messageId);
if (!msg) {
@@ -674,60 +485,428 @@ API.v1.addRoute(
return API.v1.success();
},
- },
-);
-
-API.v1.addRoute(
- 'chat.reportMessage',
- { authRequired: true, validateParams: isChatReportMessageProps },
- {
- async post() {
+ )
+ .post(
+ 'chat.reportMessage',
+ {
+ authRequired: true,
+ body: isChatReportMessageProps,
+ response: {
+ 200: ajv.compile({
+ type: 'object',
+ properties: {
+ success: {
+ type: 'boolean',
+ enum: [true],
+ },
+ },
+ required: ['success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const { messageId, description } = this.bodyParams;
- if (!messageId) {
- return API.v1.failure('The required "messageId" param is missing.');
- }
-
- if (!description) {
- return API.v1.failure('The required "description" param is missing.');
- }
await reportMessage(messageId, description, this.userId);
return API.v1.success();
},
- },
-);
-
-API.v1.addRoute(
- 'chat.ignoreUser',
- { authRequired: true, validateParams: isChatIgnoreUserProps },
- {
- async get() {
- const { rid, userId } = this.queryParams;
- let { ignore = true } = this.queryParams;
+ )
+ .post(
+ 'chat.delete',
+ {
+ authRequired: true,
+ body: isChatDeleteProps,
+ response: {
+ 200: ajv.compile<{ _id: string; ts: string; message: Pick }>({
+ type: 'object',
+ properties: {
+ _id: { type: 'string' },
+ ts: { type: 'string' },
+ message: {
+ type: 'object',
+ properties: {
+ _id: { type: 'string' },
+ rid: { type: 'string' },
+ u: { type: 'object' },
+ },
+ required: ['_id', 'rid', 'u'],
+ additionalProperties: true,
+ },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['_id', 'ts', 'message', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const msg = await Messages.findOneById(this.bodyParams.msgId, { projection: { u: 1, rid: 1 } });
- ignore = typeof ignore === 'string' ? /true|1/.test(ignore) : ignore;
+ if (!msg) {
+ return API.v1.failure(`No message found with the id of "${this.bodyParams.msgId}".`);
+ }
- if (!rid?.trim()) {
- throw new Meteor.Error('error-room-id-param-not-provided', 'The required "rid" param is missing.');
+ if (this.bodyParams.roomId !== msg.rid) {
+ return API.v1.failure('The room id provided does not match where the message is from.');
}
- if (!userId?.trim()) {
- throw new Meteor.Error('error-user-id-param-not-provided', 'The required "userId" param is missing.');
+ if (
+ this.bodyParams.asUser &&
+ msg.u._id !== this.userId &&
+ !(await hasPermissionAsync(this.userId, 'force-delete-message', msg.rid))
+ ) {
+ return API.v1.failure('Unauthorized. You must have the permission "force-delete-message" to delete other\'s message as them.');
}
- await ignoreUser(this.userId, { rid, userId, ignore });
+ const userId = this.bodyParams.asUser ? msg.u._id : this.userId;
+ const user = await Users.findOneById(userId, { projection: { _id: 1 } });
- return API.v1.success();
- },
- },
-);
+ if (!user) {
+ return API.v1.failure('User not found');
+ }
-API.v1.addRoute(
- 'chat.getDeletedMessages',
- { authRequired: true, validateParams: isChatGetDeletedMessagesProps },
- {
- async get() {
+ await deleteMessageValidatingPermission(msg, user._id);
+
+ return API.v1.success({
+ _id: msg._id,
+ ts: Date.now().toString(),
+ message: msg,
+ });
+ },
+ )
+ .get(
+ 'chat.syncMessages',
+ {
+ authRequired: true,
+ query: isChatSyncMessagesProps,
+ response: {
+ 200: ajv.compile<{
+ result: {
+ updated: IMessage[];
+ deleted: { _id: string; _deletedAt: string }[];
+ cursor?: { next: string | null; previous: string | null };
+ };
+ }>({
+ type: 'object',
+ properties: {
+ result: {
+ type: 'object',
+ properties: {
+ updated: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } },
+ deleted: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ _id: { type: 'string' },
+ _deletedAt: { type: 'string', format: 'date-time' },
+ },
+ required: ['_id', '_deletedAt'],
+ additionalProperties: false,
+ },
+ },
+ cursor: {
+ type: 'object',
+ properties: {
+ next: { type: ['string', 'null'] },
+ previous: { type: ['string', 'null'] },
+ },
+ },
+ },
+ required: ['updated', 'deleted'],
+ additionalProperties: false,
+ },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['result', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const { roomId, lastUpdate, count, next, previous, type } = this.queryParams;
+
+ if (!roomId) {
+ throw new Meteor.Error('error-param-required', 'The required "roomId" query param is missing');
+ }
+
+ if (!lastUpdate && !type) {
+ throw new Meteor.Error('error-param-required', 'The "type" or "lastUpdate" parameters must be provided');
+ }
+
+ if (lastUpdate && isNaN(Date.parse(lastUpdate))) {
+ throw new Meteor.Error('error-lastUpdate-param-invalid', 'The "lastUpdate" query parameter must be a valid date');
+ }
+
+ const getMessagesQuery = {
+ ...(lastUpdate && { lastUpdate: new Date(lastUpdate) }),
+ ...(next && { next }),
+ ...(previous && { previous }),
+ ...(count && { count }),
+ ...(type && { type }),
+ };
+
+ const result = await getMessageHistory(roomId, this.userId, getMessagesQuery);
+
+ if (!result) {
+ return API.v1.failure();
+ }
+
+ return API.v1.success({
+ result: {
+ updated: 'updated' in result ? await normalizeMessagesForUser(result.updated, this.userId) : [],
+ deleted:
+ 'deleted' in result
+ ? result.deleted.map((msg) => ({
+ _id: msg._id,
+ _deletedAt:
+ '_deletedAt' in msg && msg._deletedAt instanceof Date ? msg._deletedAt.toISOString() : new Date().toISOString(),
+ }))
+ : [],
+ cursor: 'cursor' in result ? result.cursor : undefined,
+ },
+ });
+ },
+ )
+ .get(
+ 'chat.getMessage',
+ {
+ authRequired: true,
+ query: isChatGetMessageProps,
+ response: {
+ 200: ajv.compile<{ message: IMessage }>({
+ type: 'object',
+ properties: {
+ message: { $ref: '#/components/schemas/IMessage' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['message', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ if (!this.queryParams.msgId) {
+ return API.v1.failure('The "msgId" query parameter must be provided.');
+ }
+
+ const msg = await getSingleMessage(this.userId, this.queryParams.msgId);
+
+ if (!msg) {
+ return API.v1.failure();
+ }
+
+ const [message] = await normalizeMessagesForUser([msg], this.userId);
+
+ return API.v1.success({
+ message,
+ });
+ },
+ )
+ .post(
+ 'chat.postMessage',
+ {
+ authRequired: true,
+ body: isChatPostMessageProps,
+ response: {
+ 200: ajv.compile<{ ts: number; channel: string; message: IMessage }>({
+ type: 'object',
+ properties: {
+ ts: { type: 'number' },
+ channel: { type: 'string' },
+ message: { $ref: '#/components/schemas/IMessage' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['ts', 'channel', 'message', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const { text, attachments } = this.bodyParams;
+ const maxAllowedSize = settings.get('Message_MaxAllowedSize') ?? 0;
+
+ if (text && text.length > maxAllowedSize) {
+ return API.v1.failure('error-message-size-exceeded');
+ }
+
+ if (attachments && attachments.length > 0) {
+ for (const attachment of attachments) {
+ if (attachment.text && attachment.text.length > maxAllowedSize) {
+ return API.v1.failure('error-message-size-exceeded');
+ }
+ }
+ }
+
+ const messageReturn = (await applyAirGappedRestrictionsValidation(() => processWebhookMessage(this.bodyParams, this.user)))[0];
+
+ if (!messageReturn?.message) {
+ return API.v1.failure('unknown-error');
+ }
+
+ const [message] = await normalizeMessagesForUser([messageReturn.message], this.userId);
+
+ return API.v1.success({
+ ts: Date.now(),
+ channel: messageReturn.channel,
+ message,
+ });
+ },
+ )
+ .get(
+ 'chat.search',
+ {
+ authRequired: true,
+ query: isChatSearchProps,
+ response: {
+ 200: ajv.compile<{ messages: IMessage[] }>({
+ type: 'object',
+ properties: {
+ messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['messages', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const { roomId, searchText } = this.queryParams;
+ const { offset, count } = await getPaginationItems(this.queryParams);
+
+ if (!roomId) {
+ throw new Meteor.Error('error-roomId-param-not-provided', 'The required "roomId" query param is missing.');
+ }
+
+ if (!searchText) {
+ throw new Meteor.Error('error-searchText-param-not-provided', 'The required "searchText" query param is missing.');
+ }
+
+ const searchResult = await messageSearch(this.userId, searchText, roomId, count, offset);
+ if (searchResult === false) {
+ return API.v1.failure();
+ }
+ if (!searchResult.message) {
+ return API.v1.failure();
+ }
+ const result = searchResult.message.docs;
+
+ return API.v1.success({
+ messages: await normalizeMessagesForUser(result, this.userId),
+ });
+ },
+ )
+ // The difference between `chat.postMessage` and `chat.sendMessage` is that `chat.sendMessage` allows
+ // for passing a value for `_id` and the other one doesn't. Also, `chat.sendMessage` only sends it to
+ // one channel whereas the other one allows for sending to more than one channel at a time.
+ .post(
+ 'chat.sendMessage',
+ {
+ authRequired: true,
+ body: isChatSendMessageProps,
+ response: {
+ 200: ajv.compile<{ message: IMessage }>({
+ type: 'object',
+ properties: {
+ message: { $ref: '#/components/schemas/IMessage' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['message', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ if (MessageTypes.isSystemMessage(this.bodyParams.message)) {
+ throw new Error("Cannot send system messages using 'chat.sendMessage'");
+ }
+
+ const sent = await applyAirGappedRestrictionsValidation(() =>
+ executeSendMessage(this.user, this.bodyParams.message as Pick, { previewUrls: this.bodyParams.previewUrls }),
+ );
+ const [message] = await normalizeMessagesForUser([sent], this.userId);
+
+ return API.v1.success({
+ message,
+ });
+ },
+ )
+ .get(
+ 'chat.ignoreUser',
+ {
+ authRequired: true,
+ query: isChatIgnoreUserProps,
+ response: {
+ 200: ajv.compile({
+ type: 'object',
+ properties: {
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const { rid, userId } = this.queryParams;
+ let { ignore = true } = this.queryParams;
+
+ ignore = typeof ignore === 'string' ? /true|1/.test(ignore) : ignore;
+
+ if (!rid?.trim()) {
+ throw new Meteor.Error('error-room-id-param-not-provided', 'The required "rid" param is missing.');
+ }
+
+ if (!userId?.trim()) {
+ throw new Meteor.Error('error-user-id-param-not-provided', 'The required "userId" param is missing.');
+ }
+
+ await ignoreUser(this.userId, { rid, userId, ignore });
+
+ return API.v1.success();
+ },
+ )
+ .get(
+ 'chat.getDeletedMessages',
+ {
+ authRequired: true,
+ query: isChatGetDeletedMessagesProps,
+ response: {
+ 200: ajv.compile<{ messages: Pick[]; count: number; offset: number; total: number }>({
+ type: 'object',
+ properties: {
+ messages: { type: 'array', items: { type: 'object' } }, // relaxed: only _id is projected,
+ count: { type: 'number' },
+ offset: { type: 'number' },
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['messages', 'count', 'offset', 'total', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const { roomId, since } = this.queryParams;
const { offset, count } = await getPaginationItems(this.queryParams);
@@ -750,14 +929,30 @@ API.v1.addRoute(
total,
});
},
- },
-);
-
-API.v1.addRoute(
- 'chat.getPinnedMessages',
- { authRequired: true, validateParams: isChatGetPinnedMessagesProps },
- {
- async get() {
+ )
+ .get(
+ 'chat.getPinnedMessages',
+ {
+ authRequired: true,
+ query: isChatGetPinnedMessagesProps,
+ response: {
+ 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({
+ type: 'object',
+ properties: {
+ messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } },
+ count: { type: 'number' },
+ offset: { type: 'number' },
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['messages', 'count', 'offset', 'total', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const { roomId } = this.queryParams;
const { offset, count } = await getPaginationItems(this.queryParams);
@@ -779,14 +974,30 @@ API.v1.addRoute(
total,
});
},
- },
-);
-
-API.v1.addRoute(
- 'chat.getThreadsList',
- { authRequired: true, validateParams: isChatGetThreadsListProps },
- {
- async get() {
+ )
+ .get(
+ 'chat.getThreadsList',
+ {
+ authRequired: true,
+ query: isChatGetThreadsListProps,
+ response: {
+ 200: ajv.compile<{ threads: IThreadMainMessage[]; count: number; offset: number; total: number }>({
+ type: 'object',
+ properties: {
+ threads: { type: 'array', items: { type: 'object' } }, // relaxed: IThreadMainMessage not in OpenAPI schemas,
+ count: { type: 'number' },
+ offset: { type: 'number' },
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['threads', 'count', 'offset', 'total', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const { rid, type, text } = this.queryParams;
const { offset, count } = await getPaginationItems(this.queryParams);
@@ -826,14 +1037,35 @@ API.v1.addRoute(
total,
});
},
- },
-);
-
-API.v1.addRoute(
- 'chat.syncThreadsList',
- { authRequired: true, validateParams: isChatSyncThreadsListProps },
- {
- async get() {
+ )
+ .get(
+ 'chat.syncThreadsList',
+ {
+ authRequired: true,
+ query: isChatSyncThreadsListProps,
+ response: {
+ 200: ajv.compile<{ threads: { update: IMessage[]; remove: IMessage[] } }>({
+ type: 'object',
+ properties: {
+ threads: {
+ type: 'object',
+ properties: {
+ update: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } },
+ remove: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } },
+ },
+ required: ['update', 'remove'],
+ additionalProperties: false,
+ },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['threads', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const { rid } = this.queryParams;
const { query, fields, sort } = await this.parseJsonQuery();
const { updatedSince } = this.queryParams;
@@ -870,14 +1102,30 @@ API.v1.addRoute(
},
});
},
- },
-);
-
-API.v1.addRoute(
- 'chat.getThreadMessages',
- { authRequired: true, validateParams: isChatGetThreadMessagesProps },
- {
- async get() {
+ )
+ .get(
+ 'chat.getThreadMessages',
+ {
+ authRequired: true,
+ query: isChatGetThreadMessagesProps,
+ response: {
+ 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({
+ type: 'object',
+ properties: {
+ messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } },
+ count: { type: 'number' },
+ offset: { type: 'number' },
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['messages', 'count', 'offset', 'total', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const { tmid } = this.queryParams;
const { query, fields, sort } = await this.parseJsonQuery();
const { offset, count } = await getPaginationItems(this.queryParams);
@@ -915,14 +1163,35 @@ API.v1.addRoute(
total,
});
},
- },
-);
-
-API.v1.addRoute(
- 'chat.syncThreadMessages',
- { authRequired: true, validateParams: isChatSyncThreadMessagesProps },
- {
- async get() {
+ )
+ .get(
+ 'chat.syncThreadMessages',
+ {
+ authRequired: true,
+ query: isChatSyncThreadMessagesProps,
+ response: {
+ 200: ajv.compile<{ messages: { update: IMessage[]; remove: IMessage[] } }>({
+ type: 'object',
+ properties: {
+ messages: {
+ type: 'object',
+ properties: {
+ update: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } },
+ remove: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } },
+ },
+ required: ['update', 'remove'],
+ additionalProperties: false,
+ },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['messages', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const { tmid } = this.queryParams;
const { query, fields, sort } = await this.parseJsonQuery();
const { updatedSince } = this.queryParams;
@@ -954,14 +1223,30 @@ API.v1.addRoute(
},
});
},
- },
-);
-
-API.v1.addRoute(
- 'chat.getMentionedMessages',
- { authRequired: true, validateParams: isChatGetMentionedMessagesProps },
- {
- async get() {
+ )
+ .get(
+ 'chat.getMentionedMessages',
+ {
+ authRequired: true,
+ query: isChatGetMentionedMessagesProps,
+ response: {
+ 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({
+ type: 'object',
+ properties: {
+ messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } },
+ count: { type: 'number' },
+ offset: { type: 'number' },
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['messages', 'count', 'offset', 'total', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const { roomId } = this.queryParams;
const { sort } = await this.parseJsonQuery();
const { offset, count } = await getPaginationItems(this.queryParams);
@@ -978,14 +1263,30 @@ API.v1.addRoute(
return API.v1.success(messages);
},
- },
-);
-
-API.v1.addRoute(
- 'chat.getStarredMessages',
- { authRequired: true, validateParams: isChatGetStarredMessagesProps },
- {
- async get() {
+ )
+ .get(
+ 'chat.getStarredMessages',
+ {
+ authRequired: true,
+ query: isChatGetStarredMessagesProps,
+ response: {
+ 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({
+ type: 'object',
+ properties: {
+ messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } },
+ count: { type: 'number' },
+ offset: { type: 'number' },
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['messages', 'count', 'offset', 'total', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const { roomId } = this.queryParams;
const { sort } = await this.parseJsonQuery();
const { offset, count } = await getPaginationItems(this.queryParams);
@@ -1004,14 +1305,28 @@ API.v1.addRoute(
return API.v1.success(messages);
},
- },
-);
-
-API.v1.addRoute(
- 'chat.getDiscussions',
- { authRequired: true, validateParams: isChatGetDiscussionsProps },
- {
- async get() {
+ )
+ .get(
+ 'chat.getDiscussions',
+ {
+ authRequired: true,
+ query: isChatGetDiscussionsProps,
+ response: {
+ 200: ajv.compile<{ messages: IMessage[]; total: number }>({
+ type: 'object',
+ properties: {
+ messages: { type: 'array', items: { type: 'object' } }, // relaxed: discussions have extra room fields,
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['messages', 'total', 'success'],
+ additionalProperties: true,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const { roomId, text } = this.queryParams;
const { sort } = await this.parseJsonQuery();
const { offset, count } = await getPaginationItems(this.queryParams);
@@ -1028,14 +1343,27 @@ API.v1.addRoute(
});
return API.v1.success(messages);
},
- },
-);
-
-API.v1.addRoute(
- 'chat.getURLPreview',
- { authRequired: true, validateParams: isChatGetURLPreviewProps },
- {
- async get() {
+ )
+ .get(
+ 'chat.getURLPreview',
+ {
+ authRequired: true,
+ query: isChatGetURLPreviewProps,
+ response: {
+ 200: ajv.compile<{ urlPreview: object }>({
+ type: 'object',
+ properties: {
+ urlPreview: { type: 'object' }, // relaxed: opaque preview shape,
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['urlPreview', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const { roomId, url } = this.queryParams;
if (!(await canAccessRoomIdAsync(roomId, this.userId))) {
@@ -1047,8 +1375,7 @@ API.v1.addRoute(
return API.v1.success({ urlPreview });
},
- },
-);
+ );
export type ChatEndpoints = ExtractRoutesFromAPI;
diff --git a/apps/meteor/app/api/server/v1/commands.ts b/apps/meteor/app/api/server/v1/commands.ts
index 408222052b7d9..801ad8953829f 100644
--- a/apps/meteor/app/api/server/v1/commands.ts
+++ b/apps/meteor/app/api/server/v1/commands.ts
@@ -1,8 +1,14 @@
import { Apps } from '@rocket.chat/apps';
-import type { SlashCommand } from '@rocket.chat/core-typings';
+import type { SlashCommand, SlashCommandPreviewItem } from '@rocket.chat/core-typings';
import { Messages } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
-import { ajv, ajvQuery, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings';
+import {
+ ajv,
+ ajvQuery,
+ validateUnauthorizedErrorResponse,
+ validateBadRequestErrorResponse,
+ validateForbiddenErrorResponse,
+} from '@rocket.chat/rest-typings';
import objectPath from 'object-path';
import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom';
@@ -26,63 +32,158 @@ const CommandsGetParamsSchema = {
const isCommandsGetParams = ajvQuery.compile(CommandsGetParamsSchema);
-const commandsEndpoints = API.v1.get(
- 'commands.get',
- {
- authRequired: true,
- query: isCommandsGetParams,
- response: {
- 400: validateBadRequestErrorResponse,
- 401: validateUnauthorizedErrorResponse,
- 200: ajv.compile<{
- command: Pick;
- success: true;
- }>({
- type: 'object',
- properties: {
- command: {
- type: 'object',
- properties: {
- clientOnly: { type: 'boolean' },
- command: { type: 'string' },
- description: { type: 'string' },
- params: { type: 'string' },
- providesPreview: { type: 'boolean' },
+const commandsListResponseSchema = ajv.compile<{
+ commands: SlashCommand[];
+ appsLoaded: boolean;
+ offset: number;
+ count: number;
+ total: number;
+}>({
+ type: 'object',
+ properties: {
+ commands: {
+ type: 'array',
+ items: { $ref: '#/components/schemas/SlashCommand' },
+ },
+ appsLoaded: { type: 'boolean' },
+ offset: { type: 'number' },
+ count: { type: 'number' },
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['commands', 'appsLoaded', 'offset', 'count', 'total', 'success'],
+ additionalProperties: false,
+});
+
+type CommandsListParams = {
+ offset?: number;
+ count?: number;
+ sort?: string;
+ query?: string;
+ fields?: string;
+};
+
+const isCommandsListParams = ajvQuery.compile({
+ type: 'object',
+ properties: {
+ offset: { type: 'number', nullable: true },
+ count: { type: 'number', nullable: true },
+ sort: { type: 'string', nullable: true },
+ query: { type: 'string', nullable: true },
+ fields: { type: 'string', nullable: true },
+ },
+ additionalProperties: false,
+});
+
+const commandsEndpoints = API.v1
+ .get(
+ 'commands.get',
+ {
+ authRequired: true,
+ query: isCommandsGetParams,
+ response: {
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 200: ajv.compile<{
+ command: Pick;
+ success: true;
+ }>({
+ type: 'object',
+ properties: {
+ command: {
+ type: 'object',
+ properties: {
+ clientOnly: { type: 'boolean' },
+ command: { type: 'string' },
+ description: { type: 'string' },
+ params: { type: 'string' },
+ providesPreview: { type: 'boolean' },
+ },
+ required: ['command', 'providesPreview'],
+ additionalProperties: false,
+ },
+ success: {
+ type: 'boolean',
+ enum: [true],
},
- required: ['command', 'providesPreview'],
- additionalProperties: false,
- },
- success: {
- type: 'boolean',
- enum: [true],
},
- },
- required: ['command', 'success'],
- additionalProperties: false,
- }),
+ required: ['command', 'success'],
+ additionalProperties: false,
+ }),
+ },
},
- },
- async function action() {
- const params = this.queryParams;
+ async function action() {
+ const params = this.queryParams;
- const cmd = slashCommands.commands[params.command.toLowerCase()];
+ const cmd = slashCommands.commands[params.command.toLowerCase()];
- if (!cmd) {
- return API.v1.failure(`There is no command in the system by the name of: ${params.command}`);
- }
+ if (!cmd) {
+ return API.v1.failure(`There is no command in the system by the name of: ${params.command}`);
+ }
- return API.v1.success({
- command: {
- command: cmd.command,
- description: cmd.description,
- params: cmd.params,
- clientOnly: cmd.clientOnly,
- providesPreview: cmd.providesPreview,
+ return API.v1.success({
+ command: {
+ command: cmd.command,
+ description: cmd.description,
+ params: cmd.params,
+ clientOnly: cmd.clientOnly,
+ providesPreview: cmd.providesPreview,
+ },
+ });
+ },
+ )
+ .get(
+ 'commands.list',
+ {
+ authRequired: true,
+ query: isCommandsListParams,
+ response: {
+ 200: commandsListResponseSchema,
+ 202: commandsListResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
},
- });
- },
-);
+ },
+ async function action() {
+ if (!Apps.self?.isLoaded()) {
+ return API.v1.success(
+ {
+ commands: [],
+ appsLoaded: false as const,
+ offset: 0,
+ count: 0,
+ total: 0,
+ success: true as const,
+ },
+ 202,
+ );
+ }
+
+ const { offset, count } = await getPaginationItems(this.queryParams);
+ const { sort, query } = await this.parseJsonQuery();
+
+ let commands = Object.values(slashCommands.commands);
+
+ if (query?.command) {
+ commands = commands.filter((command) => command.command === query.command);
+ }
+
+ const totalCount = commands.length;
+
+ return API.v1.success({
+ commands: processQueryOptionsOnResult(commands, {
+ sort: sort || { name: 1 },
+ skip: offset,
+ limit: count,
+ }),
+ appsLoaded: true as const,
+ offset,
+ count: commands.length,
+ total: totalCount,
+ });
+ },
+ );
/* @deprecated */
const processQueryOptionsOnResult = , F extends keyof T>(
@@ -190,216 +291,225 @@ const processQueryOptionsOnResult = ;
- const { offset, count } = await getPaginationItems(params);
- const { sort, query } = await this.parseJsonQuery();
-
- let commands = Object.values(slashCommands.commands);
+const isCommandsRunProps = ajv.compile<{ command: string; params?: string; roomId: string; tmid?: string; triggerId?: string }>({
+ type: 'object',
+ properties: {
+ command: { type: 'string', minLength: 1 },
+ params: { type: 'string', nullable: true },
+ roomId: { type: 'string', minLength: 1 },
+ tmid: { type: 'string', nullable: true },
+ triggerId: { type: 'string', nullable: true },
+ },
+ required: ['command', 'roomId'],
+ additionalProperties: false,
+});
- if (query?.command) {
- commands = commands.filter((command) => command.command === query.command);
- }
+const commandsRunResponseSchema = ajv.compile<{ result: unknown }>({
+ type: 'object',
+ properties: {
+ result: {},
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['success'],
+ additionalProperties: true,
+});
- const totalCount = commands.length;
+const isCommandsPreviewGetProps = ajvQuery.compile<{ command: string; params?: string; roomId: string }>({
+ type: 'object',
+ properties: {
+ command: { type: 'string', minLength: 1 },
+ params: { type: 'string', nullable: true },
+ roomId: { type: 'string', minLength: 1 },
+ },
+ required: ['command', 'roomId'],
+ additionalProperties: false,
+});
- return API.v1.success({
- commands: processQueryOptionsOnResult(commands, {
- sort: sort || { name: 1 },
- skip: offset,
- limit: count,
- }),
- appsLoaded: true,
- offset,
- count: commands.length,
- total: totalCount,
- });
+const commandsPreviewGetResponseSchema = ajv.compile<{ preview: Record | null }>({
+ type: 'object',
+ properties: {
+ preview: { type: 'object', nullable: true },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['success'],
+ additionalProperties: false,
+});
+
+const isCommandsPreviewPostProps = ajv.compile<{
+ command: string;
+ params?: string;
+ roomId: string;
+ tmid?: string;
+ triggerId?: string;
+ previewItem: SlashCommandPreviewItem;
+}>({
+ type: 'object',
+ properties: {
+ command: { type: 'string', minLength: 1 },
+ params: { type: 'string', nullable: true },
+ roomId: { type: 'string', minLength: 1 },
+ tmid: { type: 'string', nullable: true },
+ triggerId: { type: 'string', nullable: true },
+ previewItem: {
+ type: 'object',
+ properties: {
+ id: { type: 'string' },
+ type: { type: 'string', enum: ['image', 'video', 'audio', 'text', 'other'] },
+ value: { type: 'string' },
+ },
+ required: ['id', 'type', 'value'],
+ additionalProperties: false,
},
},
-);
+ required: ['command', 'roomId', 'previewItem'],
+ additionalProperties: false,
+});
+
+const commandsPreviewPostResponseSchema = ajv.compile({
+ type: 'object',
+ properties: {
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['success'],
+ additionalProperties: false,
+});
// Expects a body of: { command: 'gimme', params: 'any string value', roomId: 'value', triggerId: 'value' }
-API.v1.addRoute(
+API.v1.post(
'commands.run',
- { authRequired: true },
{
- async post() {
- const body = this.bodyParams;
-
- if (typeof body.command !== 'string') {
- return API.v1.failure('You must provide a command to run.');
- }
-
- if (body.params && typeof body.params !== 'string') {
- return API.v1.failure('The parameters for the command must be a single string.');
- }
-
- if (typeof body.roomId !== 'string') {
- return API.v1.failure("The room's id where to execute this command must be provided and be a string.");
- }
-
- if (body.tmid && typeof body.tmid !== 'string') {
- return API.v1.failure('The tmid parameter when provided must be a string.');
- }
+ authRequired: true,
+ body: isCommandsRunProps,
+ response: {
+ 200: commandsRunResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
+ },
+ async function action() {
+ const body = this.bodyParams;
- const cmd = body.command.toLowerCase();
- if (!slashCommands.commands[cmd]) {
- return API.v1.failure('The command provided does not exist (or is disabled).');
- }
+ const cmd = body.command.toLowerCase();
+ if (!slashCommands.commands[cmd]) {
+ return API.v1.failure('The command provided does not exist (or is disabled).');
+ }
- if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) {
- return API.v1.forbidden();
- }
+ if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) {
+ return API.v1.forbidden();
+ }
- const params = body.params ? body.params : '';
- if (typeof body.tmid === 'string') {
- const thread = await Messages.findOneById(body.tmid);
- if (!thread || thread.rid !== body.roomId) {
- return API.v1.failure('Invalid thread.');
- }
+ const params = body.params ? body.params : '';
+ if (body.tmid) {
+ const thread = await Messages.findOneById(body.tmid);
+ if (thread?.rid !== body.roomId) {
+ return API.v1.failure('Invalid thread.');
}
+ }
- const message = {
- _id: Random.id(),
- rid: body.roomId,
- msg: `/${cmd} ${params}`,
- ...(body.tmid && { tmid: body.tmid }),
- };
+ const message = {
+ _id: Random.id(),
+ rid: body.roomId,
+ msg: `/${cmd} ${params}`,
+ ...(body.tmid && { tmid: body.tmid }),
+ };
- const { triggerId } = body;
+ const { triggerId } = body;
- const result = await slashCommands.run({ command: cmd, params, message, triggerId, userId: this.userId });
+ const result = await slashCommands.run({ command: cmd, params, message, triggerId, userId: this.userId });
- return API.v1.success({ result });
- },
+ return API.v1.success({ result });
},
);
-API.v1.addRoute(
+// Expects these query params: command: 'giphy', params: 'mine', roomId: 'value'
+API.v1.get(
'commands.preview',
- { authRequired: true },
{
- // Expects these query params: command: 'giphy', params: 'mine', roomId: 'value'
- async get() {
- const query = this.queryParams;
-
- if (typeof query.command !== 'string') {
- return API.v1.failure('You must provide a command to get the previews from.');
- }
-
- if (query.params && typeof query.params !== 'string') {
- return API.v1.failure('The parameters for the command must be a single string.');
- }
-
- if (typeof query.roomId !== 'string') {
- return API.v1.failure("The room's id where the previews are being displayed must be provided and be a string.");
- }
-
- const cmd = query.command.toLowerCase();
- if (!slashCommands.commands[cmd]) {
- return API.v1.failure('The command provided does not exist (or is disabled).');
- }
-
- if (!(await canAccessRoomIdAsync(query.roomId, this.userId))) {
- return API.v1.forbidden();
- }
-
- const params = query.params ? query.params : '';
-
- const preview = await getSlashCommandPreviews({
- cmd,
- params,
- msg: { rid: query.roomId },
- userId: this.userId,
- });
-
- return API.v1.success({ preview });
+ authRequired: true,
+ query: isCommandsPreviewGetProps,
+ response: {
+ 200: commandsPreviewGetResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
},
+ },
+ async function action() {
+ const query = this.queryParams;
- // Expects a body format of: { command: 'giphy', params: 'mine', roomId: 'value', tmid: 'value', triggerId: 'value', previewItem: { id: 'sadf8' type: 'image', value: 'https://dev.null/gif' } }
- async post() {
- const body = this.bodyParams;
-
- if (typeof body.command !== 'string') {
- return API.v1.failure('You must provide a command to run the preview item on.');
- }
+ const cmd = query.command.toLowerCase();
+ if (!slashCommands.commands[cmd]) {
+ return API.v1.failure('The command provided does not exist (or is disabled).');
+ }
- if (body.params && typeof body.params !== 'string') {
- return API.v1.failure('The parameters for the command must be a single string.');
- }
+ if (!(await canAccessRoomIdAsync(query.roomId, this.userId))) {
+ return API.v1.forbidden();
+ }
- if (typeof body.roomId !== 'string') {
- return API.v1.failure("The room's id where the preview is being executed in must be provided and be a string.");
- }
+ const params = query.params ? query.params : '';
- if (typeof body.previewItem === 'undefined') {
- return API.v1.failure('The preview item being executed must be provided.');
- }
+ const preview = await getSlashCommandPreviews({
+ cmd,
+ params,
+ msg: { rid: query.roomId },
+ userId: this.userId,
+ });
- if (!body.previewItem.id || !body.previewItem.type || typeof body.previewItem.value === 'undefined') {
- return API.v1.failure('The preview item being executed is in the wrong format.');
- }
+ return API.v1.success({ preview: preview ?? null });
+ },
+);
- if (body.tmid && typeof body.tmid !== 'string') {
- return API.v1.failure('The tmid parameter when provided must be a string.');
- }
+// Expects a body format of: { command: 'giphy', params: 'mine', roomId: 'value', tmid: 'value', triggerId: 'value', previewItem: { id: 'sadf8' type: 'image', value: 'https://dev.null/gif' } }
+API.v1.post(
+ 'commands.preview',
+ {
+ authRequired: true,
+ body: isCommandsPreviewPostProps,
+ response: {
+ 200: commandsPreviewPostResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
+ },
+ async function action() {
+ const body = this.bodyParams;
- if (body.triggerId && typeof body.triggerId !== 'string') {
- return API.v1.failure('The triggerId parameter when provided must be a string.');
- }
+ const cmd = body.command.toLowerCase();
+ if (!slashCommands.commands[cmd]) {
+ return API.v1.failure('The command provided does not exist (or is disabled).');
+ }
- const cmd = body.command.toLowerCase();
- if (!slashCommands.commands[cmd]) {
- return API.v1.failure('The command provided does not exist (or is disabled).');
- }
+ if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) {
+ return API.v1.forbidden();
+ }
- if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) {
- return API.v1.forbidden();
+ const { params = '' } = body;
+ if (body.tmid) {
+ const thread = await Messages.findOneById(body.tmid);
+ if (thread?.rid !== body.roomId) {
+ return API.v1.failure('Invalid thread.');
}
+ }
- const { params = '' } = body;
- if (body.tmid) {
- const thread = await Messages.findOneById(body.tmid);
- if (!thread || thread.rid !== body.roomId) {
- return API.v1.failure('Invalid thread.');
- }
- }
+ const msg = {
+ rid: body.roomId,
+ ...(body.tmid && { tmid: body.tmid }),
+ };
- const msg = {
- rid: body.roomId,
- ...(body.tmid && { tmid: body.tmid }),
- };
-
- await executeSlashCommandPreview(
- {
- cmd,
- params,
- msg,
- triggerId: body.triggerId,
- },
- body.previewItem,
- this.userId,
- );
+ await executeSlashCommandPreview(
+ {
+ cmd,
+ params,
+ msg,
+ triggerId: body.triggerId,
+ },
+ body.previewItem,
+ this.userId,
+ );
- return API.v1.success();
- },
+ return API.v1.success();
},
);
diff --git a/apps/meteor/app/api/server/v1/custom-user-status.ts b/apps/meteor/app/api/server/v1/custom-user-status.ts
index 573a8a1a123b0..f3ab5c944839e 100644
--- a/apps/meteor/app/api/server/v1/custom-user-status.ts
+++ b/apps/meteor/app/api/server/v1/custom-user-status.ts
@@ -1,9 +1,14 @@
import type { ICustomUserStatus } from '@rocket.chat/core-typings';
import { CustomUserStatus } from '@rocket.chat/models';
-import { ajv, ajvQuery, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings';
+import {
+ ajv,
+ ajvQuery,
+ validateUnauthorizedErrorResponse,
+ validateBadRequestErrorResponse,
+ validateForbiddenErrorResponse,
+} from '@rocket.chat/rest-typings';
import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/string-helpers';
-import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { deleteCustomUserStatus } from '../../../user-status/server/methods/deleteCustomUserStatus';
@@ -100,7 +105,7 @@ const customUserStatusEndpoints = API.v1.get(
const filter = {
...query,
- ...(name ? { name: { $regex: escapeRegExp(name as string), $options: 'i' } } : {}),
+ ...(name ? { name: { $regex: escapeRegExp(name), $options: 'i' } } : {}),
...(_id ? { _id } : {}),
};
@@ -121,88 +126,152 @@ const customUserStatusEndpoints = API.v1.get(
},
);
-API.v1.addRoute(
+const isCustomUserStatusCreateProps = ajv.compile<{ name: string; statusType?: string }>({
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ statusType: { type: 'string', nullable: true },
+ },
+ required: ['name'],
+ additionalProperties: false,
+});
+
+const customUserStatusCreateResponseSchema = ajv.compile<{ customUserStatus: ICustomUserStatus }>({
+ type: 'object',
+ properties: {
+ customUserStatus: { $ref: '#/components/schemas/ICustomUserStatus' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['customUserStatus', 'success'],
+ additionalProperties: false,
+});
+
+API.v1.post(
'custom-user-status.create',
- { authRequired: true },
{
- async post() {
- check(this.bodyParams, {
- name: String,
- statusType: Match.Maybe(String),
- });
-
- const userStatusData = {
- name: this.bodyParams.name,
- statusType: this.bodyParams.statusType || '',
- };
-
- await insertOrUpdateUserStatus(this.userId, userStatusData);
-
- const customUserStatus = await CustomUserStatus.findOneByName(userStatusData.name);
- if (!customUserStatus) {
- throw new Meteor.Error('error-creating-custom-user-status', 'Error creating custom user status');
- }
-
- return API.v1.success({
- customUserStatus,
- });
+ authRequired: true,
+ body: isCustomUserStatusCreateProps,
+ response: {
+ 200: customUserStatusCreateResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
},
},
+ async function action() {
+ const userStatusData = {
+ name: this.bodyParams.name,
+ statusType: this.bodyParams.statusType || '',
+ };
+
+ await insertOrUpdateUserStatus(this.userId, userStatusData);
+
+ const customUserStatus = await CustomUserStatus.findOneByName(userStatusData.name);
+ if (!customUserStatus) {
+ throw new Meteor.Error('error-creating-custom-user-status', 'Error creating custom user status');
+ }
+
+ return API.v1.success({
+ customUserStatus,
+ });
+ },
);
-API.v1.addRoute(
+const isCustomUserStatusDeleteProps = ajv.compile<{ customUserStatusId: string }>({
+ type: 'object',
+ properties: {
+ customUserStatusId: { type: 'string', minLength: 1 },
+ },
+ required: ['customUserStatusId'],
+ additionalProperties: false,
+});
+
+const customUserStatusDeleteResponseSchema = ajv.compile({
+ type: 'object',
+ properties: {
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['success'],
+ additionalProperties: false,
+});
+
+API.v1.post(
'custom-user-status.delete',
- { authRequired: true },
{
- async post() {
- const { customUserStatusId } = this.bodyParams;
- if (!customUserStatusId) {
- return API.v1.failure('The "customUserStatusId" params is required!');
- }
+ authRequired: true,
+ body: isCustomUserStatusDeleteProps,
+ response: {
+ 200: customUserStatusDeleteResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const { customUserStatusId } = this.bodyParams;
- await deleteCustomUserStatus(this.userId, customUserStatusId);
+ await deleteCustomUserStatus(this.userId, customUserStatusId);
- return API.v1.success();
- },
+ return API.v1.success();
},
);
-API.v1.addRoute(
+const isCustomUserStatusUpdateProps = ajv.compile<{ _id: string; name: string; statusType?: string }>({
+ type: 'object',
+ properties: {
+ _id: { type: 'string' },
+ name: { type: 'string' },
+ statusType: { type: 'string', nullable: true },
+ },
+ required: ['_id', 'name'],
+ additionalProperties: false,
+});
+
+const customUserStatusUpdateResponseSchema = ajv.compile<{ customUserStatus: ICustomUserStatus }>({
+ type: 'object',
+ properties: {
+ customUserStatus: { $ref: '#/components/schemas/ICustomUserStatus' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['customUserStatus', 'success'],
+ additionalProperties: false,
+});
+
+API.v1.post(
'custom-user-status.update',
- { authRequired: true },
{
- async post() {
- check(this.bodyParams, {
- _id: String,
- name: String,
- statusType: Match.Maybe(String),
- });
-
- const userStatusData = {
- _id: this.bodyParams._id,
- name: this.bodyParams.name,
- statusType: this.bodyParams.statusType,
- };
+ authRequired: true,
+ body: isCustomUserStatusUpdateProps,
+ response: {
+ 200: customUserStatusUpdateResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const userStatusData = {
+ _id: this.bodyParams._id,
+ name: this.bodyParams.name,
+ statusType: this.bodyParams.statusType || '',
+ };
- const customUserStatusToUpdate = await CustomUserStatus.findOneById(userStatusData._id);
+ const customUserStatusToUpdate = await CustomUserStatus.findOneById(userStatusData._id);
- // Ensure the message exists
- if (!customUserStatusToUpdate) {
- return API.v1.failure(`No custom user status found with the id of "${userStatusData._id}".`);
- }
+ // Ensure the message exists
+ if (!customUserStatusToUpdate) {
+ return API.v1.failure(`No custom user status found with the id of "${userStatusData._id}".`);
+ }
- await insertOrUpdateUserStatus(this.userId, userStatusData);
+ await insertOrUpdateUserStatus(this.userId, userStatusData);
- const customUserStatus = await CustomUserStatus.findOneById(userStatusData._id);
+ const customUserStatus = await CustomUserStatus.findOneById(userStatusData._id);
- if (!customUserStatus) {
- throw new Meteor.Error('error-updating-custom-user-status', 'Error updating custom user status');
- }
+ if (!customUserStatus) {
+ throw new Meteor.Error('error-updating-custom-user-status', 'Error updating custom user status');
+ }
- return API.v1.success({
- customUserStatus,
- });
- },
+ return API.v1.success({
+ customUserStatus,
+ });
},
);
diff --git a/apps/meteor/app/api/server/v1/emoji-custom.ts b/apps/meteor/app/api/server/v1/emoji-custom.ts
index 043da3b0c5396..ed757ab9f661e 100644
--- a/apps/meteor/app/api/server/v1/emoji-custom.ts
+++ b/apps/meteor/app/api/server/v1/emoji-custom.ts
@@ -1,11 +1,11 @@
import { Media } from '@rocket.chat/core-services';
-import type { IEmojiCustom } from '@rocket.chat/core-typings';
+import type { IEmojiCustom, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import { EmojiCustom } from '@rocket.chat/models';
-import { ajv, isEmojiCustomList, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings';
+import { ajv, isEmojiCustomList, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { Meteor } from 'meteor/meteor';
+import type { WithId } from 'mongodb';
-import { SystemLogger } from '../../../../server/lib/logger/system';
import type { EmojiData } from '../../../emoji-custom/server/lib/insertOrUpdateEmoji';
import { insertOrUpdateEmoji } from '../../../emoji-custom/server/lib/insertOrUpdateEmoji';
import { uploadEmojiCustomWithBuffer } from '../../../emoji-custom/server/lib/uploadEmojiCustom';
@@ -17,6 +17,38 @@ import { getPaginationItems } from '../helpers/getPaginationItems';
import { findEmojisCustom } from '../lib/emoji-custom';
import { getUploadFormData } from '../lib/getUploadFormData';
+const emojiDeleteBodySchema = ajv.compile({
+ type: 'object',
+ properties: { emojiId: { type: 'string', minLength: 1 } },
+ required: ['emojiId'],
+ additionalProperties: false,
+});
+
+const emojiCustomAllResponseSchema = ajv.compile<{ emojis: IEmojiCustom[]; count: number; offset: number; total: number }>({
+ type: 'object',
+ properties: {
+ emojis: {
+ type: 'array',
+ items: { $ref: '#/components/schemas/IEmojiCustom' },
+ },
+ count: { type: 'number' },
+ offset: { type: 'number' },
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['emojis', 'count', 'offset', 'total', 'success'],
+ additionalProperties: false,
+});
+
+const emojiCustomDeleteResponseSchema = ajv.compile({
+ type: 'object',
+ properties: {
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['success'],
+ additionalProperties: false,
+});
+
function validateDateParam(paramName: string, paramValue: string | undefined): Date | undefined {
if (!paramValue) {
return undefined;
@@ -30,7 +62,9 @@ function validateDateParam(paramName: string, paramValue: string | undefined): D
return date;
}
-const emojiListResponseSchema = ajv.compile({
+const emojiCustomListResponseSchema = ajv.compile<{
+ emojis: { update: IEmojiCustom[]; remove: WithId>[] };
+}>({
type: 'object',
properties: {
emojis: {
@@ -47,13 +81,6 @@ const emojiListResponseSchema = ajv.compile({
additionalProperties: false,
});
-const emojiDeleteBodySchema = ajv.compile({
- type: 'object',
- properties: { emojiId: { type: 'string' } },
- required: ['emojiId'],
- additionalProperties: false,
-});
-
const emojiCustomCreateEndpoints = API.v1
.get(
'emoji-custom.list',
@@ -61,7 +88,7 @@ const emojiCustomCreateEndpoints = API.v1
authRequired: true,
query: isEmojiCustomList,
response: {
- 200: emojiListResponseSchema,
+ 200: emojiCustomListResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
@@ -109,18 +136,7 @@ const emojiCustomCreateEndpoints = API.v1
{
authRequired: true,
response: {
- 200: ajv.compile({
- type: 'object',
- properties: {
- emojis: { type: 'array', items: { type: 'object' } },
- total: { type: 'number' },
- count: { type: 'number' },
- offset: { type: 'number' },
- success: { type: 'boolean', enum: [true] },
- },
- required: ['emojis', 'total', 'count', 'offset', 'success'],
- additionalProperties: false,
- }),
+ 200: emojiCustomAllResponseSchema,
401: validateUnauthorizedErrorResponse,
},
},
@@ -153,18 +169,6 @@ const emojiCustomCreateEndpoints = API.v1
{
authRequired: true,
response: {
- 400: ajv.compile({
- type: 'object',
- properties: {
- success: { type: 'boolean', enum: [false] },
- stack: { type: 'string' },
- error: { type: 'string' },
- errorType: { type: 'string' },
- details: { type: 'string' },
- },
- required: ['success'],
- additionalProperties: false,
- }),
200: ajv.compile({
type: 'object',
properties: {
@@ -176,6 +180,8 @@ const emojiCustomCreateEndpoints = API.v1
required: ['success'],
additionalProperties: false,
}),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
},
},
async function action() {
@@ -199,20 +205,15 @@ const emojiCustomCreateEndpoints = API.v1
const [, extension] = mimetype.split('/');
fields.extension = extension;
- try {
- const emojiData = await insertOrUpdateEmoji(this.userId, {
- ...fields,
- newFile: true,
- aliases: fields.aliases || '',
- name: fields.name,
- extension: fields.extension,
- });
+ const emojiData = await insertOrUpdateEmoji(this.userId, {
+ ...fields,
+ newFile: true,
+ aliases: fields.aliases || '',
+ name: fields.name,
+ extension: fields.extension,
+ });
- await uploadEmojiCustomWithBuffer(this.userId, fileBuffer, mimetype, emojiData);
- } catch (err) {
- SystemLogger.error({ err });
- return API.v1.failure();
- }
+ await uploadEmojiCustomWithBuffer(this.userId, fileBuffer, mimetype, emojiData);
return API.v1.success();
},
@@ -290,12 +291,7 @@ const emojiCustomCreateEndpoints = API.v1
authRequired: true,
body: emojiDeleteBodySchema,
response: {
- 200: ajv.compile({
- type: 'object',
- properties: { success: { type: 'boolean', enum: [true] } },
- required: ['success'],
- additionalProperties: false,
- }),
+ 200: emojiCustomDeleteResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts
index 30b9f362bddca..1cbcf3237e3ac 100644
--- a/apps/meteor/app/api/server/v1/im.ts
+++ b/apps/meteor/app/api/server/v1/im.ts
@@ -5,6 +5,7 @@ import type { IMessage, IRoom, ISubscription, IUser } from '@rocket.chat/core-ty
import { Subscriptions, Uploads, Messages, Rooms, Users } from '@rocket.chat/models';
import {
ajv,
+ ajvQuery,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
validateBadRequestErrorResponse,
@@ -70,28 +71,6 @@ const findDirectMessageRoom = async (
};
};
-API.v1.addRoute(
- ['dm.create', 'im.create'],
- {
- authRequired: true,
- validateParams: isDmCreateProps,
- },
- {
- async post() {
- const users =
- 'username' in this.bodyParams
- ? [this.bodyParams.username]
- : this.bodyParams.usernames.split(',').map((username: string) => username.trim());
-
- const room = await createDirectMessage(users, this.userId, this.bodyParams.excludeSelf);
-
- return API.v1.success({
- room: { ...room, _id: room.rid },
- });
- },
- },
-);
-
type DmDeleteProps =
| {
roomId: string;
@@ -219,7 +198,7 @@ const dmCloseAction = (_path: Path): TypedAction(_path: Path): TypedAction({
+ type: 'object',
+ properties: { roomId: { type: 'string' } },
+ required: ['roomId'],
+ additionalProperties: false,
+});
- if (!canAccess) {
- return API.v1.forbidden();
- }
+const dmOpenEndpointsProps = {
+ authRequired: true,
+ body: isDmOpenProps,
+ response: {
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ 200: ajv.compile({
+ type: 'object',
+ properties: { success: { type: 'boolean', enum: [true] } },
+ required: ['success'],
+ additionalProperties: false,
+ }),
+ },
+} as const;
- const { room, subscription } = await findDirectMessageRoom({ roomId }, user);
+const dmOpenAction = (_path: Path): TypedAction =>
+ async function action() {
+ const { roomId } = this.bodyParams;
+ const canAccess = await canAccessRoomIdAsync(roomId, this.userId);
+ if (!canAccess) {
+ return API.v1.forbidden();
+ }
- lm = room?.lm ? new Date(room.lm).toISOString() : new Date(room._updatedAt).toISOString(); // lm is the last message timestamp
+ const { room, subscription } = await findDirectMessageRoom({ roomId }, this.userId);
- if (subscription) {
- unreads = subscription.unread ?? null;
- if (subscription.ls && room.msgs) {
- unreadsFrom = new Date(subscription.ls).toISOString(); // last read timestamp
- }
- userMentions = subscription.userMentions;
- joined = true;
- }
+ if (!subscription?.open) {
+ await openRoom(this.userId, room._id);
+ }
- if (access || joined) {
- msgs = room.msgs;
- latest = lm;
- members = await Users.countActiveUsersInDMRoom(room._id);
- }
+ return API.v1.success();
+ };
- return API.v1.success({
- joined,
- members,
- unreads,
- unreadsFrom,
- msgs,
- latest,
- userMentions,
- });
- },
+const isDmSetTopicProps = ajv.compile<{ roomId: string; topic?: string }>({
+ type: 'object',
+ properties: {
+ roomId: { type: 'string' },
+ topic: { type: 'string', nullable: true },
},
-);
+ required: ['roomId'],
+ additionalProperties: false,
+});
-API.v1.addRoute(
- ['dm.files', 'im.files'],
- {
- authRequired: true,
- validateParams: isDmFileProps,
+const dmSetTopicResponseSchema = ajv.compile<{ topic?: string }>({
+ type: 'object',
+ properties: {
+ topic: { type: 'string', nullable: true },
+ success: { type: 'boolean', enum: [true] },
},
- {
- async get() {
- const { typeGroup, name, roomId, username, onlyConfirmed } = this.queryParams;
+ required: ['success'],
+ additionalProperties: false,
+});
- const { offset, count } = await getPaginationItems(this.queryParams);
- const { sort, fields, query } = await this.parseJsonQuery();
+const dmSetTopicEndpointsProps = {
+ authRequired: true,
+ body: isDmSetTopicProps,
+ response: {
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ 200: dmSetTopicResponseSchema,
+ },
+};
- const { room } = await findDirectMessageRoom(roomId ? { roomId } : { username }, this.userId);
+const dmSetTopicAction = (_path: Path): TypedAction =>
+ async function action() {
+ const { roomId, topic } = this.bodyParams;
+ if (!roomId) {
+ throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" is required');
+ }
+ if (!this.userId) {
+ throw new Meteor.Error('error-invalid-user', 'Invalid user');
+ }
- const canAccess = await canAccessRoomIdAsync(room._id, this.userId);
- if (!canAccess) {
- return API.v1.forbidden();
- }
+ const canAccess = await canAccessRoomIdAsync(roomId, this.userId);
+ if (!canAccess) {
+ return API.v1.forbidden();
+ }
- const filter = {
- ...query,
- rid: room._id,
- ...(name ? { name: { $regex: name || '', $options: 'i' } } : {}),
- ...(typeGroup ? { typeGroup } : {}),
- ...(onlyConfirmed && { expiresAt: { $exists: false } }),
- };
+ const { room } = await findDirectMessageRoom({ roomId }, this.userId);
- const { cursor, totalCount } = Uploads.findPaginatedWithoutThumbs(filter, {
- sort: sort || { name: 1 },
- skip: offset,
- limit: count,
- projection: fields,
- });
+ // saveRoomTopic treats undefined and '' identically
+ await saveRoomSettings(this.userId, room._id, 'roomTopic', topic ?? '');
- const [files, total] = await Promise.all([cursor.toArray(), totalCount]);
+ return API.v1.success({
+ topic,
+ });
+ };
- return API.v1.success({
- files: await addUserToFileObj(files),
- count: files.length,
- offset,
- total,
- });
- },
+type DmCountersProps = {
+ roomId: string;
+ userId?: string;
+};
+
+const isDmCountersProps = ajvQuery.compile({
+ type: 'object',
+ properties: {
+ roomId: { type: 'string' },
+ userId: { type: 'string', nullable: true },
},
-);
-
-API.v1.addRoute(
- ['dm.history', 'im.history'],
- { authRequired: true, validateParams: isDmHistoryProps },
- {
- async get() {
- const { offset = 0, count = 20 } = await getPaginationItems(this.queryParams);
- const { roomId, latest, oldest, inclusive, unreads, showThreadMessages } = this.queryParams;
-
- if (!roomId) {
- throw new Meteor.Error('error-room-param-not-provided', 'Query param "roomId" is required');
- }
- const { room } = await findDirectMessageRoom({ roomId }, this.userId);
-
- const objectParams = {
- rid: room._id,
- fromUserId: this.userId,
- latest: latest ? new Date(latest) : new Date(),
- oldest: oldest ? new Date(oldest) : undefined,
- inclusive: inclusive === 'true',
- offset,
- count,
- unreads: unreads === 'true',
- showThreadMessages: showThreadMessages === 'true',
- };
+ required: ['roomId'],
+ additionalProperties: false,
+});
+
+const dmCountersResponseSchema = ajv.compile<{
+ joined: boolean;
+ members: number | null;
+ unreads: number | null;
+ unreadsFrom: string | null;
+ msgs: number | null;
+ latest: string | null;
+ userMentions: number | null;
+}>({
+ type: 'object',
+ properties: {
+ joined: { type: 'boolean' },
+ members: { type: 'number', nullable: true },
+ unreads: { type: 'number', nullable: true },
+ unreadsFrom: { type: 'string', nullable: true },
+ msgs: { type: 'number', nullable: true },
+ latest: { type: 'string', nullable: true },
+ userMentions: { type: 'number', nullable: true },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['joined', 'members', 'unreads', 'unreadsFrom', 'msgs', 'latest', 'userMentions', 'success'],
+ additionalProperties: false,
+});
- const result = await getChannelHistory(objectParams);
+const dmCountersEndpointsProps = {
+ authRequired: true,
+ query: isDmCountersProps,
+ response: {
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ 200: dmCountersResponseSchema,
+ },
+};
+
+const dmCountersAction = (_path: Path): TypedAction =>
+ async function action() {
+ if (!this.userId) {
+ throw new Meteor.Error('error-invalid-user', 'Invalid user');
+ }
- if (!result) {
+ const access = await hasPermissionAsync(this.userId, 'view-room-administration');
+ const { roomId, userId: ruserId } = this.queryParams;
+ if (!roomId) {
+ throw new Meteor.Error('error-room-param-not-provided', 'Query param "roomId" is required');
+ }
+ let user = this.userId;
+ let unreads = null;
+ let userMentions = null;
+ let unreadsFrom = null;
+ let joined = false;
+ let msgs = null;
+ let latest = null;
+ let members = null;
+ let lm = null;
+
+ if (ruserId) {
+ if (!access) {
return API.v1.forbidden();
}
+ user = ruserId;
+ }
+ const canAccess = await canAccessRoomIdAsync(roomId, user);
- return API.v1.success(result);
- },
- },
-);
+ if (!canAccess) {
+ return API.v1.forbidden();
+ }
-API.v1.addRoute(
- ['dm.members', 'im.members'],
- {
- authRequired: true,
- validateParams: isDmMemberProps,
- },
- {
- async get() {
- const { room } = await findDirectMessageRoom(this.queryParams, this.userId);
+ const { room, subscription } = await findDirectMessageRoom({ roomId }, user);
- const canAccess = await canAccessRoomIdAsync(room._id, this.userId);
- if (!canAccess) {
- return API.v1.forbidden();
+ lm = room?.lm ? new Date(room.lm).toISOString() : new Date(room._updatedAt).toISOString();
+
+ if (subscription) {
+ unreads = subscription.unread ?? null;
+ if (subscription.ls && room.msgs) {
+ unreadsFrom = new Date(subscription.ls).toISOString();
}
+ userMentions = subscription.userMentions;
+ joined = true;
+ }
- const { offset, count } = await getPaginationItems(this.queryParams);
- const { sort } = await this.parseJsonQuery();
-
- check(
- this.queryParams,
- Match.ObjectIncluding({
- status: Match.Maybe([String]),
- filter: Match.Maybe(String),
- }),
- );
- const { status, filter } = this.queryParams;
-
- const extraQuery = {
- _id: { $in: room.uids },
- ...(status && { status: { $in: status } }),
- };
+ if (access || joined) {
+ msgs = room.msgs;
+ latest = lm;
+ members = await Users.countActiveUsersInDMRoom(room._id);
+ }
- const options: FindOptions = {
- projection: {
- _id: 1,
- username: 1,
- name: 1,
- status: 1,
- statusText: 1,
- utcOffset: 1,
- federated: 1,
- freeSwitchExtension: 1,
- },
- skip: offset,
- limit: count,
- sort: {
- _updatedAt: -1,
- username: sort?.username ? sort.username : 1,
- },
- };
+ return API.v1.success({
+ joined,
+ members,
+ unreads,
+ unreadsFrom,
+ msgs,
+ latest,
+ userMentions,
+ });
+ };
- const searchFields = settings.get('Accounts_SearchFields').trim().split(',');
+const dmFilesResponseSchema = ajv.compile<{ files: object[]; count: number; offset: number; total: number }>({
+ type: 'object',
+ properties: {
+ files: { type: 'array', items: { type: 'object' } }, // relaxed: IUpload with addUserToFileObj transform
+ count: { type: 'number' },
+ offset: { type: 'number' },
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['files', 'count', 'offset', 'total', 'success'],
+ additionalProperties: false,
+});
- const { cursor, totalCount } = Users.findPaginatedByActiveUsersExcept(filter, [], options, searchFields, [extraQuery]);
+const dmFilesEndpointsProps = {
+ authRequired: true,
+ query: isDmFileProps,
+ response: {
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ 200: dmFilesResponseSchema,
+ },
+};
- const [members, total] = await Promise.all([cursor.toArray(), totalCount]);
+const dmFilesAction = (_path: Path): TypedAction =>
+ async function action() {
+ if (!this.userId) {
+ throw new Meteor.Error('error-invalid-user', 'Invalid user');
+ }
- // find subscriptions of those users
- const subs = await Subscriptions.findByRoomIdAndUserIds(
- room._id,
- members.map((member) => member._id),
- { projection: { u: 1, status: 1, ts: 1, roles: 1 } },
- ).toArray();
+ const { typeGroup, name, roomId, username, onlyConfirmed } = this.queryParams;
- const membersWithSubscriptionInfo = members.map((member) => {
- const sub = subs.find((sub) => sub.u._id === member._id);
+ const { offset, count } = await getPaginationItems(this.queryParams);
+ const { sort, fields, query } = await this.parseJsonQuery();
- const { u: _u, ...subscription } = sub || {};
+ const { room } = await findDirectMessageRoom(roomId ? { roomId } : { username }, this.userId);
- return {
- ...member,
- subscription,
- };
- });
+ const canAccess = await canAccessRoomIdAsync(room._id, this.userId);
+ if (!canAccess) {
+ return API.v1.forbidden();
+ }
- return API.v1.success({
- members: membersWithSubscriptionInfo,
- count: members.length,
- offset,
- total,
- });
- },
+ const filter = {
+ ...query,
+ rid: room._id,
+ ...(name ? { name: { $regex: name || '', $options: 'i' } } : {}),
+ ...(typeGroup ? { typeGroup } : {}),
+ ...(onlyConfirmed && { expiresAt: { $exists: false } }),
+ };
+
+ const { cursor, totalCount } = Uploads.findPaginatedWithoutThumbs(filter, {
+ sort: sort || { name: 1 },
+ skip: offset,
+ limit: count,
+ projection: fields,
+ });
+
+ const [files, total] = await Promise.all([cursor.toArray(), totalCount]);
+
+ return API.v1.success({
+ files: await addUserToFileObj(files),
+ count: files.length,
+ offset,
+ total,
+ });
+ };
+
+const dmMembersResponseSchema = ajv.compile<{ members: object[]; count: number; offset: number; total: number }>({
+ type: 'object',
+ properties: {
+ members: { type: 'array', items: { type: 'object' } }, // relaxed: projected IUser + subscription info
+ count: { type: 'number' },
+ offset: { type: 'number' },
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
},
-);
+ required: ['members', 'count', 'offset', 'total', 'success'],
+ additionalProperties: false,
+});
-API.v1.addRoute(
- ['dm.messages', 'im.messages'],
- {
- authRequired: true,
- validateParams: isDmMessagesProps,
+const dmMembersEndpointsProps = {
+ authRequired: true,
+ query: isDmMemberProps,
+ response: {
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ 200: dmMembersResponseSchema,
},
- {
- async get() {
- const { roomId, username, mentionIds, starredIds, pinned } = this.queryParams;
+};
- const { room } = await findDirectMessageRoom({ ...(roomId ? { roomId } : { username }) }, this.userId);
+const dmMembersAction = (_path: Path): TypedAction =>
+ async function action() {
+ if (!this.userId) {
+ throw new Meteor.Error('error-invalid-user', 'Invalid user');
+ }
- const canAccess = await canAccessRoomIdAsync(room._id, this.userId);
- if (!canAccess) {
- return API.v1.forbidden();
- }
+ const { room } = await findDirectMessageRoom(this.queryParams, this.userId);
- const { offset, count } = await getPaginationItems(this.queryParams);
- const { sort, fields, query } = await this.parseJsonQuery();
+ const canAccess = await canAccessRoomIdAsync(room._id, this.userId);
+ if (!canAccess) {
+ return API.v1.forbidden();
+ }
+
+ const { offset, count } = await getPaginationItems(this.queryParams);
+ const { sort } = await this.parseJsonQuery();
+
+ check(
+ this.queryParams,
+ Match.ObjectIncluding({
+ status: Match.Maybe([String]),
+ filter: Match.Maybe(String),
+ }),
+ );
+ const { status, filter } = this.queryParams;
+
+ const extraQuery: Record = {
+ _id: { $in: room.uids },
+ ...(status && { status: { $in: status } }),
+ };
+
+ const options: FindOptions = {
+ projection: {
+ _id: 1,
+ username: 1,
+ name: 1,
+ status: 1,
+ statusText: 1,
+ utcOffset: 1,
+ federated: 1,
+ freeSwitchExtension: 1,
+ },
+ skip: offset,
+ limit: count,
+ sort: {
+ _updatedAt: -1,
+ username: sort?.username ? sort.username : 1,
+ },
+ };
+
+ const searchFields = settings.get('Accounts_SearchFields').trim().split(',');
- const parseIds = (ids: string | undefined, field: string) =>
- typeof ids === 'string' && ids ? { [field]: { $in: ids.split(',').map((id) => id.trim()) } } : {};
+ const { cursor, totalCount } = Users.findPaginatedByActiveUsersExcept(filter ?? '', [], options, searchFields, [extraQuery]);
- const ourQuery = {
- rid: room._id,
- ...query,
- ...parseIds(mentionIds, 'mentions._id'),
- ...parseIds(starredIds, 'starred._id'),
- ...(pinned && pinned.toLowerCase() === 'true' ? { pinned: true } : {}),
- _hidden: { $ne: true },
+ const [members, total] = await Promise.all([cursor.toArray(), totalCount]);
+
+ // find subscriptions of those users
+ const subs = await Subscriptions.findByRoomIdAndUserIds(
+ room._id,
+ members.map((member) => member._id),
+ { projection: { u: 1, status: 1, ts: 1, roles: 1 } },
+ ).toArray();
+
+ const membersWithSubscriptionInfo = members.map((member) => {
+ const sub = subs.find((sub) => sub.u._id === member._id);
+
+ const { u: _u, ...subscription } = sub || {};
+
+ return {
+ ...member,
+ subscription,
};
- const sortObj = sort || { ts: -1 };
+ });
- const { cursor, totalCount } = Messages.findPaginated(ourQuery, {
- sort: sortObj,
- skip: offset,
- limit: count,
- ...(fields && { projection: fields }),
- });
+ return API.v1.success({
+ members: membersWithSubscriptionInfo,
+ count: members.length,
+ offset,
+ total,
+ });
+ };
- const [messages, total] = await Promise.all([cursor.toArray(), totalCount]);
+const dmMessagesResponseSchema = ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({
+ type: 'object',
+ properties: {
+ messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } },
+ count: { type: 'number' },
+ offset: { type: 'number' },
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['messages', 'count', 'offset', 'total', 'success'],
+ additionalProperties: false,
+});
- return API.v1.success({
- messages: await normalizeMessagesForUser(messages, this.userId),
- count: messages.length,
- offset,
- total,
- });
- },
+const dmMessagesEndpointsProps = {
+ authRequired: true,
+ query: isDmMessagesProps,
+ response: {
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ 200: dmMessagesResponseSchema,
},
-);
-
-API.v1.addRoute(
- ['dm.messages.others', 'im.messages.others'],
- { authRequired: true, permissionsRequired: ['view-room-administration'] },
- {
- async get() {
- if (settings.get('API_Enable_Direct_Message_History_EndPoint') !== true) {
- throw new Meteor.Error('error-endpoint-disabled', 'This endpoint is disabled', {
- route: '/api/v1/im.messages.others',
- });
- }
+};
- const { roomId } = this.queryParams;
- if (!roomId) {
- throw new Meteor.Error('error-roomid-param-not-provided', 'The parameter "roomId" is required');
- }
+const dmMessagesAction = (_path: Path): TypedAction =>
+ async function action() {
+ if (!this.userId) {
+ throw new Meteor.Error('error-invalid-user', 'Invalid user');
+ }
- const room = await Rooms.findOneById>(roomId, { projection: { _id: 1, t: 1 } });
- if (!room || room?.t !== 'd') {
- throw new Meteor.Error('error-room-not-found', `No direct message room found by the id of: ${roomId}`);
- }
+ const { roomId, username, mentionIds, starredIds, pinned } = this.queryParams as {
+ roomId?: string;
+ username?: string;
+ mentionIds?: string;
+ starredIds?: string;
+ pinned?: string;
+ };
- const { offset, count } = await getPaginationItems(this.queryParams);
- const { sort, fields, query } = await this.parseJsonQuery();
- const ourQuery = Object.assign({}, query, { rid: room._id });
+ const { room } = await findDirectMessageRoom({ ...(roomId ? { roomId } : { username }) }, this.userId);
- const { cursor, totalCount } = Messages.findPaginated(ourQuery, {
- sort: sort || { ts: -1 },
- skip: offset,
- limit: count,
- projection: fields,
- });
+ const canAccess = await canAccessRoomIdAsync(room._id, this.userId);
+ if (!canAccess) {
+ return API.v1.forbidden();
+ }
- const [msgs, total] = await Promise.all([cursor.toArray(), totalCount]);
+ const { offset, count } = await getPaginationItems(this.queryParams);
+ const { sort, fields, query } = await this.parseJsonQuery();
+
+ const parseIds = (ids: string | undefined, field: string) =>
+ typeof ids === 'string' && ids ? { [field]: { $in: ids.split(',').map((id) => id.trim()) } } : {};
+
+ const ourQuery = {
+ rid: room._id,
+ ...query,
+ ...parseIds(mentionIds, 'mentions._id'),
+ ...parseIds(starredIds, 'starred._id'),
+ ...(pinned?.toLowerCase() === 'true' && { pinned: true }),
+ _hidden: { $ne: true },
+ };
+ const sortObj = sort || { ts: -1 };
+
+ const { cursor, totalCount } = Messages.findPaginated(ourQuery, {
+ sort: sortObj,
+ skip: offset,
+ limit: count,
+ ...(fields && { projection: fields }),
+ });
- if (!msgs) {
- throw new Meteor.Error('error-no-messages', 'No messages found');
- }
+ const [messages, total] = await Promise.all([cursor.toArray(), totalCount]);
- return API.v1.success({
- messages: await normalizeMessagesForUser(msgs, this.userId),
- offset,
- count: msgs.length,
- total,
- });
- },
+ return API.v1.success({
+ messages: await normalizeMessagesForUser(messages, this.userId),
+ count: messages.length,
+ offset,
+ total,
+ });
+ };
+
+const dmHistoryResponseSchema = ajv.compile>({
+ type: 'object',
+ properties: {
+ messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } },
+ success: { type: 'boolean', enum: [true] },
},
-);
-
-API.v1.addRoute(
- ['dm.list', 'im.list'],
- { authRequired: true },
- {
- async get() {
- const { offset, count } = await getPaginationItems(this.queryParams);
- const { sort = { name: 1 }, fields } = await this.parseJsonQuery();
-
- // TODO: CACHE: Add Breaking notice since we removed the query param
-
- const subscriptions = await Subscriptions.find({ 'u._id': this.userId, 't': 'd' }, { projection: { rid: 1 } })
- .map((item) => item.rid)
- .toArray();
-
- const { cursor, totalCount } = Rooms.findPaginated(
- { t: 'd', _id: { $in: subscriptions } },
- {
- sort,
- skip: offset,
- limit: count,
- projection: fields,
- },
- );
+ required: ['messages', 'success'],
+ additionalProperties: true,
+});
- const [ims, total] = await Promise.all([cursor.toArray(), totalCount]);
+const dmHistoryEndpointsProps = {
+ authRequired: true,
+ query: isDmHistoryProps,
+ response: {
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ 200: dmHistoryResponseSchema,
+ },
+};
- return API.v1.success({
- ims: await Promise.all(ims.map((room: IRoom) => composeRoomWithLastMessage(room, this.userId))),
- offset,
- count: ims.length,
- total,
- });
- },
+const dmHistoryAction = (_path: Path): TypedAction =>
+ async function action() {
+ if (!this.userId) {
+ throw new Meteor.Error('error-invalid-user', 'Invalid user');
+ }
+
+ const { offset = 0, count = 20 } = await getPaginationItems(this.queryParams);
+ const { roomId, latest, oldest, inclusive, unreads, showThreadMessages } = this.queryParams;
+
+ if (!roomId) {
+ throw new Meteor.Error('error-room-param-not-provided', 'Query param "roomId" is required');
+ }
+ const { room } = await findDirectMessageRoom({ roomId }, this.userId);
+
+ const objectParams = {
+ rid: room._id,
+ fromUserId: this.userId,
+ latest: latest ? new Date(latest) : new Date(),
+ oldest: oldest ? new Date(oldest) : undefined,
+ inclusive: inclusive === 'true',
+ offset,
+ count,
+ unreads: unreads === 'true',
+ showThreadMessages: showThreadMessages === 'true',
+ };
+
+ const result = await getChannelHistory(objectParams);
+
+ if (!result) {
+ return API.v1.forbidden();
+ }
+
+ return API.v1.success(result as Record);
+ };
+
+const dmCreateResponseSchema = ajv.compile<{ room: IRoom & { rid: string } }>({
+ type: 'object',
+ properties: {
+ room: { type: 'object' }, // relaxed: IRoom shape varies,
+ success: { type: 'boolean', enum: [true] },
},
-);
-
-API.v1.addRoute(
- ['dm.list.everyone', 'im.list.everyone'],
- { authRequired: true, permissionsRequired: ['view-room-administration'] },
- {
- async get() {
- const { offset, count }: { offset: number; count: number } = await getPaginationItems(this.queryParams);
- const { sort, fields, query } = await this.parseJsonQuery();
-
- const { cursor, totalCount } = Rooms.findPaginated(
- { ...query, t: 'd' },
- {
- sort: sort || { name: 1 },
- skip: offset,
- limit: count,
- projection: fields,
- },
- );
+ required: ['room', 'success'],
+ additionalProperties: false,
+});
- const [rooms, total] = await Promise.all([cursor.toArray(), totalCount]);
+const paginatedMessagesResponseSchema = ajv.compile<{ messages: IMessage[]; offset: number; count: number; total: number }>({
+ type: 'object',
+ properties: {
+ messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } },
+ offset: { type: 'number' },
+ count: { type: 'number' },
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['messages', 'offset', 'count', 'total', 'success'],
+ additionalProperties: false,
+});
- return API.v1.success({
- ims: await Promise.all(rooms.map((room: IRoom) => composeRoomWithLastMessage(room, this.userId))),
- offset,
- count: rooms.length,
- total,
- });
- },
+const paginatedImsResponseSchema = ajv.compile<{ ims: IRoom[]; offset: number; count: number; total: number }>({
+ type: 'object',
+ properties: {
+ ims: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom with lastMessage compose
+ offset: { type: 'number' },
+ count: { type: 'number' },
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
},
-);
+ required: ['ims', 'offset', 'count', 'total', 'success'],
+ additionalProperties: false,
+});
-API.v1.addRoute(
- ['dm.open', 'im.open'],
- { authRequired: true },
- {
- async post() {
- const { roomId } = this.bodyParams;
+const dmMessagesOthersEndpointsProps = {
+ authRequired: true as const,
+ permissionsRequired: ['view-room-administration'],
+ response: {
+ 200: paginatedMessagesResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
+};
- if (!roomId) {
- throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" is required');
- }
- const canAccess = await canAccessRoomIdAsync(roomId, this.userId);
- if (!canAccess) {
- return API.v1.forbidden();
- }
+const dmMessagesOthersAction = (_name: Path): TypedAction =>
+ async function action() {
+ if (settings.get('API_Enable_Direct_Message_History_EndPoint') !== true) {
+ throw new Meteor.Error('error-endpoint-disabled', 'This endpoint is disabled', {
+ route: '/api/v1/im.messages.others',
+ });
+ }
- const { room, subscription } = await findDirectMessageRoom({ roomId }, this.userId);
+ const { roomId } = this.queryParams;
+ if (!roomId) {
+ throw new Meteor.Error('error-roomid-param-not-provided', 'The parameter "roomId" is required');
+ }
- if (!subscription?.open) {
- await openRoom(this.userId, room._id);
- }
+ const room = await Rooms.findOneById>(roomId, { projection: { _id: 1, t: 1 } });
+ if (!room || room?.t !== 'd') {
+ throw new Meteor.Error('error-room-not-found', `No direct message room found by the id of: ${roomId}`);
+ }
- return API.v1.success();
- },
+ const { offset, count } = await getPaginationItems(this.queryParams);
+ const { sort, fields, query } = await this.parseJsonQuery();
+ const ourQuery = Object.assign({}, query, { rid: room._id });
+
+ const { cursor, totalCount } = Messages.findPaginated(ourQuery, {
+ sort: sort || { ts: -1 },
+ skip: offset,
+ limit: count,
+ projection: fields,
+ });
+
+ const [msgs, total] = await Promise.all([cursor.toArray(), totalCount]);
+
+ if (!msgs) {
+ throw new Meteor.Error('error-no-messages', 'No messages found');
+ }
+
+ return API.v1.success({
+ messages: await normalizeMessagesForUser(msgs, this.userId),
+ offset,
+ count: msgs.length,
+ total,
+ });
+ };
+
+const dmListEndpointsProps = {
+ authRequired: true as const,
+ response: {
+ 200: paginatedImsResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
},
-);
+};
-API.v1.addRoute(
- ['dm.setTopic', 'im.setTopic'],
- { authRequired: true },
- {
- async post() {
- const { roomId, topic } = this.bodyParams;
+const dmListAction = (_name: Path): TypedAction =>
+ async function action() {
+ const { offset, count } = await getPaginationItems(this.queryParams);
+ const { sort = { name: 1 }, fields } = await this.parseJsonQuery();
- if (!roomId) {
- throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" is required');
- }
+ // TODO: CACHE: Add Breaking notice since we removed the query param
- const canAccess = await canAccessRoomIdAsync(roomId, this.userId);
- if (!canAccess) {
- return API.v1.forbidden();
- }
+ const subscriptions = await Subscriptions.find({ 'u._id': this.userId, 't': 'd' }, { projection: { rid: 1 } })
+ .map((item) => item.rid)
+ .toArray();
- const { room } = await findDirectMessageRoom({ roomId }, this.userId);
+ const { cursor, totalCount } = Rooms.findPaginated(
+ { t: 'd', _id: { $in: subscriptions } },
+ {
+ sort,
+ skip: offset,
+ limit: count,
+ projection: fields,
+ },
+ );
- await saveRoomSettings(this.userId, room._id, 'roomTopic', topic);
+ const [ims, total] = await Promise.all([cursor.toArray(), totalCount]);
- return API.v1.success({
- topic,
- });
- },
+ return API.v1.success({
+ ims: await Promise.all(ims.map((room: IRoom) => composeRoomWithLastMessage(room, this.userId))),
+ offset,
+ count: ims.length,
+ total,
+ });
+ };
+
+const dmListEveryoneEndpointsProps = {
+ authRequired: true as const,
+ permissionsRequired: ['view-room-administration'],
+ response: {
+ 200: paginatedImsResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
+};
+
+const dmListEveryoneAction = (_name: Path): TypedAction =>
+ async function action() {
+ const { offset, count }: { offset: number; count: number } = await getPaginationItems(this.queryParams);
+ const { sort, fields, query } = await this.parseJsonQuery();
+
+ const { cursor, totalCount } = Rooms.findPaginated(
+ { ...query, t: 'd' },
+ {
+ sort: sort || { name: 1 },
+ skip: offset,
+ limit: count,
+ projection: fields,
+ },
+ );
+
+ const [rooms, total] = await Promise.all([cursor.toArray(), totalCount]);
+
+ return API.v1.success({
+ ims: await Promise.all(rooms.map((room: IRoom) => composeRoomWithLastMessage(room, this.userId))),
+ offset,
+ count: rooms.length,
+ total,
+ });
+ };
+
+const dmCreateEndpointsProps = {
+ authRequired: true,
+ body: isDmCreateProps,
+ response: {
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 200: dmCreateResponseSchema,
},
-);
+} as const;
+
+const dmCreateAction = (_path: Path): TypedAction =>
+ async function action() {
+ const users =
+ 'username' in this.bodyParams
+ ? [this.bodyParams.username]
+ : this.bodyParams.usernames.split(',').map((username: string) => username.trim());
+
+ const room = await createDirectMessage(users, this.userId, this.bodyParams.excludeSelf);
+
+ return API.v1.success({
+ room: { ...room, _id: room.rid },
+ });
+ };
+
+const dmEndpoints = API.v1
+ .post('im.delete', dmDeleteEndpointsProps, dmDeleteAction('im.delete'))
+ .post('dm.delete', dmDeleteEndpointsProps, dmDeleteAction('dm.delete'))
+ .post('dm.close', dmCloseEndpointsProps, dmCloseAction('dm.close'))
+ .post('im.close', dmCloseEndpointsProps, dmCloseAction('im.close'))
+ .post('dm.create', dmCreateEndpointsProps, dmCreateAction('dm.create'))
+ .post('im.create', dmCreateEndpointsProps, dmCreateAction('im.create'))
+ .post('dm.open', dmOpenEndpointsProps, dmOpenAction('dm.open'))
+ .post('im.open', dmOpenEndpointsProps, dmOpenAction('im.open'))
+ .post('dm.setTopic', dmSetTopicEndpointsProps, dmSetTopicAction('dm.setTopic'))
+ .post('im.setTopic', dmSetTopicEndpointsProps, dmSetTopicAction('im.setTopic'))
+ .get('dm.counters', dmCountersEndpointsProps, dmCountersAction('dm.counters'))
+ .get('im.counters', dmCountersEndpointsProps, dmCountersAction('im.counters'))
+ .get('dm.files', dmFilesEndpointsProps, dmFilesAction('dm.files'))
+ .get('im.files', dmFilesEndpointsProps, dmFilesAction('im.files'))
+ .get('dm.members', dmMembersEndpointsProps, dmMembersAction('dm.members'))
+ .get('im.members', dmMembersEndpointsProps, dmMembersAction('im.members'))
+ .get('dm.messages', dmMessagesEndpointsProps, dmMessagesAction('dm.messages'))
+ .get('im.messages', dmMessagesEndpointsProps, dmMessagesAction('im.messages'))
+ .get('dm.history', dmHistoryEndpointsProps, dmHistoryAction('dm.history'))
+ .get('im.history', dmHistoryEndpointsProps, dmHistoryAction('im.history'))
+ .get('dm.messages.others', dmMessagesOthersEndpointsProps, dmMessagesOthersAction('dm.messages.others'))
+ .get('im.messages.others', dmMessagesOthersEndpointsProps, dmMessagesOthersAction('im.messages.others'))
+ .get('dm.list', dmListEndpointsProps, dmListAction('dm.list'))
+ .get('im.list', dmListEndpointsProps, dmListAction('im.list'))
+ .get('dm.list.everyone', dmListEveryoneEndpointsProps, dmListEveryoneAction('dm.list.everyone'))
+ .get('im.list.everyone', dmListEveryoneEndpointsProps, dmListEveryoneAction('im.list.everyone'));
export type DmEndpoints = ExtractRoutesFromAPI;
diff --git a/apps/meteor/app/api/server/v1/integrations.ts b/apps/meteor/app/api/server/v1/integrations.ts
index d3dd51139bbc2..2870fdb8236e8 100644
--- a/apps/meteor/app/api/server/v1/integrations.ts
+++ b/apps/meteor/app/api/server/v1/integrations.ts
@@ -33,7 +33,7 @@ import { findOneIntegration } from '../lib/integrations';
const integrationSuccessSchema = ajv.compile<{ integration: IIntegration | null }>({
type: 'object',
properties: {
- integration: { type: 'object' },
+ integration: { oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }] },
success: { type: 'boolean', enum: [true] },
},
required: ['integration', 'success'],
@@ -79,7 +79,7 @@ API.v1.get(
200: ajv.compile<{ history: IIntegrationHistory[]; offset: number; items: number; count: number; total: number }>({
type: 'object',
properties: {
- history: { type: 'array', items: { type: 'object' } },
+ history: { type: 'array', items: { $ref: '#/components/schemas/IIntegrationHistory' } },
offset: { type: 'number' },
items: { type: 'number' },
count: { type: 'number' },
@@ -147,7 +147,9 @@ API.v1.get(
properties: {
integrations: {
type: 'array',
- items: { type: 'object' },
+ items: {
+ oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }],
+ },
},
offset: { type: 'number' },
items: { type: 'number' },
@@ -280,7 +282,9 @@ API.v1.get(
200: ajv.compile<{ integration: IIntegration | null }>({
type: 'object',
properties: {
- integration: { type: 'object' },
+ integration: {
+ oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }],
+ },
success: { type: 'boolean', enum: [true] },
},
required: ['integration', 'success'],
diff --git a/apps/meteor/app/api/server/v1/invites.ts b/apps/meteor/app/api/server/v1/invites.ts
index 1c4b6ed4433ee..b5a6fbd12c36c 100644
--- a/apps/meteor/app/api/server/v1/invites.ts
+++ b/apps/meteor/app/api/server/v1/invites.ts
@@ -6,6 +6,7 @@ import {
isValidateInviteTokenProps,
isSendInvitationEmailParams,
validateBadRequestErrorResponse,
+ validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import { findOrCreateInvite } from '../../../invites/server/functions/findOrCreateInvite';
@@ -17,6 +18,51 @@ import { validateInviteToken } from '../../../invites/server/functions/validateI
import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';
+const removeInviteResponseSchema = ajv.compile({
+ type: 'boolean',
+ enum: [true],
+});
+
+const useInviteTokenResponseSchema = ajv.compile({
+ type: 'object',
+ properties: {
+ room: {
+ type: 'object',
+ properties: {
+ rid: { type: 'string' },
+ prid: { type: 'string', nullable: true },
+ fname: { type: 'string', nullable: true },
+ name: { type: 'string', nullable: true },
+ t: { type: 'string' },
+ },
+ required: ['rid', 't'],
+ additionalProperties: false,
+ },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['room', 'success'],
+ additionalProperties: false,
+});
+
+const validateInviteTokenResponseSchema = ajv.compile<{ valid: boolean }>({
+ type: 'object',
+ properties: {
+ valid: { type: 'boolean' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['valid', 'success'],
+ additionalProperties: false,
+});
+
+const sendInvitationEmailResponseSchema = ajv.compile<{ success: boolean }>({
+ type: 'object',
+ properties: {
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['success'],
+ additionalProperties: false,
+});
+
const invites = API.v1
.get(
'listInvites',
@@ -177,10 +223,7 @@ const invites = API.v1
{
authRequired: true,
response: {
- 200: ajv.compile({
- type: 'boolean',
- enum: [true],
- }),
+ 200: removeInviteResponseSchema,
400: validateBadRequestErrorResponse,
401: ajv.compile({
type: 'object',
@@ -192,7 +235,6 @@ const invites = API.v1
},
async function action() {
const { _id } = this.urlParams;
-
return API.v1.success(await removeInvite(this.userId, { _id }));
},
)
@@ -202,33 +244,9 @@ const invites = API.v1
authRequired: true,
body: isUseInviteTokenProps,
response: {
- 200: ajv.compile({
- type: 'object',
- properties: {
- room: {
- type: 'object',
- properties: {
- rid: { type: 'string' },
- prid: { type: 'string', nullable: true },
- fname: { type: 'string', nullable: true },
- name: { type: 'string', nullable: true },
- t: { type: 'string' },
- },
- required: ['rid', 't'],
- additionalProperties: false,
- },
- success: { type: 'boolean', enum: [true] },
- },
- required: ['room', 'success'],
- additionalProperties: false,
- }),
+ 200: useInviteTokenResponseSchema,
400: validateBadRequestErrorResponse,
- 401: ajv.compile({
- type: 'object',
- properties: { error: { type: 'string' }, success: { type: 'boolean', enum: [false] } },
- required: ['success', 'error'],
- additionalProperties: false,
- }),
+ 401: validateUnauthorizedErrorResponse,
},
},
async function action() {
@@ -242,15 +260,7 @@ const invites = API.v1
authRequired: false,
body: isValidateInviteTokenProps,
response: {
- 200: ajv.compile({
- type: 'object',
- properties: {
- valid: { type: 'boolean' },
- success: { type: 'boolean', enum: [true] },
- },
- required: ['valid', 'success'],
- additionalProperties: false,
- }),
+ 200: validateInviteTokenResponseSchema,
},
},
async function action() {
@@ -268,24 +278,9 @@ const invites = API.v1
authRequired: true,
body: isSendInvitationEmailParams,
response: {
- 200: ajv.compile({
- type: 'object',
- properties: { success: { type: 'boolean' } },
- required: ['success'],
- additionalProperties: false,
- }),
- 400: ajv.compile({
- type: 'object',
- properties: { error: { type: 'string' }, success: { type: 'boolean', enum: [false] } },
- required: ['success', 'error'],
- additionalProperties: false,
- }),
- 401: ajv.compile({
- type: 'object',
- properties: { error: { type: 'string' }, success: { type: 'boolean', enum: [false] } },
- required: ['success', 'error'],
- additionalProperties: false,
- }),
+ 200: sendInvitationEmailResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
},
},
async function action() {
diff --git a/apps/meteor/app/api/server/v1/mailer.ts b/apps/meteor/app/api/server/v1/mailer.ts
index 57fa62e85a6c3..2186d1e04e8dd 100644
--- a/apps/meteor/app/api/server/v1/mailer.ts
+++ b/apps/meteor/app/api/server/v1/mailer.ts
@@ -1,41 +1,72 @@
-import { isMailerProps, isMailerUnsubscribeProps } from '@rocket.chat/rest-typings';
+import {
+ ajv,
+ isMailerProps,
+ isMailerUnsubscribeProps,
+ validateUnauthorizedErrorResponse,
+ validateForbiddenErrorResponse,
+ validateBadRequestErrorResponse,
+} from '@rocket.chat/rest-typings';
import { sendMail } from '../../../mail-messages/server/functions/sendMail';
import { Mailer } from '../../../mail-messages/server/lib/Mailer';
import { API } from '../api';
-API.v1.addRoute(
+const mailerResponseSchema = ajv.compile({
+ type: 'object',
+ properties: {
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['success'],
+ additionalProperties: true,
+});
+
+const mailerUnsubscribeResponseSchema = ajv.compile({
+ type: 'object',
+ properties: {
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['success'],
+ additionalProperties: false,
+});
+
+API.v1.post(
'mailer',
{
authRequired: true,
- validateParams: isMailerProps,
+ body: isMailerProps,
permissionsRequired: ['send-mail'],
+ response: {
+ 200: mailerResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
},
- {
- async post() {
- const { from, subject, body, dryrun, query } = this.bodyParams;
+ async function action() {
+ const { from, subject, body, dryrun, query } = this.bodyParams;
- const result = await sendMail({ from, subject, body, dryrun: Boolean(dryrun), query });
+ const result = await sendMail({ from, subject, body, dryrun: Boolean(dryrun), query });
- return API.v1.success(result);
- },
+ return API.v1.success(result);
},
);
-API.v1.addRoute(
+API.v1.post(
'mailer.unsubscribe',
{
authRequired: true,
- validateParams: isMailerUnsubscribeProps,
+ body: isMailerUnsubscribeProps,
rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 1 },
+ response: {
+ 200: mailerUnsubscribeResponseSchema,
+ 401: validateUnauthorizedErrorResponse,
+ },
},
- {
- async post() {
- const { _id, createdAt } = this.bodyParams;
+ async function action() {
+ const { _id, createdAt } = this.bodyParams;
- await Mailer.unsubscribe(_id, createdAt);
+ await Mailer.unsubscribe(_id, createdAt);
- return API.v1.success();
- },
+ return API.v1.success();
},
);
diff --git a/apps/meteor/app/api/server/v1/media-calls.ts b/apps/meteor/app/api/server/v1/media-calls.ts
new file mode 100644
index 0000000000000..d526ec549ea1c
--- /dev/null
+++ b/apps/meteor/app/api/server/v1/media-calls.ts
@@ -0,0 +1,212 @@
+import { MediaCall } from '@rocket.chat/core-services';
+import type { IMediaCall } from '@rocket.chat/core-typings';
+import type { CallAnswer, CallFeature, ServerMediaCallSignal } from '@rocket.chat/media-signaling';
+import { callFeatureList, callAnswerList } from '@rocket.chat/media-signaling';
+import { MediaCalls } from '@rocket.chat/models';
+import {
+ ajv,
+ validateNotFoundErrorResponse,
+ validateBadRequestErrorResponse,
+ validateUnauthorizedErrorResponse,
+ validateForbiddenErrorResponse,
+} from '@rocket.chat/rest-typings';
+import type { JSONSchemaType } from 'ajv';
+
+import type { ExtractRoutesFromAPI } from '../ApiClass';
+import { API } from '../api';
+
+type MediaCallsAnswer = {
+ callId: string;
+ contractId: string;
+
+ answer: CallAnswer;
+
+ supportedFeatures?: CallFeature[];
+};
+
+const MediaCallsAnswerSchema: JSONSchemaType = {
+ type: 'object',
+ properties: {
+ callId: {
+ type: 'string',
+ },
+ contractId: {
+ type: 'string',
+ },
+ answer: {
+ type: 'string',
+ enum: callAnswerList,
+ },
+ supportedFeatures: {
+ type: 'array',
+ items: {
+ type: 'string',
+ enum: callFeatureList,
+ },
+ nullable: true,
+ },
+ },
+ required: ['callId', 'contractId', 'answer'],
+ additionalProperties: false,
+};
+
+export const isMediaCallsAnswerProps = ajv.compile(MediaCallsAnswerSchema);
+
+const mediaCallsAnswerEndpoints = API.v1.post(
+ 'media-calls.answer',
+ {
+ response: {
+ 200: ajv.compile<{
+ call: IMediaCall;
+ }>({
+ additionalProperties: false,
+ type: 'object',
+ properties: {
+ call: {
+ type: 'object',
+ $ref: '#/components/schemas/IMediaCall',
+ description: 'The updated call information.',
+ },
+ success: {
+ type: 'boolean',
+ description: 'Indicates if the request was successful.',
+ },
+ },
+ required: ['call', 'success'],
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ 404: validateNotFoundErrorResponse,
+ },
+ body: isMediaCallsAnswerProps,
+ authRequired: true,
+ },
+ async function action() {
+ const call = await MediaCall.answerCall(this.userId, this.bodyParams);
+
+ return API.v1.success({
+ call,
+ });
+ },
+);
+
+type MediaCallsAnswerEndpoints = ExtractRoutesFromAPI;
+
+declare module '@rocket.chat/rest-typings' {
+ // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
+ interface Endpoints extends MediaCallsAnswerEndpoints {}
+}
+
+type MediaCallsStateSignalsParams = {
+ contractId: string;
+};
+
+const MediaCallsStateSignalsSchema: JSONSchemaType = {
+ type: 'object',
+ properties: {
+ contractId: {
+ type: 'string',
+ },
+ },
+ required: ['contractId'],
+ additionalProperties: false,
+};
+
+export const isMediaCallsStateSignalsProps = ajv.compile(MediaCallsStateSignalsSchema);
+
+const mediaCallsStateEndpoints = API.v1
+ .get(
+ 'media-calls.state',
+ {
+ response: {
+ 200: ajv.compile<{
+ calls: IMediaCall[];
+ }>({
+ additionalProperties: false,
+ type: 'object',
+ properties: {
+ calls: {
+ type: 'array',
+ items: {
+ type: 'object',
+ $ref: '#/components/schemas/IMediaCall',
+ },
+ description: 'The list of active calls.',
+ },
+ success: {
+ type: 'boolean',
+ description: 'Indicates the request was successful.',
+ },
+ },
+ required: ['calls', 'success'],
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ 404: validateNotFoundErrorResponse,
+ },
+ authRequired: true,
+ },
+ async function action() {
+ const calls = await MediaCalls.findAllNotOverByUid(this.userId).toArray();
+
+ return API.v1.success({
+ calls,
+ });
+ },
+ )
+ .get(
+ 'media-calls.stateSignals',
+ {
+ response: {
+ 200: ajv.compile<{
+ signals: ServerMediaCallSignal[];
+ }>({
+ additionalProperties: false,
+ type: 'object',
+ properties: {
+ signals: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ type: { type: 'string' },
+ callId: { type: 'string' },
+ },
+ required: ['callId', 'type'],
+ additionalProperties: true,
+ },
+ description: 'The list of signals that were already sent for the active calls.',
+ },
+ success: {
+ type: 'boolean',
+ description: 'Indicates the request was successful.',
+ },
+ },
+ required: ['signals', 'success'],
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ 404: validateNotFoundErrorResponse,
+ },
+ query: isMediaCallsStateSignalsProps,
+ authRequired: true,
+ },
+ async function action() {
+ const { contractId } = this.queryParams;
+ const signals = await MediaCall.getUserStateSignals(this.userId, contractId);
+
+ return API.v1.success({
+ signals,
+ });
+ },
+ );
+
+type MediaCallsStateEndpoints = ExtractRoutesFromAPI;
+
+declare module '@rocket.chat/rest-typings' {
+ // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
+ interface Endpoints extends MediaCallsStateEndpoints {}
+}
diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts
index 1b24c8cde425f..c93450d937cd0 100644
--- a/apps/meteor/app/api/server/v1/misc.ts
+++ b/apps/meteor/app/api/server/v1/misc.ts
@@ -9,9 +9,11 @@ import {
isDirectoryProps,
isFingerprintProps,
isMeteorCall,
+ meSuccessResponseSchema,
validateUnauthorizedErrorResponse,
validateBadRequestErrorResponse,
} from '@rocket.chat/rest-typings';
+import type { MeApiSuccessResponse } from '@rocket.chat/rest-typings';
import { escapeHTML } from '@rocket.chat/string-helpers';
import EJSON from 'ejson';
import { check } from 'meteor/check';
@@ -170,18 +172,13 @@ import { getUserInfo } from '../helpers/getUserInfo';
* schema:
* $ref: '#/components/schemas/ApiFailureV1'
*/
-const meResponseSchema = ajv.compile>({
- type: 'object',
- additionalProperties: true,
-});
-
API.v1.get(
'me',
{
authRequired: true,
userWithoutUsername: true,
response: {
- 200: meResponseSchema,
+ 200: ajv.compile(meSuccessResponseSchema),
401: validateUnauthorizedErrorResponse,
},
},
@@ -189,7 +186,7 @@ API.v1.get(
const userFields = { ...getBaseUserFields(), services: 1 };
const user = (await Users.findOneById(this.userId, { projection: userFields })) as IUser;
- return API.v1.success((await getUserInfo(user)) as unknown as Record);
+ return API.v1.success(await getUserInfo(user));
},
);
@@ -197,7 +194,12 @@ let onlineCache = 0;
let onlineCacheDate = 0;
const cacheInvalid = 60000; // 1 minute
-API.v1.addRoute(
+const shieldSvgResponseSchema = ajv.compile({
+ type: 'string',
+ description: 'SVG image markup',
+});
+
+API.v1.get(
'shield.svg',
{
authRequired: false,
@@ -205,98 +207,99 @@ API.v1.addRoute(
numRequestsAllowed: 60,
intervalTimeInMS: 60000,
},
- validateParams: isShieldSvgProps,
+ query: isShieldSvgProps,
+ response: {
+ 200: shieldSvgResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ },
},
- {
- async get() {
- const { type, icon } = this.queryParams;
- let { channel, name } = this.queryParams;
- if (!settings.get('API_Enable_Shields')) {
- throw new Meteor.Error('error-endpoint-disabled', 'This endpoint is disabled', {
- route: '/api/v1/shield.svg',
- });
- }
+ async function action() {
+ const { type, icon } = this.queryParams;
+ let { channel, name } = this.queryParams;
+ if (!settings.get('API_Enable_Shields')) {
+ throw new Meteor.Error('error-endpoint-disabled', 'This endpoint is disabled', {
+ route: '/api/v1/shield.svg',
+ });
+ }
- const types = settings.get('API_Shield_Types');
- if (
- type &&
- types !== '*' &&
- !types
- .split(',')
- .map((t: string) => t.trim())
- .includes(type)
- ) {
- throw new Meteor.Error('error-shield-disabled', 'This shield type is disabled', {
- route: '/api/v1/shield.svg',
- });
- }
- const hideIcon = icon === 'false';
- if (hideIcon && !name?.trim()) {
- return API.v1.failure('Name cannot be empty when icon is hidden');
- }
+ const types = settings.get('API_Shield_Types');
+ if (
+ type &&
+ types !== '*' &&
+ !types
+ .split(',')
+ .map((t: string) => t.trim())
+ .includes(type)
+ ) {
+ throw new Meteor.Error('error-shield-disabled', 'This shield type is disabled', {
+ route: '/api/v1/shield.svg',
+ });
+ }
+ const hideIcon = icon === 'false';
+ if (hideIcon && !name?.trim()) {
+ return API.v1.failure('Name cannot be empty when icon is hidden');
+ }
- let text;
- let backgroundColor = '#4c1';
- switch (type) {
- case 'online':
- if (Date.now() - onlineCacheDate > cacheInvalid) {
- onlineCache = await Users.countUsersNotOffline();
- onlineCacheDate = Date.now();
- }
-
- text = `${onlineCache} ${i18n.t('Online')}`;
- break;
- case 'channel':
- if (!channel) {
- return API.v1.failure('Shield channel is required for type "channel"');
- }
-
- text = `#${channel}`;
- break;
- case 'user':
- if (settings.get('API_Shield_user_require_auth') && !this.user) {
- return API.v1.failure('You must be logged in to do this.');
- }
- const user = await getUserFromParams(this.queryParams);
-
- // Respect the server's choice for using their real names or not
- if (user.name && settings.get('UI_Use_Real_Name')) {
- text = `${user.name}`;
- } else {
- text = `@${user.username}`;
- }
-
- switch (user.status) {
- case 'online':
- backgroundColor = '#1fb31f';
- break;
- case 'away':
- backgroundColor = '#dc9b01';
- break;
- case 'busy':
- backgroundColor = '#bc2031';
- break;
- case 'offline':
- backgroundColor = '#a5a1a1';
- }
- break;
- default:
- text = i18n.t('Join_Chat').toUpperCase();
- }
+ let text;
+ let backgroundColor = '#4c1';
+ switch (type) {
+ case 'online':
+ if (Date.now() - onlineCacheDate > cacheInvalid) {
+ onlineCache = await Users.countUsersNotOffline();
+ onlineCacheDate = Date.now();
+ }
+
+ text = `${onlineCache} ${i18n.t('Online')}`;
+ break;
+ case 'channel':
+ if (!channel) {
+ return API.v1.failure('Shield channel is required for type "channel"');
+ }
+
+ text = `#${channel}`;
+ break;
+ case 'user':
+ if (settings.get('API_Shield_user_require_auth') && !this.user) {
+ return API.v1.failure('You must be logged in to do this.');
+ }
+ const user = await getUserFromParams(this.queryParams);
+
+ // Respect the server's choice for using their real names or not
+ if (user.name && settings.get('UI_Use_Real_Name')) {
+ text = `${user.name}`;
+ } else {
+ text = `@${user.username}`;
+ }
+
+ switch (user.status) {
+ case 'online':
+ backgroundColor = '#1fb31f';
+ break;
+ case 'away':
+ backgroundColor = '#dc9b01';
+ break;
+ case 'busy':
+ backgroundColor = '#bc2031';
+ break;
+ case 'offline':
+ backgroundColor = '#a5a1a1';
+ }
+ break;
+ default:
+ text = i18n.t('Join_Chat').toUpperCase();
+ }
- const iconSize = hideIcon ? 7 : 24;
- const leftSize = name ? name.length * 6 + 7 + iconSize : iconSize;
- const rightSize = text.length * 6 + 20;
- const width = leftSize + rightSize;
- const height = 20;
+ const iconSize = hideIcon ? 7 : 24;
+ const leftSize = name ? name.length * 6 + 7 + iconSize : iconSize;
+ const rightSize = text.length * 6 + 20;
+ const width = leftSize + rightSize;
+ const height = 20;
- channel = escapeHTML(channel);
- text = escapeHTML(text);
- name = escapeHTML(name);
+ channel = escapeHTML(channel);
+ text = escapeHTML(text);
+ name = escapeHTML(name);
- return {
- headers: { 'Content-Type': 'image/svg+xml;charset=utf-8' },
- body: `
+ const svgBody = `
`
- .trim()
- .replace(/\>[\s]+\<'),
- } as any;
- },
+ .trim()
+ .replace(/\>[\s]+\<');
+
+ return {
+ statusCode: 200 as const,
+ body: svgBody,
+ headers: { 'Content-Type': 'image/svg+xml;charset=utf-8' },
+ };
},
);
@@ -360,7 +367,7 @@ const spotlightResponseSchema = ajv.compile<{
_id: { type: 'string' },
t: { type: 'string' },
name: { type: 'string' },
- lastMessage: { type: 'object' },
+ lastMessage: { $ref: '#/components/schemas/IMessage' },
},
required: ['_id', 't', 'name'],
additionalProperties: true,
diff --git a/apps/meteor/app/api/server/v1/moderation.ts b/apps/meteor/app/api/server/v1/moderation.ts
index 5651d9ea983df..ef4dde807d603 100644
--- a/apps/meteor/app/api/server/v1/moderation.ts
+++ b/apps/meteor/app/api/server/v1/moderation.ts
@@ -104,7 +104,21 @@ const reportedMessagesResponseSchema = ajv.compile<{
}>({
type: 'object',
properties: {
- user: { type: ['object', 'null'] },
+ user: {
+ oneOf: [
+ {
+ type: 'object',
+ properties: {
+ _id: { type: 'string' },
+ username: { type: 'string' },
+ name: { type: 'string' },
+ },
+ required: ['_id', 'username'],
+ additionalProperties: false,
+ },
+ { type: 'null' },
+ ],
+ },
messages: { type: 'array', items: { type: 'object' } },
count: { type: 'number' },
total: { type: 'number' },
@@ -122,7 +136,7 @@ const reportedMessagesResponseSchema = ajv.compile<{
// aggregation actually returns, or adjusting the AJV schema generation for union types), we use a
// relaxed inline schema here that accepts `ts` as a string.
const reportsByUserIdResponseSchema = ajv.compile<{
- user: IUser | null;
+ user: Pick | null;
reports: IModerationReport[];
count: number;
total: number;
@@ -130,7 +144,26 @@ const reportsByUserIdResponseSchema = ajv.compile<{
}>({
type: 'object',
properties: {
- user: { type: ['object', 'null'] },
+ user: {
+ oneOf: [
+ {
+ type: 'object',
+ properties: {
+ _id: { type: 'string' },
+ username: { type: 'string' },
+ name: { type: 'string' },
+ avatarETag: { type: 'string' },
+ active: { type: 'boolean' },
+ roles: { type: 'array', items: { type: 'string' } },
+ emails: { type: 'array', items: { type: 'object' } },
+ createdAt: { type: 'string' },
+ },
+ required: ['_id', 'username'],
+ additionalProperties: false,
+ },
+ { type: 'null' },
+ ],
+ },
reports: { type: 'array', items: { type: 'object' } },
count: { type: 'number' },
total: { type: 'number' },
diff --git a/apps/meteor/app/api/server/v1/push.ts b/apps/meteor/app/api/server/v1/push.ts
index 2c72d37d7e5b3..e16dc9bb37de4 100644
--- a/apps/meteor/app/api/server/v1/push.ts
+++ b/apps/meteor/app/api/server/v1/push.ts
@@ -1,8 +1,10 @@
import { Push } from '@rocket.chat/core-services';
-import type { IPushToken, IPushTokenTypes } from '@rocket.chat/core-typings';
+import { pushTokenTypes } from '@rocket.chat/core-typings';
+import type { IMessage, IPushNotificationConfig, IPushToken, IPushTokenTypes } from '@rocket.chat/core-typings';
import { Messages, PushToken, Users, Rooms, Settings } from '@rocket.chat/models';
import {
ajv,
+ isPushGetProps,
validateNotFoundErrorResponse,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
@@ -10,7 +12,6 @@ import {
} from '@rocket.chat/rest-typings';
import type { JSONSchemaType } from 'ajv';
import { Accounts } from 'meteor/accounts-base';
-import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { executePushTest } from '../../../../server/lib/pushConfig';
@@ -38,7 +39,7 @@ const PushTokenPOSTSchema: JSONSchemaType = {
},
type: {
type: 'string',
- enum: ['apn', 'gcm'],
+ enum: pushTokenTypes,
},
value: {
type: 'string',
@@ -148,6 +149,7 @@ const pushTokenEndpoints = API.v1
},
voipToken: {
type: 'string',
+ nullable: true,
},
},
additionalProperties: false,
@@ -222,25 +224,89 @@ const pushTokenEndpoints = API.v1
},
);
-API.v1.addRoute(
- 'push.get',
- { authRequired: true },
- {
- async get() {
- const params = this.queryParams;
- check(
- params,
- Match.ObjectIncluding({
- id: String,
- }),
- );
+const pushGetResponseSchema = ajv.compile<{
+ data: { message: IMessage; notification: IPushNotificationConfig };
+}>({
+ type: 'object',
+ properties: {
+ data: {
+ type: 'object',
+ properties: {
+ message: { type: 'object' },
+ notification: {
+ type: 'object',
+ properties: {
+ from: { type: 'string' },
+ title: { type: 'string' },
+ text: { type: 'string' },
+ userId: { type: 'string' },
+ badge: { type: 'number', nullable: true },
+ sound: { type: 'string', nullable: true },
+ priority: { type: 'number', nullable: true },
+ payload: { type: 'object', nullable: true },
+ notId: { type: 'number', nullable: true },
+ gcm: {
+ type: 'object',
+ properties: {
+ style: { type: 'string' },
+ image: { type: 'string' },
+ },
+ required: ['style', 'image'],
+ nullable: true,
+ },
+ apn: {
+ type: 'object',
+ properties: {
+ category: { type: 'string' },
+ topicSuffix: { type: 'string', nullable: true },
+ },
+ required: ['category'],
+ nullable: true,
+ },
+ },
+ required: ['from', 'title', 'text', 'userId'],
+ },
+ },
+ required: ['message', 'notification'],
+ },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['data', 'success'],
+ additionalProperties: false,
+});
+
+const pushInfoResponseSchema = ajv.compile<{ pushGatewayEnabled: boolean; defaultPushGateway: boolean }>({
+ type: 'object',
+ properties: {
+ pushGatewayEnabled: { type: 'boolean' },
+ defaultPushGateway: { type: 'boolean' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['pushGatewayEnabled', 'defaultPushGateway', 'success'],
+ additionalProperties: false,
+});
+
+const pushGetInfoEndpoints = API.v1
+ .get(
+ 'push.get',
+ {
+ authRequired: true,
+ query: isPushGetProps,
+ response: {
+ 200: pushGetResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const { id } = this.queryParams;
const receiver = await Users.findOneById(this.userId);
if (!receiver) {
throw new Error('error-user-not-found');
}
- const message = await Messages.findOneById(params.id);
+ const message = await Messages.findOneById(id);
if (!message) {
throw new Error('error-message-not-found');
}
@@ -258,23 +324,25 @@ API.v1.addRoute(
return API.v1.success({ data });
},
- },
-);
-
-API.v1.addRoute(
- 'push.info',
- { authRequired: true },
- {
- async get() {
+ )
+ .get(
+ 'push.info',
+ {
+ authRequired: true,
+ response: {
+ 200: pushInfoResponseSchema,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const defaultGateway = (await Settings.findOneById('Push_gateway', { projection: { packageValue: 1 } }))?.packageValue;
const defaultPushGateway = settings.get('Push_gateway') === defaultGateway;
return API.v1.success({
- pushGatewayEnabled: settings.get('Push_enable'),
+ pushGatewayEnabled: Boolean(settings.get('Push_enable')),
defaultPushGateway,
});
},
- },
-);
+ );
const pushTestEndpoints = API.v1.post(
'push.test',
@@ -320,7 +388,9 @@ type PushTestEndpoints = ExtractRoutesFromAPI;
type PushTokenEndpoints = ExtractRoutesFromAPI;
-type PushEndpoints = PushTestEndpoints & PushTokenEndpoints;
+type PushGetInfoEndpoints = ExtractRoutesFromAPI;
+
+type PushEndpoints = PushTestEndpoints & PushTokenEndpoints & PushGetInfoEndpoints;
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/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts
index ae02a578ab05c..8904d97dfbc93 100644
--- a/apps/meteor/app/api/server/v1/rooms.ts
+++ b/apps/meteor/app/api/server/v1/rooms.ts
@@ -1,5 +1,13 @@
import { FederationMatrix, MeteorError, Team } from '@rocket.chat/core-services';
-import { type IRoom, type IUpload, type RequiredField, isPrivateRoom, isPublicRoom, type IUser } from '@rocket.chat/core-typings';
+import {
+ type IRoom,
+ type IUpload,
+ type RequiredField,
+ type RoomAdminFieldsType,
+ isPrivateRoom,
+ isPublicRoom,
+ type IUser,
+} from '@rocket.chat/core-typings';
import { Messages, Rooms, Users, Uploads, Subscriptions } from '@rocket.chat/models';
import type { Notifications } from '@rocket.chat/rest-typings';
import {
@@ -19,9 +27,18 @@ import {
isRoomsChangeArchivationStateProps,
isRoomsHideProps,
isRoomsInviteProps,
+ isRoomsCreateDiscussionProps,
+ isRoomsAdminRoomsProps,
+ isRoomsAutocompleteAdminRoomsPayload,
+ isRoomsAdminRoomsGetRoomProps,
+ isRoomsAutoCompleteChannelAndPrivateProps,
+ isRoomsAutocompleteChannelAndPrivateWithPaginationProps,
+ isRoomsAutocompleteAvailableForTeamsProps,
+ isRoomsSaveRoomSettingsProps,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
+ validateNotFoundErrorResponse,
} from '@rocket.chat/rest-typings';
import { isTruthy } from '@rocket.chat/tools';
import { Meteor } from 'meteor/meteor';
@@ -110,20 +127,31 @@ export async function findRoomByIdOrName({
return room;
}
-API.v1.addRoute(
+API.v1.get(
'rooms.nameExists',
{
authRequired: true,
- validateParams: isGETRoomsNameExists,
+ query: isGETRoomsNameExists,
+ response: {
+ 200: ajv.compile<{ exists: boolean }>({
+ type: 'object',
+ properties: {
+ exists: { type: 'boolean' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['exists', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
},
- {
- async get() {
- const { roomName } = this.queryParams;
+ async function action() {
+ const { roomName } = this.queryParams;
- const room = await Rooms.findOneByName(roomName, { projection: { _id: 1 } });
+ const room = await Rooms.findOneByName(roomName, { projection: { _id: 1 } });
- return API.v1.success({ exists: !!room });
- },
+ return API.v1.success({ exists: !!room });
},
);
@@ -182,36 +210,50 @@ const roomDeleteEndpoint = API.v1.post(
},
);
-API.v1.addRoute(
+API.v1.get(
'rooms.get',
- { authRequired: true },
{
- async get() {
- const { updatedSince } = this.queryParams;
-
- let updatedSinceDate;
- if (updatedSince) {
- if (isNaN(Date.parse(updatedSince))) {
- throw new Meteor.Error('error-updatedSince-param-invalid', 'The "updatedSince" query parameter must be a valid date.');
- } else {
- updatedSinceDate = new Date(updatedSince);
- }
+ authRequired: true,
+ response: {
+ 200: ajv.compile<{ update: IRoom[]; remove: IRoom[] }>({
+ type: 'object',
+ properties: {
+ update: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom composed with lastMessage
+ remove: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom composed with lastMessage
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['update', 'remove', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const { updatedSince } = this.queryParams;
+
+ let updatedSinceDate;
+ if (updatedSince) {
+ if (isNaN(Date.parse(updatedSince))) {
+ throw new Meteor.Error('error-updatedSince-param-invalid', 'The "updatedSince" query parameter must be a valid date.');
+ } else {
+ updatedSinceDate = new Date(updatedSince);
}
+ }
- let result = await roomsGetMethod(this.userId, updatedSinceDate);
+ let result = await roomsGetMethod(this.userId, updatedSinceDate);
- if (Array.isArray(result)) {
- result = {
- update: result,
- remove: [],
- };
- }
+ if (Array.isArray(result)) {
+ result = {
+ update: result,
+ remove: [],
+ };
+ }
- return API.v1.success({
- update: await Promise.all(result.update.map((room) => composeRoomWithLastMessage(room, this.userId))),
- remove: await Promise.all(result.remove.map((room) => composeRoomWithLastMessage(room, this.userId))),
- });
- },
+ return API.v1.success({
+ update: await Promise.all(result.update.map((room) => composeRoomWithLastMessage(room, this.userId))),
+ remove: await Promise.all(result.remove.map((room) => composeRoomWithLastMessage(room, this.userId))),
+ });
},
);
@@ -372,599 +414,810 @@ const roomsSaveNotificationEndpoint = API.v1.post(
},
);
-API.v1.addRoute(
+API.v1.post(
'rooms.cleanHistory',
- { authRequired: true, validateParams: isRoomsCleanHistoryProps },
{
- async post() {
- const room = await findRoomByIdOrName({ params: this.bodyParams });
- const { _id } = room;
-
- if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) {
- return API.v1.failure('User does not have access to the room [error-not-allowed]', 'error-not-allowed');
- }
-
- const {
- latest,
- oldest,
- inclusive = false,
- limit,
- excludePinned,
- filesOnly,
- ignoreThreads,
- ignoreDiscussion,
- users,
- } = this.bodyParams;
-
- if (!latest) {
- return API.v1.failure('Body parameter "latest" is required.');
- }
-
- if (!oldest) {
- return API.v1.failure('Body parameter "oldest" is required.');
- }
+ authRequired: true,
+ body: isRoomsCleanHistoryProps,
+ response: {
+ 200: ajv.compile<{ _id: string; count: number }>({
+ type: 'object',
+ properties: {
+ _id: { type: 'string' },
+ count: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['_id', 'count', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const room = await findRoomByIdOrName({ params: this.bodyParams });
+ const { _id } = room;
- const count = await cleanRoomHistoryMethod(this.userId, {
- roomId: _id,
- latest: new Date(latest),
- oldest: new Date(oldest),
- inclusive,
- limit,
- excludePinned: [true, 'true', 1, '1'].includes(excludePinned ?? false),
- filesOnly: [true, 'true', 1, '1'].includes(filesOnly ?? false),
- ignoreThreads: [true, 'true', 1, '1'].includes(ignoreThreads ?? false),
- ignoreDiscussion: [true, 'true', 1, '1'].includes(ignoreDiscussion ?? false),
- fromUsers: users?.filter(isTruthy) || [],
- });
+ if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) {
+ return API.v1.failure('User does not have access to the room [error-not-allowed]', 'error-not-allowed');
+ }
- return API.v1.success({ _id, count });
- },
+ const { latest, oldest, inclusive = false, limit, excludePinned, filesOnly, ignoreThreads, ignoreDiscussion, users } = this.bodyParams;
+
+ const count = await cleanRoomHistoryMethod(this.userId, {
+ roomId: _id,
+ latest: new Date(latest),
+ oldest: new Date(oldest),
+ inclusive,
+ limit,
+ excludePinned: [true, 'true', 1, '1'].includes(excludePinned ?? false),
+ filesOnly: [true, 'true', 1, '1'].includes(filesOnly ?? false),
+ ignoreThreads: [true, 'true', 1, '1'].includes(ignoreThreads ?? false),
+ ignoreDiscussion: [true, 'true', 1, '1'].includes(ignoreDiscussion ?? false),
+ fromUsers: users?.filter(isTruthy) || [],
+ });
+
+ return API.v1.success({ _id, count });
},
);
-API.v1.addRoute(
+API.v1.get(
'rooms.info',
- { authRequired: true },
{
- async get() {
- const room = await findRoomByIdOrName({ params: this.queryParams });
- const { fields } = await this.parseJsonQuery();
-
- if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) {
- return API.v1.failure('not-allowed', 'Not Allowed');
- }
+ authRequired: true,
+ response: {
+ 200: ajv.compile<{ room: IRoom | null }>({
+ type: 'object',
+ properties: {
+ room: { type: ['object', 'null'] },
+ team: { type: 'object' },
+ parent: { type: 'object' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['room', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const room = await findRoomByIdOrName({ params: this.queryParams });
+ const { fields } = await this.parseJsonQuery();
- const discussionParent =
- room.prid &&
- (await Rooms.findOneById>(room.prid, {
- projection: { name: 1, fname: 1, t: 1, prid: 1, u: 1 },
- }));
- const { team, parentRoom } = await Team.getRoomInfo(room);
- const parent = discussionParent || parentRoom;
+ if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) {
+ return API.v1.failure('not-allowed', 'Not Allowed');
+ }
- return API.v1.success({
- room: await Rooms.findOneByIdOrName(room._id, { projection: fields }),
- ...(team && { team }),
- ...(parent && { parent }),
- });
- },
+ const discussionParent =
+ room.prid &&
+ (await Rooms.findOneById>(room.prid, {
+ projection: { name: 1, fname: 1, t: 1, prid: 1, u: 1 },
+ }));
+ const { team, parentRoom } = await Team.getRoomInfo(room);
+ const parent = discussionParent || parentRoom;
+
+ return API.v1.success({
+ room: await Rooms.findOneByIdOrName(room._id, { projection: fields }),
+ ...(team && { team }),
+ ...(parent && { parent }),
+ });
},
);
-/*
-TO-DO: 8.0.0 should use the ajv validation
-which will change this endpoint's
-response errors.
-*/
-API.v1.addRoute(
+API.v1.post(
'rooms.createDiscussion',
- { authRequired: true /* , validateParams: isRoomsCreateDiscussionProps */ },
{
- async post() {
- const { prid, pmid, reply, t_name, users, encrypted, topic } = this.bodyParams;
- if (!prid) {
- return API.v1.failure('Body parameter "prid" is required.');
- }
- if (!t_name) {
- return API.v1.failure('Body parameter "t_name" is required.');
- }
- if (users && !Array.isArray(users)) {
- return API.v1.failure('Body parameter "users" must be an array.');
- }
-
- if (encrypted !== undefined && typeof encrypted !== 'boolean') {
- return API.v1.failure('Body parameter "encrypted" must be a boolean when included.');
- }
-
- const discussion = await applyAirGappedRestrictionsValidation(() =>
- createDiscussion(this.userId, {
- prid,
- pmid,
- t_name,
- reply,
- users: users?.filter(isTruthy) || [],
- encrypted,
- topic,
- }),
- );
-
- return API.v1.success({ discussion });
+ authRequired: true,
+ body: isRoomsCreateDiscussionProps,
+ response: {
+ 200: ajv.compile<{ discussion: IRoom }>({
+ type: 'object',
+ properties: {
+ discussion: { type: 'object' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['discussion', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
},
},
+ async function action() {
+ const { prid, pmid, reply, t_name, users, encrypted, topic } = this.bodyParams;
+
+ const discussion = await applyAirGappedRestrictionsValidation(() =>
+ createDiscussion(this.userId, {
+ prid,
+ pmid,
+ t_name,
+ reply,
+ users: users?.filter(isTruthy) || [],
+ encrypted,
+ topic,
+ }),
+ );
+
+ return API.v1.success({ discussion });
+ },
);
-API.v1.addRoute(
+API.v1.get(
'rooms.getDiscussions',
- { authRequired: true },
{
- async get() {
- const room = await findRoomByIdOrName({ params: this.queryParams });
- const { offset, count } = await getPaginationItems(this.queryParams);
- const { sort, fields, query } = await this.parseJsonQuery();
+ authRequired: true,
+ response: {
+ 200: ajv.compile<{ discussions: IRoom[]; count: number; offset: number; total: number }>({
+ type: 'object',
+ properties: {
+ discussions: { type: 'array', items: { type: 'object' } }, // relaxed: discussions have extra room fields
+ count: { type: 'number' },
+ offset: { type: 'number' },
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['discussions', 'count', 'offset', 'total', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const room = await findRoomByIdOrName({ params: this.queryParams });
+ const { offset, count } = await getPaginationItems(this.queryParams);
+ const { sort, fields, query } = await this.parseJsonQuery();
- if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) {
- return API.v1.failure('not-allowed', 'Not Allowed');
- }
+ if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) {
+ return API.v1.failure('not-allowed', 'Not Allowed');
+ }
- const ourQuery = Object.assign(query, { prid: room._id });
+ const ourQuery = Object.assign(query, { prid: room._id });
- const { cursor, totalCount } = await Rooms.findPaginated(ourQuery, {
- sort: sort || { fname: 1 },
- skip: offset,
- limit: count,
- projection: fields,
- });
+ const { cursor, totalCount } = await Rooms.findPaginated(ourQuery, {
+ sort: sort || { fname: 1 },
+ skip: offset,
+ limit: count,
+ projection: fields,
+ });
- const [discussions, total] = await Promise.all([cursor.toArray(), totalCount]);
+ const [discussions, total] = await Promise.all([cursor.toArray(), totalCount]);
- return API.v1.success({
- discussions,
- count: discussions.length,
- offset,
- total,
- });
- },
+ return API.v1.success({
+ discussions,
+ count: discussions.length,
+ offset,
+ total,
+ });
},
);
-API.v1.addRoute(
+API.v1.get(
'rooms.images',
- { authRequired: true, validateParams: isRoomsImagesProps },
{
- async get() {
- const room = await Rooms.findOneById>(this.queryParams.roomId, {
- projection: { t: 1, teamId: 1, prid: 1 },
- });
+ authRequired: true,
+ query: isRoomsImagesProps,
+ response: {
+ 200: ajv.compile<{ files: IUpload[]; count: number; offset: number; total: number }>({
+ type: 'object',
+ properties: {
+ files: { type: 'array', items: { type: 'object' } }, // relaxed: IUpload with user transform
+ count: { type: 'number' },
+ offset: { type: 'number' },
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['files', 'count', 'offset', 'total', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
+ },
+ async function action() {
+ const room = await Rooms.findOneById>(this.queryParams.roomId, {
+ projection: { t: 1, teamId: 1, prid: 1 },
+ });
- if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) {
- return API.v1.forbidden();
- }
+ if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) {
+ return API.v1.forbidden();
+ }
- let initialImage: IUpload | null = null;
- if (this.queryParams.startingFromId) {
- initialImage = await Uploads.findOneById(this.queryParams.startingFromId);
+ let initialImage: IUpload | null = null;
+ if (this.queryParams.startingFromId) {
+ initialImage = await Uploads.findOneById(this.queryParams.startingFromId);
+ if (initialImage && initialImage.rid !== room._id) {
+ initialImage = null;
}
+ }
- const { offset, count } = await getPaginationItems(this.queryParams);
+ const { offset, count } = await getPaginationItems(this.queryParams);
- const { cursor, totalCount } = Uploads.findImagesByRoomId(room._id, initialImage?.uploadedAt, {
- skip: offset,
- limit: count,
- });
+ const { cursor, totalCount } = Uploads.findImagesByRoomId(room._id, initialImage?.uploadedAt, {
+ skip: offset,
+ limit: count,
+ });
- const [files, total] = await Promise.all([cursor.toArray(), totalCount]);
+ const [files, total] = await Promise.all([cursor.toArray(), totalCount]);
- // If the initial image was not returned in the query, insert it as the first element of the list
- if (initialImage && !files.find(({ _id }) => _id === initialImage._id)) {
- files.splice(0, 0, initialImage);
- }
+ // If the initial image was not returned in the query, insert it as the first element of the list
+ if (initialImage && !files.find(({ _id }) => _id === initialImage._id)) {
+ files.splice(0, 0, initialImage);
+ }
- return API.v1.success({
- files,
- count,
- offset,
- total,
- });
- },
+ return API.v1.success({
+ files,
+ count,
+ offset,
+ total,
+ });
},
);
-API.v1.addRoute(
+API.v1.get(
'rooms.adminRooms',
- { authRequired: true },
{
- async get() {
- const { offset, count } = await getPaginationItems(this.queryParams);
- const { sort } = await this.parseJsonQuery();
- const { types, filter } = this.queryParams;
-
- return API.v1.success(
- await findAdminRooms({
- uid: this.userId,
- filter: filter || '',
- types: (types && !Array.isArray(types) ? [types] : types) ?? [],
- pagination: {
- offset,
- count,
- sort,
- },
- }),
- );
+ authRequired: true,
+ query: isRoomsAdminRoomsProps,
+ response: {
+ 200: ajv.compile<{ rooms: IRoom[]; count: number; offset: number; total: number }>({
+ type: 'object',
+ properties: {
+ rooms: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom with admin fields
+ count: { type: 'number' },
+ offset: { type: 'number' },
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['rooms', 'count', 'offset', 'total', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
},
},
+ async function action() {
+ const { offset, count } = await getPaginationItems(this.queryParams);
+ const { sort } = await this.parseJsonQuery();
+ const { types, filter } = this.queryParams;
+
+ return API.v1.success(
+ await findAdminRooms({
+ uid: this.userId,
+ filter: filter || '',
+ types: types ?? [],
+ pagination: {
+ offset,
+ count,
+ sort,
+ },
+ }),
+ );
+ },
);
-API.v1.addRoute(
+API.v1.get(
'rooms.autocomplete.adminRooms',
- { authRequired: true },
{
- async get() {
- const { selector } = this.queryParams;
- if (!selector) {
- return API.v1.failure("The 'selector' param is required");
- }
-
- return API.v1.success(
- await findAdminRoomsAutocomplete({
- uid: this.userId,
- selector: JSON.parse(selector),
- }),
- );
+ authRequired: true,
+ query: isRoomsAutocompleteAdminRoomsPayload,
+ response: {
+ 200: ajv.compile<{ items: IRoom[] }>({
+ type: 'object',
+ properties: {
+ items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['items', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
},
},
+ async function action() {
+ const { selector } = this.queryParams;
+
+ return API.v1.success(
+ await findAdminRoomsAutocomplete({
+ uid: this.userId,
+ selector: JSON.parse(selector),
+ }),
+ );
+ },
);
-API.v1.addRoute(
+API.v1.get(
'rooms.adminRooms.getRoom',
- { authRequired: true },
{
- async get() {
- const { rid } = this.queryParams;
- const room = await findAdminRoom({
- uid: this.userId,
- rid: rid || '',
- });
-
- if (!room) {
- return API.v1.failure('not-allowed', 'Not Allowed');
- }
- return API.v1.success(room);
+ authRequired: true,
+ query: isRoomsAdminRoomsGetRoomProps,
+ response: {
+ 200: ajv.compile>({
+ allOf: [
+ { $ref: '#/components/schemas/IRoomAdmin' },
+ { type: 'object', properties: { success: { type: 'boolean', enum: [true] } }, required: ['success'] },
+ ],
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
},
},
+ async function action() {
+ const { rid } = this.queryParams;
+ const room = await findAdminRoom({
+ uid: this.userId,
+ rid: rid || '',
+ });
+
+ if (!room) {
+ return API.v1.failure('not-allowed', 'Not Allowed');
+ }
+ return API.v1.success(room);
+ },
);
-API.v1.addRoute(
+API.v1.get(
'rooms.autocomplete.channelAndPrivate',
- { authRequired: true },
{
- async get() {
- const { selector } = this.queryParams;
- if (!selector) {
- return API.v1.failure("The 'selector' param is required");
- }
-
- return API.v1.success(
- await findChannelAndPrivateAutocomplete({
- uid: this.userId,
- selector: JSON.parse(selector),
- }),
- );
+ authRequired: true,
+ query: isRoomsAutoCompleteChannelAndPrivateProps,
+ response: {
+ 200: ajv.compile<{ items: IRoom[] }>({
+ type: 'object',
+ properties: {
+ items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['items', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
},
},
+ async function action() {
+ const { selector } = this.queryParams;
+
+ return API.v1.success(
+ await findChannelAndPrivateAutocomplete({
+ uid: this.userId,
+ selector: JSON.parse(selector),
+ }),
+ );
+ },
);
-API.v1.addRoute(
+API.v1.get(
'rooms.autocomplete.channelAndPrivate.withPagination',
- { authRequired: true },
{
- async get() {
- const { selector } = this.queryParams;
- const { offset, count } = await getPaginationItems(this.queryParams);
- const { sort } = await this.parseJsonQuery();
-
- if (!selector) {
- return API.v1.failure("The 'selector' param is required");
- }
-
- return API.v1.success(
- await findChannelAndPrivateAutocompleteWithPagination({
- uid: this.userId,
- selector: JSON.parse(selector),
- pagination: {
- offset,
- count,
- sort,
- },
- }),
- );
+ authRequired: true,
+ query: isRoomsAutocompleteChannelAndPrivateWithPaginationProps,
+ response: {
+ 200: ajv.compile<{ items: IRoom[]; total: number }>({
+ type: 'object',
+ properties: {
+ items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['items', 'total', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
},
},
+ async function action() {
+ const { selector } = this.queryParams;
+ const { offset, count } = await getPaginationItems(this.queryParams);
+ const { sort } = await this.parseJsonQuery();
+
+ return API.v1.success(
+ await findChannelAndPrivateAutocompleteWithPagination({
+ uid: this.userId,
+ selector: JSON.parse(selector),
+ pagination: {
+ offset,
+ count,
+ sort,
+ },
+ }),
+ );
+ },
);
-API.v1.addRoute(
+API.v1.get(
'rooms.autocomplete.availableForTeams',
- { authRequired: true },
{
- async get() {
- const { name } = this.queryParams;
-
- if (name && typeof name !== 'string') {
- return API.v1.failure("The 'name' param is invalid");
- }
-
- return API.v1.success(
- await findRoomsAvailableForTeams({
- uid: this.userId,
- name: name || '',
- }),
- );
+ authRequired: true,
+ query: isRoomsAutocompleteAvailableForTeamsProps,
+ response: {
+ 200: ajv.compile<{ items: IRoom[] }>({
+ type: 'object',
+ properties: {
+ items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['items', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
},
},
+ async function action() {
+ const { name } = this.queryParams;
+
+ return API.v1.success(
+ await findRoomsAvailableForTeams({
+ uid: this.userId,
+ name,
+ }),
+ );
+ },
);
-API.v1.addRoute(
+API.v1.post(
'rooms.saveRoomSettings',
- { authRequired: true },
{
- async post() {
- const { rid, ...params } = this.bodyParams;
+ authRequired: true,
+ body: isRoomsSaveRoomSettingsProps,
+ response: {
+ 200: ajv.compile<{ rid: string }>({
+ type: 'object',
+ properties: {
+ rid: { type: 'string' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['rid', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const { rid, ...params } = this.bodyParams;
- const result = await saveRoomSettings(this.userId, rid, params);
+ const result = await saveRoomSettings(this.userId, rid, params);
- return API.v1.success({ rid: result.rid });
- },
+ return API.v1.success({ rid: result.rid });
},
);
-API.v1.addRoute(
+const successResponseSchema = ajv.compile({
+ type: 'object',
+ properties: { success: { type: 'boolean', enum: [true] } },
+ required: ['success'],
+ additionalProperties: false,
+});
+
+API.v1.post(
'rooms.changeArchivationState',
- { authRequired: true, validateParams: isRoomsChangeArchivationStateProps },
{
- async post() {
- const { rid, action } = this.bodyParams;
+ authRequired: true,
+ body: isRoomsChangeArchivationStateProps,
+ response: {
+ 200: successResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const { rid, action } = this.bodyParams;
- let result;
- if (action === 'archive') {
- result = await executeArchiveRoom(this.userId, rid);
- } else {
- result = await executeUnarchiveRoom(this.userId, rid);
- }
+ if (action === 'archive') {
+ await executeArchiveRoom(this.userId, rid);
+ } else {
+ await executeUnarchiveRoom(this.userId, rid);
+ }
- return API.v1.success({ result });
- },
+ return API.v1.success();
},
);
-API.v1.addRoute(
+API.v1.post(
'rooms.export',
- { authRequired: true, validateParams: isRoomsExportProps },
{
- async post() {
- const { rid, type } = this.bodyParams;
-
- if (!(await hasPermissionAsync(this.userId, 'mail-messages', rid))) {
- throw new Meteor.Error('error-action-not-allowed', 'Mailing is not allowed');
- }
+ authRequired: true,
+ body: isRoomsExportProps,
+ response: {
+ 200: ajv.compile({
+ type: 'object',
+ properties: {
+ success: { type: 'boolean', enum: [true] },
+ missing: { type: 'array', items: { type: 'string' } },
+ },
+ required: ['success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const { rid, type } = this.bodyParams;
- const room = await Rooms.findOneById(rid);
- if (!room) {
- throw new Meteor.Error('error-invalid-room');
- }
+ if (!(await hasPermissionAsync(this.userId, 'mail-messages', rid))) {
+ throw new Meteor.Error('error-action-not-allowed', 'Mailing is not allowed');
+ }
- const user = await Users.findOneById(this.userId);
+ const room = await Rooms.findOneById(rid);
+ if (!room) {
+ throw new Meteor.Error('error-invalid-room');
+ }
- if (!user || !(await canAccessRoomAsync(room, user))) {
- throw new Meteor.Error('error-not-allowed', 'Not Allowed');
- }
+ const user = await Users.findOneById(this.userId);
- if (type === 'file') {
- const { dateFrom, dateTo } = this.bodyParams;
- const { format } = this.bodyParams;
+ if (!user || !(await canAccessRoomAsync(room, user))) {
+ throw new Meteor.Error('error-not-allowed', 'Not Allowed');
+ }
- const convertedDateFrom = dateFrom ? new Date(dateFrom) : new Date(0);
- const convertedDateTo = dateTo ? new Date(dateTo) : new Date();
- convertedDateTo.setDate(convertedDateTo.getDate() + 1);
+ if (type === 'file') {
+ const { dateFrom, dateTo } = this.bodyParams;
+ const { format } = this.bodyParams;
- if (convertedDateFrom > convertedDateTo) {
- throw new Meteor.Error('error-invalid-dates', 'From date cannot be after To date');
- }
+ const convertedDateFrom = dateFrom ? new Date(dateFrom) : new Date(0);
+ const convertedDateTo = dateTo ? new Date(dateTo) : new Date();
+ convertedDateTo.setDate(convertedDateTo.getDate() + 1);
- void dataExport.sendFile(
- {
- rid,
- format,
- dateFrom: convertedDateFrom,
- dateTo: convertedDateTo,
- },
- user,
- );
- return API.v1.success();
+ if (convertedDateFrom > convertedDateTo) {
+ throw new Meteor.Error('error-invalid-dates', 'From date cannot be after To date');
}
- if (type === 'email') {
- const { toUsers, toEmails, subject, messages } = this.bodyParams;
+ void dataExport.sendFile(
+ {
+ rid,
+ format,
+ dateFrom: convertedDateFrom,
+ dateTo: convertedDateTo,
+ },
+ user,
+ );
+ return API.v1.success();
+ }
- if ((!toUsers || toUsers.length === 0) && (!toEmails || toEmails.length === 0)) {
- throw new Meteor.Error('error-invalid-recipient');
- }
+ if (type === 'email') {
+ const { toUsers, toEmails, subject, messages } = this.bodyParams;
- const result = await dataExport.sendViaEmail(
- {
- rid,
- toUsers: (toUsers as string[]) || [],
- toEmails: toEmails || [],
- subject: subject || '',
- messages: messages || [],
- language: user.language || 'en',
- },
- user,
- );
-
- return API.v1.success(result);
+ if ((!toUsers || toUsers.length === 0) && (!toEmails || toEmails.length === 0)) {
+ throw new Meteor.Error('error-invalid-recipient');
}
- return API.v1.failure();
- },
+ const result = await dataExport.sendViaEmail(
+ {
+ rid,
+ toUsers: (toUsers as string[]) || [],
+ toEmails: toEmails || [],
+ subject: subject || '',
+ messages: messages || [],
+ language: user.language || 'en',
+ },
+ user,
+ );
+
+ return API.v1.success(result);
+ }
+
+ return API.v1.failure();
},
);
-API.v1.addRoute(
+API.v1.get(
'rooms.isMember',
{
authRequired: true,
- validateParams: isRoomsIsMemberProps,
+ query: isRoomsIsMemberProps,
+ response: {
+ 200: ajv.compile<{ isMember: boolean }>({
+ type: 'object',
+ properties: {
+ isMember: { type: 'boolean' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['isMember', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
},
- {
- async get() {
- const { roomId, userId, username } = this.queryParams;
- const [room, user] = await Promise.all([
- findRoomByIdOrName({
- params: { roomId },
- }),
- Users.findOneByIdOrUsername(userId || username),
- ]);
+ async function action() {
+ const { roomId } = this.queryParams;
+ const usernameOrUserId = 'userId' in this.queryParams ? this.queryParams.userId : this.queryParams.username;
+ const [room, user] = await Promise.all([
+ findRoomByIdOrName({
+ params: { roomId },
+ }),
+ Users.findOneByIdOrUsername(usernameOrUserId),
+ ]);
- if (!user?._id) {
- return API.v1.failure('error-user-not-found');
- }
+ if (!user?._id) {
+ return API.v1.failure('error-user-not-found');
+ }
- if (await canAccessRoomAsync(room, { _id: this.user._id })) {
- return API.v1.success({
- isMember: (await Subscriptions.countByRoomIdAndUserId(room._id, user._id)) > 0,
- });
- }
- return API.v1.forbidden();
- },
+ if (await canAccessRoomAsync(room, { _id: this.user._id })) {
+ return API.v1.success({
+ isMember: (await Subscriptions.countByRoomIdAndUserId(room._id, user._id)) > 0,
+ });
+ }
+ return API.v1.forbidden();
},
);
-API.v1.addRoute(
+API.v1.get(
'rooms.membersOrderedByRole',
- { authRequired: true, validateParams: isRoomsMembersOrderedByRoleProps },
{
- async get() {
- const findResult = await findRoomByIdOrName({
- params: this.queryParams,
- checkedArchived: false,
- });
-
- if (!(await canAccessRoomAsync(findResult, this.user))) {
- return API.v1.notFound('The required "roomId" or "roomName" param provided does not match any room');
- }
-
- if (!isPublicRoom(findResult) && !isPrivateRoom(findResult)) {
- return API.v1.failure('error-room-type-not-supported');
- }
-
- if (findResult.broadcast && !(await hasPermissionAsync(this.userId, 'view-broadcast-member-list', findResult._id))) {
- return API.v1.unauthorized();
- }
-
- // Ensures that role priorities for the specified room are synchronized correctly.
- // This function acts as a soft migration. If the `roomRolePriorities` field
- // for the room has already been created and is up-to-date, no updates will be performed.
- // If not, it will synchronize the role priorities of the users of the room.
- await syncRolePrioritiesForRoomIfRequired(findResult._id);
+ authRequired: true,
+ query: isRoomsMembersOrderedByRoleProps,
+ response: {
+ 200: ajv.compile<{ members: IUser[]; count: number; offset: number; total: number }>({
+ type: 'object',
+ properties: {
+ members: { type: 'array', items: { type: 'object' } }, // relaxed: projected IUser with role priority
+ count: { type: 'number' },
+ offset: { type: 'number' },
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['members', 'count', 'offset', 'total', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 404: validateNotFoundErrorResponse,
+ },
+ },
+ async function action() {
+ const findResult = await findRoomByIdOrName({
+ params: this.queryParams,
+ checkedArchived: false,
+ });
- const { offset: skip, count: limit } = await getPaginationItems(this.queryParams);
- const { sort = {} } = await this.parseJsonQuery();
+ if (!(await canAccessRoomAsync(findResult, this.user))) {
+ return API.v1.notFound('The required "roomId" or "roomName" param provided does not match any room');
+ }
- const { status, filter } = this.queryParams;
+ if (!isPublicRoom(findResult) && !isPrivateRoom(findResult)) {
+ return API.v1.failure('error-room-type-not-supported');
+ }
- const { members, total } = await findUsersOfRoomOrderedByRole({
- rid: findResult._id,
- ...(status && { status: { $in: status } }),
- skip,
- limit,
- filter,
- sort,
- });
+ if (findResult.broadcast && !(await hasPermissionAsync(this.userId, 'view-broadcast-member-list', findResult._id))) {
+ return API.v1.unauthorized();
+ }
- return API.v1.success({
- members,
- count: members.length,
- offset: skip,
- total,
- });
- },
+ // Ensures that role priorities for the specified room are synchronized correctly.
+ // This function acts as a soft migration. If the `roomRolePriorities` field
+ // for the room has already been created and is up-to-date, no updates will be performed.
+ // If not, it will synchronize the role priorities of the users of the room.
+ await syncRolePrioritiesForRoomIfRequired(findResult._id);
+
+ const { offset: skip, count: limit } = await getPaginationItems(this.queryParams);
+ const { sort = {} } = await this.parseJsonQuery();
+
+ const { status, filter } = this.queryParams;
+
+ const { members, total } = await findUsersOfRoomOrderedByRole({
+ rid: findResult._id,
+ ...(status && { status: { $in: status } }),
+ skip,
+ limit,
+ filter,
+ sort,
+ });
+
+ return API.v1.success({
+ members,
+ count: members.length,
+ offset: skip,
+ total,
+ });
},
);
-API.v1.addRoute(
+API.v1.post(
'rooms.muteUser',
- { authRequired: true, validateParams: isRoomsMuteUnmuteUserProps },
{
- async post() {
- const user = await getUserFromParams(this.bodyParams);
+ authRequired: true,
+ body: isRoomsMuteUnmuteUserProps,
+ response: {
+ 200: successResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const user = await getUserFromParams(this.bodyParams);
- if (!user.username) {
- return API.v1.failure('Invalid user');
- }
+ if (!user.username) {
+ return API.v1.failure('Invalid user');
+ }
- await muteUserInRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username });
+ await muteUserInRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username });
- return API.v1.success();
- },
+ return API.v1.success();
},
);
-API.v1.addRoute(
+API.v1.post(
'rooms.unmuteUser',
- { authRequired: true, validateParams: isRoomsMuteUnmuteUserProps },
{
- async post() {
- const user = await getUserFromParams(this.bodyParams);
+ authRequired: true,
+ body: isRoomsMuteUnmuteUserProps,
+ response: {
+ 200: successResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const user = await getUserFromParams(this.bodyParams);
- if (!user.username) {
- return API.v1.failure('Invalid user');
- }
+ if (!user.username) {
+ return API.v1.failure('Invalid user');
+ }
- await unmuteUserInRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username });
+ await unmuteUserInRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username });
- return API.v1.success();
- },
+ return API.v1.success();
},
);
-API.v1.addRoute(
+API.v1.post(
'rooms.open',
- { authRequired: true, validateParams: isRoomsOpenProps },
{
- async post() {
- const { roomId } = this.bodyParams;
+ authRequired: true,
+ body: isRoomsOpenProps,
+ response: {
+ 200: successResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const { roomId } = this.bodyParams;
- await openRoom(this.userId, roomId);
+ await openRoom(this.userId, roomId);
- return API.v1.success();
- },
+ return API.v1.success();
},
);
-API.v1.addRoute(
+API.v1.post(
'rooms.hide',
- { authRequired: true, validateParams: isRoomsHideProps },
{
- async post() {
- const { roomId } = this.bodyParams;
+ authRequired: true,
+ body: isRoomsHideProps,
+ response: {
+ 200: successResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const { roomId } = this.bodyParams;
- if (!(await canAccessRoomIdAsync(roomId, this.userId))) {
- return API.v1.unauthorized();
- }
+ if (!(await canAccessRoomIdAsync(roomId, this.userId))) {
+ return API.v1.unauthorized();
+ }
- const user = await Users.findOneById(this.userId, { projections: { _id: 1 } });
+ const user = await Users.findOneById(this.userId, { projections: { _id: 1 } });
- if (!user) {
- return API.v1.failure('error-invalid-user');
- }
+ if (!user) {
+ return API.v1.failure('error-invalid-user');
+ }
- const modCount = await hideRoomMethod(this.userId, roomId);
+ const modCount = await hideRoomMethod(this.userId, roomId);
- if (!modCount) {
- return API.v1.failure('error-room-already-hidden');
- }
+ if (!modCount) {
+ return API.v1.failure('error-room-already-hidden');
+ }
- return API.v1.success();
- },
+ return API.v1.success();
},
);
@@ -1153,7 +1406,7 @@ export const roomEndpoints = API.v1
properties: {
rooms: {
type: 'array',
- items: { type: 'object' },
+ items: { type: 'object' }, // relaxed: IRoom subset
},
count: { type: 'number' },
offset: { type: 'number' },
diff --git a/apps/meteor/app/api/server/v1/settings.ts b/apps/meteor/app/api/server/v1/settings.ts
index 5300db2c79c80..97e4a51d3133c 100644
--- a/apps/meteor/app/api/server/v1/settings.ts
+++ b/apps/meteor/app/api/server/v1/settings.ts
@@ -6,7 +6,7 @@ import type {
TwitterOAuthConfiguration,
OAuthConfiguration,
} from '@rocket.chat/core-typings';
-import { isSettingAction, isSettingColor } from '@rocket.chat/core-typings';
+import { isActionSettingWithEndpoint, isSettingAction, isSettingColor } from '@rocket.chat/core-typings';
import { LoginServiceConfiguration as LoginServiceConfigurationModel, Settings } from '@rocket.chat/models';
import {
ajv,
@@ -348,7 +348,12 @@ API.v1.post(
const { bodyParams } = this;
- if (isSettingAction(setting) && isSettingsUpdatePropsActions(bodyParams) && bodyParams.execute) {
+ if (
+ isSettingAction(setting) &&
+ isSettingsUpdatePropsActions(bodyParams) &&
+ bodyParams.execute &&
+ !isActionSettingWithEndpoint(setting.value)
+ ) {
await Meteor.callAsync(setting.value);
return API.v1.success();
}
diff --git a/apps/meteor/app/api/server/v1/stats.ts b/apps/meteor/app/api/server/v1/stats.ts
index 27cea2c310574..ba5500457c1d8 100644
--- a/apps/meteor/app/api/server/v1/stats.ts
+++ b/apps/meteor/app/api/server/v1/stats.ts
@@ -1,62 +1,120 @@
+import type { IStats } from '@rocket.chat/core-typings';
+import {
+ ajv,
+ isTelemetryPayload,
+ validateUnauthorizedErrorResponse,
+ validateForbiddenErrorResponse,
+ validateBadRequestErrorResponse,
+} from '@rocket.chat/rest-typings';
+
import { getStatistics, getLastStatistics } from '../../../statistics/server';
import telemetryEvent from '../../../statistics/server/lib/telemetryEvents';
import { API } from '../api';
import { getPaginationItems } from '../helpers/getPaginationItems';
-API.v1.addRoute(
+const statisticsResponseSchema = ajv.compile({
+ type: 'object',
+ properties: {
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['success'],
+ additionalProperties: true,
+});
+
+const statisticsListResponseSchema = ajv.compile<{ statistics: IStats[]; count: number; offset: number; total: number }>({
+ type: 'object',
+ properties: {
+ statistics: { type: 'array' },
+ count: { type: 'number' },
+ offset: { type: 'number' },
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['statistics', 'count', 'offset', 'total', 'success'],
+ additionalProperties: false,
+});
+
+const statisticsTelemetryResponseSchema = ajv.compile({
+ type: 'object',
+ properties: {
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['success'],
+ additionalProperties: false,
+});
+
+API.v1.get(
'statistics',
- { authRequired: true },
{
- async get() {
- const { refresh = 'false' } = this.queryParams;
-
- return API.v1.success(
- await getLastStatistics({
- userId: this.userId,
- refresh: refresh === 'true',
- }),
- );
+ authRequired: true,
+ response: {
+ 200: statisticsResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
},
},
+ async function action() {
+ const { refresh } = this.queryParams;
+
+ return API.v1.success(
+ await getLastStatistics({
+ userId: this.userId,
+ refresh: refresh === 'true',
+ }),
+ );
+ },
);
-API.v1.addRoute(
+API.v1.get(
'statistics.list',
- { authRequired: true },
{
- async get() {
- const { offset, count } = await getPaginationItems(this.queryParams);
- const { sort, fields, query } = await this.parseJsonQuery();
-
- return API.v1.success(
- await getStatistics({
- userId: this.userId,
- query,
- pagination: {
- offset,
- count,
- sort,
- fields,
- },
- }),
- );
+ authRequired: true,
+ response: {
+ 200: statisticsListResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
},
},
+ async function action() {
+ const { offset, count } = await getPaginationItems(this.queryParams);
+ const { sort, fields, query } = await this.parseJsonQuery();
+
+ return API.v1.success(
+ await getStatistics({
+ userId: this.userId,
+ query,
+ pagination: {
+ offset,
+ count,
+ sort,
+ fields,
+ },
+ }),
+ );
+ },
);
-API.v1.addRoute(
+API.v1.post(
'statistics.telemetry',
- { authRequired: true },
{
- post() {
- const events = this.bodyParams;
+ authRequired: true,
+ body: isTelemetryPayload,
+ response: {
+ 200: statisticsTelemetryResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ function action() {
+ const { params } = this.bodyParams;
- events?.params?.forEach((event) => {
- const { eventName, ...params } = event;
- void telemetryEvent.call(eventName, params);
- });
+ params.forEach((event) => {
+ const { eventName, ...rest } = event;
+ void telemetryEvent.call(eventName, rest);
+ });
- return API.v1.success();
- },
+ return API.v1.success();
},
);
diff --git a/apps/meteor/app/api/server/v1/subscriptions.ts b/apps/meteor/app/api/server/v1/subscriptions.ts
index 375107c016cd6..9b69aa5478d1c 100644
--- a/apps/meteor/app/api/server/v1/subscriptions.ts
+++ b/apps/meteor/app/api/server/v1/subscriptions.ts
@@ -1,3 +1,4 @@
+import type { ISubscription } from '@rocket.chat/core-typings';
import { Rooms, Subscriptions } from '@rocket.chat/models';
import {
ajv,
@@ -15,9 +16,27 @@ import { getSubscriptions } from '../../../../server/publications/subscription';
import { unreadMessages } from '../../../message-mark-as-unread/server/unreadMessages';
import { API } from '../api';
-const successResponseSchema = ajv.compile({
+const subscriptionsGetResponseSchema = ajv.compile<{
+ update: ISubscription[];
+ remove: (Pick & { _deletedAt: Date })[];
+}>({
type: 'object',
- properties: { success: { type: 'boolean', enum: [true] } },
+ properties: {
+ update: { type: 'array', items: { $ref: '#/components/schemas/ISubscription' } },
+ remove: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ _id: { type: 'string' },
+ _deletedAt: { type: 'string', format: 'date-time' },
+ },
+ required: ['_id', '_deletedAt'],
+ additionalProperties: false,
+ },
+ },
+ success: { type: 'boolean', enum: [true] },
+ },
required: ['success'],
additionalProperties: true,
});
@@ -28,16 +47,7 @@ API.v1.get(
authRequired: true,
query: isSubscriptionsGetProps,
response: {
- 200: ajv.compile({
- type: 'object',
- properties: {
- update: { type: 'array', items: { type: 'object' } },
- remove: { type: 'array', items: { type: 'object' } },
- success: { type: 'boolean', enum: [true] },
- },
- required: ['success'],
- additionalProperties: true,
- }),
+ 200: subscriptionsGetResponseSchema,
401: validateUnauthorizedErrorResponse,
},
},
@@ -46,11 +56,10 @@ API.v1.get(
let updatedSinceDate: Date | undefined;
if (updatedSince) {
- const updatedSinceStr = String(updatedSince);
- if (isNaN(Date.parse(updatedSinceStr))) {
+ if (isNaN(Date.parse(updatedSince))) {
throw new Meteor.Error('error-roomId-param-invalid', 'The "lastUpdate" query parameter must be a valid date.');
}
- updatedSinceDate = new Date(updatedSinceStr);
+ updatedSinceDate = new Date(updatedSince);
}
const result = await getSubscriptions(this.userId, updatedSinceDate);
@@ -66,21 +75,23 @@ API.v1.get(
},
);
+const subscriptionsGetOneResponseSchema = ajv.compile<{ subscription: ISubscription | null }>({
+ type: 'object',
+ properties: {
+ subscription: { oneOf: [{ $ref: '#/components/schemas/ISubscription' }, { type: 'null' }] },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['subscription', 'success'],
+ additionalProperties: false,
+});
+
API.v1.get(
'subscriptions.getOne',
{
authRequired: true,
query: isSubscriptionsGetOneProps,
response: {
- 200: ajv.compile({
- type: 'object',
- properties: {
- subscription: { type: 'object', nullable: true },
- success: { type: 'boolean', enum: [true] },
- },
- required: ['subscription', 'success'],
- additionalProperties: false,
- }),
+ 200: subscriptionsGetOneResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
@@ -107,13 +118,22 @@ API.v1.get(
- rid: The rid of the room to be marked as read.
- roomId: Alternative for rid.
*/
+const voidSuccessResponseSchema = ajv.compile({
+ type: 'object',
+ properties: {
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['success'],
+ additionalProperties: false,
+});
+
API.v1.post(
'subscriptions.read',
{
authRequired: true,
body: isSubscriptionsReadProps,
response: {
- 200: successResponseSchema,
+ 200: voidSuccessResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
@@ -129,7 +149,7 @@ API.v1.post(
await readMessages(room, this.userId, readThreads);
- return API.v1.success({});
+ return API.v1.success();
},
);
@@ -139,7 +159,7 @@ API.v1.post(
authRequired: true,
body: isSubscriptionsUnreadProps,
response: {
- 200: successResponseSchema,
+ 200: voidSuccessResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
@@ -151,6 +171,6 @@ API.v1.post(
'roomId' in this.bodyParams ? this.bodyParams.roomId : undefined,
);
- return API.v1.success({});
+ return API.v1.success();
},
);
diff --git a/apps/meteor/app/api/server/v1/teams.ts b/apps/meteor/app/api/server/v1/teams.ts
index 0281d0f7a8080..fe3de29cf22a4 100644
--- a/apps/meteor/app/api/server/v1/teams.ts
+++ b/apps/meteor/app/api/server/v1/teams.ts
@@ -1,35 +1,75 @@
import { Team } from '@rocket.chat/core-services';
-import type { ITeam, UserStatus } from '@rocket.chat/core-typings';
-import { TeamType } from '@rocket.chat/core-typings';
+import type { ITeamAutocompleteResult } from '@rocket.chat/core-services';
+import type { ITeam } from '@rocket.chat/core-typings';
import { Users, Rooms } from '@rocket.chat/models';
import {
+ ajv,
+ isTeamsAddRoomsProps,
+ isTeamsAutocompleteProps,
isTeamsConvertToChannelProps,
+ isTeamsCreateProps,
+ isTeamsInfoProps,
+ isTeamsListRoomsProps,
+ isTeamsListRoomsOfUserProps,
+ isTeamsMembersProps,
isTeamsRemoveRoomProps,
isTeamsUpdateMemberProps,
+ isTeamsUpdateRoomProps,
isTeamsRemoveMemberProps,
isTeamsAddMembersProps,
isTeamsDeleteProps,
isTeamsLeaveProps,
isTeamsUpdateProps,
isTeamsListChildrenProps,
+ validateBadRequestErrorResponse,
+ validateUnauthorizedErrorResponse,
+ validateForbiddenErrorResponse,
+ validateNotFoundErrorResponse,
} from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/string-helpers';
-import { Match, check } from 'meteor/check';
import { eraseRoom } from '../../../../server/lib/eraseRoom';
import { canAccessRoomAsync } from '../../../authorization/server';
import { hasPermissionAsync, hasAtLeastOnePermissionAsync } from '../../../authorization/server/functions/hasPermission';
import { removeUserFromRoom } from '../../../lib/server/functions/removeUserFromRoom';
import { settings } from '../../../settings/server';
+import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';
import { getPaginationItems } from '../helpers/getPaginationItems';
import { eraseTeam } from '../lib/eraseTeam';
-API.v1.addRoute(
- 'teams.list',
- { authRequired: true },
- {
- async get() {
+const paginatedTeamsResponseSchema = ajv.compile<{ teams: ITeam[]; total: number; count: number; offset: number }>({
+ type: 'object',
+ properties: {
+ teams: { type: 'array', items: { type: 'object' } },
+ total: { type: 'number' },
+ count: { type: 'number' },
+ offset: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['teams', 'total', 'count', 'offset', 'success'],
+ additionalProperties: false,
+});
+
+const successResponseSchema = ajv.compile({
+ type: 'object',
+ properties: { success: { type: 'boolean', enum: [true] } },
+ required: ['success'],
+ additionalProperties: false,
+});
+
+const teamsEndpoints = API.v1
+ .get(
+ 'teams.list',
+ {
+ authRequired: true,
+ response: {
+ 200: paginatedTeamsResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort, query } = await this.parseJsonQuery();
@@ -42,14 +82,19 @@ API.v1.addRoute(
offset,
});
},
- },
-);
-
-API.v1.addRoute(
- 'teams.listAll',
- { authRequired: true, permissionsRequired: ['view-all-teams'] },
- {
- async get() {
+ )
+ .get(
+ 'teams.listAll',
+ {
+ authRequired: true,
+ permissionsRequired: ['view-all-teams'],
+ response: {
+ 200: paginatedTeamsResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { records, total } = await Team.listAll({ offset, count });
@@ -61,25 +106,28 @@ API.v1.addRoute(
offset,
});
},
- },
-);
-
-API.v1.addRoute(
- 'teams.create',
- { authRequired: true, permissionsRequired: ['create-team'] },
- {
- async post() {
- check(
- this.bodyParams,
- Match.ObjectIncluding({
- name: String,
- type: Match.OneOf(TeamType.PRIVATE, TeamType.PUBLIC),
- members: Match.Maybe([String]),
- room: Match.Maybe(Match.Any),
- owner: Match.Maybe(String),
+ )
+ .post(
+ 'teams.create',
+ {
+ authRequired: true,
+ permissionsRequired: ['create-team'],
+ body: isTeamsCreateProps,
+ response: {
+ 200: ajv.compile<{ team: ITeam }>({
+ type: 'object',
+ properties: {
+ team: { type: 'object' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['team', 'success'],
+ additionalProperties: false,
}),
- );
-
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const { name, type, members, room, owner } = this.bodyParams;
const team = await Team.create(this.userId, {
@@ -87,15 +135,14 @@ API.v1.addRoute(
name,
type,
},
- room,
+ room: room as Parameters[1]['room'],
members,
owner,
});
return API.v1.success({ team });
},
- },
-);
+ );
const getTeamByIdOrName = async (params: { teamId: string } | { teamName: string }): Promise => {
if ('teamId' in params && params.teamId) {
@@ -109,264 +156,266 @@ const getTeamByIdOrName = async (params: { teamId: string } | { teamName: string
return null;
};
-API.v1.addRoute(
+API.v1.post(
'teams.convertToChannel',
{
authRequired: true,
- validateParams: isTeamsConvertToChannelProps,
+ body: isTeamsConvertToChannelProps,
+ response: {
+ 200: successResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
},
- {
- async post() {
- const { roomsToRemove = [] } = this.bodyParams;
+ async function action() {
+ const { roomsToRemove = [] } = this.bodyParams;
- const team = await getTeamByIdOrName(this.bodyParams);
+ const team = await getTeamByIdOrName(this.bodyParams);
- if (!team) {
- return API.v1.failure('team-does-not-exist');
- }
+ if (!team) {
+ return API.v1.failure('team-does-not-exist');
+ }
- if (!(await hasPermissionAsync(this.userId, 'convert-team', team.roomId))) {
- return API.v1.forbidden();
- }
+ if (!(await hasPermissionAsync(this.userId, 'convert-team', team.roomId))) {
+ return API.v1.forbidden();
+ }
- const rooms = await Team.getMatchingTeamRooms(team._id, roomsToRemove);
+ const rooms = await Team.getMatchingTeamRooms(team._id, roomsToRemove);
- if (rooms.length) {
- for (const room of rooms) {
- await eraseRoom(room, this.user);
- }
+ if (rooms.length) {
+ for (const room of rooms) {
+ await eraseRoom(room, this.user);
}
+ }
- await Promise.all([Team.unsetTeamIdOfRooms(this.user, team), Team.removeAllMembersFromTeam(team._id)]);
+ await Promise.all([Team.unsetTeamIdOfRooms(this.user, team), Team.removeAllMembersFromTeam(team._id)]);
- await Team.deleteById(team._id);
+ await Team.deleteById(team._id);
- return API.v1.success();
- },
+ return API.v1.success();
},
);
-API.v1.addRoute(
+const roomResponseSchema = ajv.compile<{ room: object }>({
+ type: 'object',
+ properties: {
+ room: { type: 'object' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['room', 'success'],
+ additionalProperties: false,
+});
+
+const roomsResponseSchema = ajv.compile<{ rooms: object[] }>({
+ type: 'object',
+ properties: {
+ rooms: { type: 'array', items: { type: 'object' } },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['rooms', 'success'],
+ additionalProperties: false,
+});
+
+API.v1.post(
'teams.addRooms',
- { authRequired: true },
{
- async post() {
- check(
- this.bodyParams,
- Match.OneOf(
- Match.ObjectIncluding({
- teamId: String,
- rooms: [String] as [StringConstructor],
- }),
- Match.ObjectIncluding({
- teamName: String,
- rooms: [String] as [StringConstructor],
- }),
- ),
- );
-
- const team = await getTeamByIdOrName(this.bodyParams);
- if (!team) {
- return API.v1.failure('team-does-not-exist');
- }
+ authRequired: true,
+ body: isTeamsAddRoomsProps,
+ response: {
+ 200: roomsResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
+ },
+ async function action() {
+ const team = await getTeamByIdOrName(this.bodyParams);
+ if (!team) {
+ return API.v1.failure('team-does-not-exist');
+ }
- if (!(await hasPermissionAsync(this.userId, 'move-room-to-team', team.roomId))) {
- return API.v1.forbidden('error-no-permission-team-channel');
- }
+ if (!(await hasPermissionAsync(this.userId, 'move-room-to-team', team.roomId))) {
+ return API.v1.forbidden('error-no-permission-team-channel');
+ }
- const { rooms } = this.bodyParams;
+ const { rooms } = this.bodyParams;
- const validRooms = await Team.addRooms(this.userId, rooms, team._id);
+ const validRooms = await Team.addRooms(this.userId, rooms, team._id);
- return API.v1.success({ rooms: validRooms });
- },
+ return API.v1.success({ rooms: validRooms });
},
);
-API.v1.addRoute(
+API.v1.post(
'teams.removeRoom',
{
authRequired: true,
- validateParams: isTeamsRemoveRoomProps,
+ body: isTeamsRemoveRoomProps,
+ response: {
+ 200: roomResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
},
- {
- async post() {
- const team = await getTeamByIdOrName(this.bodyParams);
- if (!team) {
- return API.v1.failure('team-does-not-exist');
- }
+ async function action() {
+ const team = await getTeamByIdOrName(this.bodyParams);
+ if (!team) {
+ return API.v1.failure('team-does-not-exist');
+ }
- if (!(await hasPermissionAsync(this.userId, 'remove-team-channel', team.roomId))) {
- return API.v1.forbidden();
- }
+ if (!(await hasPermissionAsync(this.userId, 'remove-team-channel', team.roomId))) {
+ return API.v1.forbidden();
+ }
- const canRemoveAny = !!(await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId));
+ const canRemoveAny = !!(await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId));
- const { roomId } = this.bodyParams;
+ const { roomId } = this.bodyParams;
- const room = await Team.removeRoom(this.userId, roomId, team._id, canRemoveAny);
+ const room = await Team.removeRoom(this.userId, roomId, team._id, canRemoveAny);
- return API.v1.success({ room });
- },
+ return API.v1.success({ room });
},
);
-API.v1.addRoute(
+API.v1.post(
'teams.updateRoom',
- { authRequired: true },
{
- async post() {
- check(
- this.bodyParams,
- Match.ObjectIncluding({
- roomId: String,
- isDefault: Boolean,
- }),
- );
-
- const { roomId, isDefault } = this.bodyParams;
+ authRequired: true,
+ body: isTeamsUpdateRoomProps,
+ response: {
+ 200: roomResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
+ },
+ async function action() {
+ const { roomId, isDefault } = this.bodyParams;
- const team = await Team.getOneByRoomId(roomId);
- if (!team) {
- return API.v1.failure('team-does-not-exist');
- }
+ const team = await Team.getOneByRoomId(roomId);
+ if (!team) {
+ return API.v1.failure('team-does-not-exist');
+ }
- if (!(await hasPermissionAsync(this.userId, 'edit-team-channel', team.roomId))) {
- return API.v1.forbidden();
- }
- const canUpdateAny = !!(await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId));
+ if (!(await hasPermissionAsync(this.userId, 'edit-team-channel', team.roomId))) {
+ return API.v1.forbidden();
+ }
+ const canUpdateAny = !!(await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId));
- if (settings.get('ABAC_Enabled') && isDefault) {
- const room = await Rooms.findOneByIdAndType(roomId, 'p', { projection: { abacAttributes: 1 } });
- if (room?.abacAttributes?.length) {
- return API.v1.failure('error-room-is-abac-managed');
- }
+ if (settings.get('ABAC_Enabled') && isDefault) {
+ const room = await Rooms.findOneByIdAndType(roomId, 'p', { projection: { abacAttributes: 1 } });
+ if (room?.abacAttributes?.length) {
+ return API.v1.failure('error-room-is-abac-managed');
}
+ }
- const room = await Team.updateRoom(this.userId, roomId, isDefault, canUpdateAny);
+ const room = await Team.updateRoom(this.userId, roomId, isDefault, canUpdateAny);
- return API.v1.success({ room });
- },
+ return API.v1.success({ room });
},
);
-API.v1.addRoute(
+const paginatedRoomsResponseSchema = ajv.compile<{ rooms: object[]; total: number; count: number; offset: number }>({
+ type: 'object',
+ properties: {
+ rooms: { type: 'array', items: { type: 'object' } },
+ total: { type: 'number' },
+ count: { type: 'number' },
+ offset: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['rooms', 'total', 'count', 'offset', 'success'],
+ additionalProperties: false,
+});
+
+API.v1.get(
'teams.listRooms',
- { authRequired: true },
{
- async get() {
- check(
- this.queryParams,
- Match.OneOf(
- Match.ObjectIncluding({
- teamId: String,
- }),
- Match.ObjectIncluding({
- teamName: String,
- }),
- ),
- );
-
- check(
- this.queryParams,
- Match.ObjectIncluding({
- filter: Match.Maybe(String),
- type: Match.Maybe(String),
- offset: Match.Maybe(String),
- count: Match.Maybe(String),
- }),
- );
-
- const { filter, type } = this.queryParams;
- const { offset, count } = await getPaginationItems(this.queryParams);
-
- const team = await getTeamByIdOrName(this.queryParams);
- if (!team) {
- return API.v1.failure('team-does-not-exist');
- }
-
- const allowPrivateTeam: boolean = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId);
-
- const getAllRooms = await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId);
-
- const listFilter = {
- name: filter ?? undefined,
- isDefault: type === 'autoJoin',
- getAllRooms,
- allowPrivateTeam,
- };
-
- const { records, total } = await Team.listRooms(this.userId, team._id, listFilter, {
- offset,
- count,
- });
-
- return API.v1.success({
- rooms: records,
- total,
- count: records.length,
- offset,
- });
+ authRequired: true,
+ query: isTeamsListRoomsProps,
+ response: {
+ 200: paginatedRoomsResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
},
},
+ async function action() {
+ const { filter, type } = this.queryParams;
+ const { offset, count } = await getPaginationItems(this.queryParams);
+
+ const team = await getTeamByIdOrName(this.queryParams);
+ if (!team) {
+ return API.v1.failure('team-does-not-exist');
+ }
+
+ const allowPrivateTeam: boolean = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId);
+
+ const getAllRooms = await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId);
+
+ const listFilter = {
+ name: filter ?? undefined,
+ isDefault: type === 'autoJoin',
+ getAllRooms,
+ allowPrivateTeam,
+ };
+
+ const { records, total } = await Team.listRooms(this.userId, team._id, listFilter, {
+ offset,
+ count,
+ });
+
+ return API.v1.success({
+ rooms: records,
+ total,
+ count: records.length,
+ offset,
+ });
+ },
);
-API.v1.addRoute(
+API.v1.get(
'teams.listRoomsOfUser',
- { authRequired: true },
{
- async get() {
- check(
- this.queryParams,
- Match.OneOf(
- Match.ObjectIncluding({
- teamId: String,
- }),
- Match.ObjectIncluding({
- teamName: String,
- }),
- ),
- );
-
- check(
- this.queryParams,
- Match.ObjectIncluding({
- userId: String,
- canUserDelete: Match.Maybe(String),
- offset: Match.Maybe(String),
- count: Match.Maybe(String),
- }),
- );
-
- const { offset, count } = await getPaginationItems(this.queryParams);
-
- const team = await getTeamByIdOrName(this.queryParams);
- if (!team) {
- return API.v1.failure('team-does-not-exist');
- }
-
- const allowPrivateTeam = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId);
-
- const { userId, canUserDelete } = this.queryParams;
-
- if (!(this.userId === userId || (await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId)))) {
- return API.v1.forbidden();
- }
-
- const booleanCanUserDelete = canUserDelete === 'true';
- const { records, total } = await Team.listRoomsOfUser(this.userId, team._id, userId, allowPrivateTeam, booleanCanUserDelete, {
- offset,
- count,
- });
-
- return API.v1.success({
- rooms: records,
- total,
- count: records.length,
- offset: 0,
- });
+ authRequired: true,
+ query: isTeamsListRoomsOfUserProps,
+ response: {
+ 200: paginatedRoomsResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
},
},
+ async function action() {
+ const { offset, count } = await getPaginationItems(this.queryParams);
+
+ const team = await getTeamByIdOrName(this.queryParams);
+ if (!team) {
+ return API.v1.failure('team-does-not-exist');
+ }
+
+ const allowPrivateTeam = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId);
+
+ const { userId, canUserDelete } = this.queryParams;
+
+ if (!(this.userId === userId || (await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId)))) {
+ return API.v1.forbidden();
+ }
+
+ const booleanCanUserDelete = canUserDelete === 'true';
+ const { records, total } = await Team.listRoomsOfUser(this.userId, team._id, userId, allowPrivateTeam, booleanCanUserDelete, {
+ offset,
+ count,
+ });
+
+ return API.v1.success({
+ rooms: records,
+ total,
+ count: records.length,
+ offset: 0,
+ });
+ },
);
const getTeamByIdOrNameOrParentRoom = async (
@@ -386,322 +435,380 @@ const getTeamByIdOrNameOrParentRoom = async (
// This should accept a teamId, filter (search by name on rooms collection) and sort/pagination
// should return a list of rooms/discussions from the team. the discussions will only be returned from the main room
-API.v1.addRoute(
+API.v1.get(
'teams.listChildren',
- { authRequired: true, validateParams: isTeamsListChildrenProps },
{
- async get() {
- const { offset, count } = await getPaginationItems(this.queryParams);
- const { sort } = await this.parseJsonQuery();
- const { filter, type } = this.queryParams;
+ authRequired: true,
+ query: isTeamsListChildrenProps,
+ response: {
+ 200: ajv.compile<{ data: object[]; total: number; offset: number; count: number }>({
+ type: 'object',
+ properties: {
+ data: { type: 'array', items: { type: 'object' } },
+ total: { type: 'number' },
+ offset: { type: 'number' },
+ count: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['data', 'total', 'offset', 'count', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 404: validateNotFoundErrorResponse,
+ },
+ },
+ async function action() {
+ const { offset, count } = await getPaginationItems(this.queryParams);
+ const { sort } = await this.parseJsonQuery();
+ const { filter, type } = this.queryParams;
- const team = await getTeamByIdOrNameOrParentRoom(this.queryParams);
- if (!team) {
- return API.v1.notFound();
- }
+ const team = await getTeamByIdOrNameOrParentRoom(this.queryParams);
+ if (!team) {
+ return API.v1.notFound();
+ }
- const data = await Team.listChildren(this.userId, team, filter, type, sort, offset, count);
+ const result = await Team.listChildren(this.userId, team, filter, type, sort, offset, count);
- return API.v1.success({ ...data, offset, count });
- },
+ return API.v1.success({ data: result.data, total: result.total, offset, count });
},
);
-API.v1.addRoute(
+API.v1.get(
'teams.members',
- { authRequired: true },
{
- async get() {
- const { offset, count } = await getPaginationItems(this.queryParams);
-
- check(
- this.queryParams,
- Match.OneOf(
- Match.ObjectIncluding({
- teamId: String,
- }),
- Match.ObjectIncluding({
- teamName: String,
- }),
- ),
- );
-
- check(
- this.queryParams,
- Match.ObjectIncluding({
- status: Match.Maybe([String]),
- username: Match.Maybe(String),
- name: Match.Maybe(String),
- }),
- );
-
- const { status, username, name } = this.queryParams;
-
- const team = await getTeamByIdOrName(this.queryParams);
- if (!team) {
- return API.v1.failure('team-does-not-exist');
- }
-
- const canSeeAllMembers = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId);
-
- const query = {
- ...(username && { username: new RegExp(escapeRegExp(username), 'i') }),
- ...(name && { name: new RegExp(escapeRegExp(name), 'i') }),
- ...(status && { status: { $in: status as UserStatus[] } }),
- };
-
- const { records, total } = await Team.members(this.userId, team._id, canSeeAllMembers, { offset, count }, query);
-
- return API.v1.success({
- members: records,
- total,
- count: records.length,
- offset,
- });
+ authRequired: true,
+ query: isTeamsMembersProps,
+ response: {
+ 200: ajv.compile<{ members: object[]; total: number; count: number; offset: number }>({
+ type: 'object',
+ properties: {
+ members: { type: 'array', items: { type: 'object' } },
+ total: { type: 'number' },
+ count: { type: 'number' },
+ offset: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['members', 'total', 'count', 'offset', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
},
},
+ async function action() {
+ const { offset, count } = await getPaginationItems(this.queryParams);
+
+ const { status, username, name } = this.queryParams;
+
+ const team = await getTeamByIdOrName(this.queryParams);
+ if (!team) {
+ return API.v1.failure('team-does-not-exist');
+ }
+
+ const canSeeAllMembers = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId);
+
+ const query: Record = {};
+ if (username) {
+ query.username = new RegExp(escapeRegExp(username), 'i');
+ }
+ if (name) {
+ query.name = new RegExp(escapeRegExp(name), 'i');
+ }
+ if (status) {
+ query.status = { $in: status };
+ }
+
+ const { records, total } = await Team.members(this.userId, team._id, canSeeAllMembers, { offset, count }, query);
+
+ return API.v1.success({
+ members: records,
+ total,
+ count: records.length,
+ offset,
+ });
+ },
);
-API.v1.addRoute(
+API.v1.post(
'teams.addMembers',
{
authRequired: true,
- validateParams: isTeamsAddMembersProps,
+ body: isTeamsAddMembersProps,
+ response: {
+ 200: successResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
},
- {
- async post() {
- const { bodyParams } = this;
- const { members } = bodyParams;
+ async function action() {
+ const { bodyParams } = this;
+ const { members } = bodyParams;
- const team = await getTeamByIdOrName(this.bodyParams);
- if (!team) {
- return API.v1.failure('team-does-not-exist');
- }
+ const team = await getTeamByIdOrName(this.bodyParams);
+ if (!team) {
+ return API.v1.failure('team-does-not-exist');
+ }
- if (!(await hasAtLeastOnePermissionAsync(this.userId, ['add-team-member', 'edit-team-member'], team.roomId))) {
- return API.v1.forbidden();
- }
+ if (!(await hasAtLeastOnePermissionAsync(this.userId, ['add-team-member', 'edit-team-member'], team.roomId))) {
+ return API.v1.forbidden();
+ }
- await Team.addMembers(this.userId, team._id, members);
+ await Team.addMembers(this.userId, team._id, members);
- return API.v1.success();
- },
+ return API.v1.success();
},
);
-API.v1.addRoute(
+API.v1.post(
'teams.updateMember',
{
authRequired: true,
- validateParams: isTeamsUpdateMemberProps,
+ body: isTeamsUpdateMemberProps,
+ response: {
+ 200: successResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
},
- {
- async post() {
- const { bodyParams } = this;
- const { member } = bodyParams;
+ async function action() {
+ const { bodyParams } = this;
+ const { member } = bodyParams;
- const team = await getTeamByIdOrName(this.bodyParams);
- if (!team) {
- return API.v1.failure('team-does-not-exist');
- }
+ const team = await getTeamByIdOrName(this.bodyParams);
+ if (!team) {
+ return API.v1.failure('team-does-not-exist');
+ }
- if (!(await hasAtLeastOnePermissionAsync(this.userId, ['edit-team-member'], team.roomId))) {
- return API.v1.forbidden();
- }
+ if (!(await hasAtLeastOnePermissionAsync(this.userId, ['edit-team-member'], team.roomId))) {
+ return API.v1.forbidden();
+ }
- await Team.updateMember(team._id, member);
+ await Team.updateMember(team._id, member);
- return API.v1.success();
- },
+ return API.v1.success();
},
);
-API.v1.addRoute(
+API.v1.post(
'teams.removeMember',
{
authRequired: true,
- validateParams: isTeamsRemoveMemberProps,
- },
- {
- async post() {
- const { bodyParams } = this;
- const { userId, rooms } = bodyParams;
-
- const team = await getTeamByIdOrName(this.bodyParams);
- if (!team) {
- return API.v1.failure('team-does-not-exist');
- }
-
- if (!(await hasAtLeastOnePermissionAsync(this.userId, ['edit-team-member'], team.roomId))) {
- return API.v1.forbidden();
- }
-
- const user = await Users.findOneActiveById(userId, {});
- if (!user) {
- return API.v1.failure('invalid-user');
- }
-
- if (!(await Team.removeMembers(this.userId, team._id, [{ userId }]))) {
- return API.v1.failure();
- }
-
- if (rooms?.length) {
- const roomsFromTeam: string[] = await Team.getMatchingTeamRooms(team._id, rooms);
-
- await Promise.all(
- roomsFromTeam.map((rid) =>
- removeUserFromRoom(rid, user, {
- byUser: this.user,
- }),
- ),
- );
- }
- return API.v1.success();
+ body: isTeamsRemoveMemberProps,
+ response: {
+ 200: successResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
},
},
+ async function action() {
+ const { bodyParams } = this;
+ const { userId, rooms } = bodyParams;
+
+ const team = await getTeamByIdOrName(this.bodyParams);
+ if (!team) {
+ return API.v1.failure('team-does-not-exist');
+ }
+
+ if (!(await hasAtLeastOnePermissionAsync(this.userId, ['edit-team-member'], team.roomId))) {
+ return API.v1.forbidden();
+ }
+
+ const user = await Users.findOneActiveById(userId, {});
+ if (!user) {
+ return API.v1.failure('invalid-user');
+ }
+
+ if (!(await Team.removeMembers(this.userId, team._id, [{ userId }]))) {
+ return API.v1.failure('could-not-remove-member');
+ }
+
+ if (rooms?.length) {
+ const roomsFromTeam: string[] = await Team.getMatchingTeamRooms(team._id, rooms);
+
+ await Promise.all(
+ roomsFromTeam.map((rid) =>
+ removeUserFromRoom(rid, user, {
+ byUser: this.user,
+ }),
+ ),
+ );
+ }
+ return API.v1.success();
+ },
);
-API.v1.addRoute(
+API.v1.post(
'teams.leave',
{
authRequired: true,
- validateParams: isTeamsLeaveProps,
- },
- {
- async post() {
- const { rooms = [] } = this.bodyParams;
-
- const team = await getTeamByIdOrName(this.bodyParams);
- if (!team) {
- return API.v1.failure('team-does-not-exist');
- }
-
- await Team.removeMembers(this.userId, team._id, [
- {
- userId: this.userId,
- },
- ]);
-
- if (rooms.length) {
- const roomsFromTeam: string[] = await Team.getMatchingTeamRooms(team._id, rooms);
- await Promise.all(roomsFromTeam.map((rid) => removeUserFromRoom(rid, this.user)));
- }
-
- return API.v1.success();
+ body: isTeamsLeaveProps,
+ response: {
+ 200: successResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
},
},
+ async function action() {
+ const { rooms = [] } = this.bodyParams;
+
+ const team = await getTeamByIdOrName(this.bodyParams);
+ if (!team) {
+ return API.v1.failure('team-does-not-exist');
+ }
+
+ await Team.removeMembers(this.userId, team._id, [
+ {
+ userId: this.userId,
+ },
+ ]);
+
+ if (rooms.length) {
+ const roomsFromTeam: string[] = await Team.getMatchingTeamRooms(team._id, rooms);
+ await Promise.all(roomsFromTeam.map((rid) => removeUserFromRoom(rid, this.user)));
+ }
+
+ return API.v1.success();
+ },
);
-API.v1.addRoute(
+API.v1.get(
'teams.info',
- { authRequired: true },
{
- async get() {
- check(
- this.queryParams,
- Match.OneOf(
- Match.ObjectIncluding({
- teamId: String,
- }),
- Match.ObjectIncluding({
- teamName: String,
- }),
- ),
- );
-
- const teamInfo = await getTeamByIdOrName(this.queryParams);
- if (!teamInfo) {
- return API.v1.failure('Team not found');
- }
+ authRequired: true,
+ query: isTeamsInfoProps,
+ response: {
+ 200: ajv.compile<{ teamInfo: ITeam }>({
+ type: 'object',
+ properties: {
+ teamInfo: { type: 'object' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['teamInfo', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
+ },
+ async function action() {
+ const teamInfo = await getTeamByIdOrName(this.queryParams);
+ if (!teamInfo) {
+ return API.v1.failure('Team not found');
+ }
- const room = await Rooms.findOneById(teamInfo.roomId);
+ const room = await Rooms.findOneById(teamInfo.roomId);
- if (!room) {
- return API.v1.failure('Room not found');
- }
+ if (!room) {
+ return API.v1.failure('Room not found');
+ }
- const canViewInfo =
- (await canAccessRoomAsync(room, { _id: this.userId })) || (await hasPermissionAsync(this.userId, 'view-all-teams'));
+ const canViewInfo = (await canAccessRoomAsync(room, { _id: this.userId })) || (await hasPermissionAsync(this.userId, 'view-all-teams'));
- if (!canViewInfo) {
- return API.v1.forbidden();
- }
+ if (!canViewInfo) {
+ return API.v1.forbidden();
+ }
- return API.v1.success({ teamInfo });
- },
+ return API.v1.success({ teamInfo });
},
);
-API.v1.addRoute(
+API.v1.post(
'teams.delete',
{
authRequired: true,
- validateParams: isTeamsDeleteProps,
+ body: isTeamsDeleteProps,
+ response: {
+ 200: successResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
},
- {
- async post() {
- const { roomsToRemove = [] } = this.bodyParams;
+ async function action() {
+ const { roomsToRemove = [] } = this.bodyParams;
- const team = await getTeamByIdOrName(this.bodyParams);
+ const team = await getTeamByIdOrName(this.bodyParams);
- if (!team) {
- return API.v1.failure('team-does-not-exist');
- }
+ if (!team) {
+ return API.v1.failure('team-does-not-exist');
+ }
- if (!(await hasPermissionAsync(this.userId, 'delete-team', team.roomId))) {
- return API.v1.forbidden();
- }
+ if (!(await hasPermissionAsync(this.userId, 'delete-team', team.roomId))) {
+ return API.v1.forbidden();
+ }
- await eraseTeam(this.user, team, roomsToRemove);
+ await eraseTeam(this.user, team, roomsToRemove);
- return API.v1.success();
- },
+ return API.v1.success();
},
);
-API.v1.addRoute(
+API.v1.get(
'teams.autocomplete',
- { authRequired: true },
{
- async get() {
- check(
- this.queryParams,
- Match.ObjectIncluding({
- name: String,
- }),
- );
-
- const { name } = this.queryParams;
+ authRequired: true,
+ query: isTeamsAutocompleteProps,
+ response: {
+ 200: ajv.compile<{ teams: ITeamAutocompleteResult[] }>({
+ type: 'object',
+ properties: {
+ teams: { type: 'array', items: { type: 'object' } },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['teams', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const { name } = this.queryParams;
- const teams = await Team.autocomplete(this.userId, name);
+ const teams = await Team.autocomplete(this.userId, name);
- return API.v1.success({ teams });
- },
+ return API.v1.success({ teams });
},
);
-API.v1.addRoute(
+API.v1.post(
'teams.update',
{
authRequired: true,
- validateParams: isTeamsUpdateProps,
+ body: isTeamsUpdateProps,
+ response: {
+ 200: successResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
},
- {
- async post() {
- const { data } = this.bodyParams;
+ async function action() {
+ const { data } = this.bodyParams;
- const team = await getTeamByIdOrName(this.bodyParams);
- if (!team) {
- return API.v1.failure('team-does-not-exist');
- }
+ const team = await getTeamByIdOrName(this.bodyParams);
+ if (!team) {
+ return API.v1.failure('team-does-not-exist');
+ }
- if (!(await hasPermissionAsync(this.userId, 'edit-team', team.roomId))) {
- return API.v1.forbidden();
- }
+ if (!(await hasPermissionAsync(this.userId, 'edit-team', team.roomId))) {
+ return API.v1.forbidden();
+ }
- await Team.update(this.userId, team._id, data);
+ await Team.update(this.userId, team._id, data);
- return API.v1.success();
- },
+ return API.v1.success();
},
);
+
+export type TeamsEndpoints = ExtractRoutesFromAPI;
+
+declare module '@rocket.chat/rest-typings' {
+ // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
+ interface Endpoints extends TeamsEndpoints {}
+}
diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts
index 50c65abcd8d12..c2d5cf6841829 100644
--- a/apps/meteor/app/api/server/v1/users.ts
+++ b/apps/meteor/app/api/server/v1/users.ts
@@ -18,10 +18,17 @@ import {
isUsersSetPreferencesParamsPOST,
isUsersCheckUsernameAvailabilityParamsGET,
isUsersSendConfirmationEmailParamsPOST,
+ isUsersListParamsGET,
+ isUsersPresenceParamsGET,
+ isUsersRequestDataDownloadParamsGET,
+ isUsersGetPresenceParamsGET,
+ isUsersGetStatusParamsGET,
ajv,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
+ validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';
+import { escapeRegExp } from '@rocket.chat/string-helpers';
import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { Match, check } from 'meteor/check';
@@ -52,7 +59,7 @@ import {
} from '../../../lib/server/functions/checkUsernameAvailability';
import { deleteUser } from '../../../lib/server/functions/deleteUser';
import { getAvatarSuggestionForUser } from '../../../lib/server/functions/getAvatarSuggestionForUser';
-import { getFullUserDataByIdOrUsernameOrImportId, defaultFields, fullFields } from '../../../lib/server/functions/getFullUserData';
+import { getFullUserDataByIdOrUsernameOrImportIdOrEmail, 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';
@@ -99,59 +106,91 @@ API.v1.addRoute(
},
);
-API.v1.addRoute(
+const voidSuccessResponse = ajv.compile({
+ type: 'object',
+ properties: {
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['success'],
+ additionalProperties: false,
+});
+
+// user shape varies by projection and permissions — use $ref when IUser is available in typia
+const userObjectResponse = ajv.compile<{ user: object }>({
+ type: 'object',
+ properties: {
+ user: { type: 'object' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['user', 'success'],
+ additionalProperties: false,
+});
+
+API.v1.post(
'users.update',
- { authRequired: true, twoFactorRequired: true, validateParams: isUsersUpdateParamsPOST },
{
- async post() {
- const userData = { _id: this.bodyParams.userId, ...this.bodyParams.data };
+ authRequired: true,
+ twoFactorRequired: true,
+ body: isUsersUpdateParamsPOST,
+ response: {
+ 200: userObjectResponse,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const userData = { _id: this.bodyParams.userId, ...this.bodyParams.data };
- if (userData.name && !validateNameChars(userData.name)) {
- return API.v1.failure('Name contains invalid characters');
- }
- const auditStore = new UserChangedAuditStore({
- _id: this.user._id,
- ip: this.requestIp,
- useragent: this.request.headers.get('user-agent') || '',
- username: this.user.username,
- });
+ if (userData.name && !validateNameChars(userData.name)) {
+ return API.v1.failure('Name contains invalid characters');
+ }
+ const auditStore = new UserChangedAuditStore({
+ _id: this.user._id,
+ ip: this.requestIp || '',
+ useragent: this.request.headers.get('user-agent') || '',
+ username: this.user.username,
+ });
- await saveUser(this.userId, userData, { auditStore });
+ await saveUser(this.userId, userData, { auditStore });
- if (typeof this.bodyParams.data.active !== 'undefined') {
- const {
- userId,
- data: { active },
- confirmRelinquish,
- } = this.bodyParams;
- await executeSetUserActiveStatus(this.userId, userId, active, Boolean(confirmRelinquish));
- }
+ if (typeof this.bodyParams.data.active !== 'undefined') {
+ const {
+ userId,
+ data: { active },
+ confirmRelinquish,
+ } = this.bodyParams;
+ await executeSetUserActiveStatus(this.userId, userId, active, Boolean(confirmRelinquish));
+ }
- const { fields } = await this.parseJsonQuery();
+ const { fields } = await this.parseJsonQuery();
- const user = await Users.findOneById(this.bodyParams.userId, { projection: fields });
- if (!user) {
- return API.v1.failure('User not found');
- }
+ const user = await Users.findOneById(this.bodyParams.userId, { projection: fields });
+ if (!user) {
+ return API.v1.failure('User not found');
+ }
- return API.v1.success({ user });
- },
+ return API.v1.success({ user });
},
);
-API.v1.addRoute(
- 'users.updateOwnBasicInfo',
- {
- authRequired: true,
- userWithoutUsername: true,
- validateParams: isUsersUpdateOwnBasicInfoParamsPOST,
- rateLimiterOptions: {
- numRequestsAllowed: 1,
- intervalTimeInMS: 60000,
+API.v1
+ .post(
+ 'users.updateOwnBasicInfo',
+ {
+ authRequired: true,
+ userWithoutUsername: true,
+ body: isUsersUpdateOwnBasicInfoParamsPOST,
+ rateLimiterOptions: {
+ numRequestsAllowed: 1,
+ intervalTimeInMS: 60000,
+ },
+ response: {
+ 200: userObjectResponse,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
},
- },
- {
- async post() {
+ async function action() {
const userData = {
email: this.bodyParams.data.email,
realname: this.bodyParams.data.name,
@@ -182,14 +221,19 @@ API.v1.addRoute(
user: await getUserInfo((await Users.findOneById(this.userId, { projection: API.v1.defaultFieldsToExclude })) as IUser, false),
});
},
- },
-);
-
-API.v1.addRoute(
- 'users.setPreferences',
- { authRequired: true, validateParams: isUsersSetPreferencesParamsPOST },
- {
- async post() {
+ )
+ .post(
+ 'users.setPreferences',
+ {
+ authRequired: true,
+ body: isUsersSetPreferencesParamsPOST,
+ response: {
+ 200: userObjectResponse,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
if (
this.bodyParams.userId &&
this.bodyParams.userId !== this.userId &&
@@ -226,14 +270,20 @@ API.v1.addRoute(
} as unknown as Required>,
});
},
- },
-);
-
-API.v1.addRoute(
- 'users.setAvatar',
- { authRequired: true, validateParams: isUsersSetAvatarProps },
- {
- async post() {
+ )
+ .post(
+ 'users.setAvatar',
+ {
+ authRequired: true,
+ body: isUsersSetAvatarProps,
+ response: {
+ 200: voidSuccessResponse,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
+ },
+ async function action() {
const canEditOtherUserAvatar = await hasPermissionAsync(this.userId, 'edit-other-user-avatar');
if (!settings.get('Accounts_AllowUserAvatarChange') && !canEditOtherUserAvatar) {
@@ -297,14 +347,19 @@ API.v1.addRoute(
return API.v1.success();
},
- },
-);
-
-API.v1.addRoute(
- 'users.create',
- { authRequired: true, validateParams: isUserCreateParamsPOST },
- {
- async post() {
+ )
+ .post(
+ 'users.create',
+ {
+ authRequired: true,
+ body: isUserCreateParamsPOST,
+ response: {
+ 200: userObjectResponse,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
// New change made by pull request #5152
if (typeof this.bodyParams.joinDefaultChannels === 'undefined') {
this.bodyParams.joinDefaultChannels = true;
@@ -342,146 +397,232 @@ API.v1.addRoute(
return API.v1.success({ user });
},
- },
-);
+ );
-API.v1.addRoute(
+API.v1.post(
'users.delete',
- { authRequired: true, permissionsRequired: ['delete-user'] },
{
- async post() {
- const user = await getUserFromParams(this.bodyParams);
- const { confirmRelinquish = false } = this.bodyParams;
+ authRequired: true,
+ permissionsRequired: ['delete-user'],
+ body: ajv.compile<{ userId?: string; username?: string; user?: string; confirmRelinquish?: boolean }>({
+ type: 'object',
+ properties: {
+ userId: { type: 'string' },
+ username: { type: 'string' },
+ user: { type: 'string' },
+ confirmRelinquish: { type: 'boolean', nullable: true },
+ },
+ anyOf: [{ required: ['userId'] }, { required: ['username'] }, { required: ['user'] }],
+ additionalProperties: false,
+ }),
+ response: {
+ 200: ajv.compile<{ deletedRooms: string[] }>({
+ type: 'object',
+ properties: {
+ deletedRooms: { type: 'array', items: { type: 'string' } },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['deletedRooms', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const user = await getUserFromParams(this.bodyParams);
+ const { confirmRelinquish = false } = this.bodyParams;
- const { deletedRooms } = await deleteUser(user._id, confirmRelinquish, this.userId);
+ const { deletedRooms } = await deleteUser(user._id, confirmRelinquish, this.userId);
- return API.v1.success({ deletedRooms });
- },
+ return API.v1.success({ deletedRooms });
},
);
-API.v1.addRoute(
+API.v1.post(
'users.deleteOwnAccount',
- { authRequired: true },
{
- async post() {
- const { password } = this.bodyParams;
- if (!password) {
- return API.v1.failure('Body parameter "password" is required.');
- }
- if (!settings.get('Accounts_AllowDeleteOwnAccount')) {
- throw new Meteor.Error('error-not-allowed', 'Not allowed');
- }
+ authRequired: true,
+ body: ajv.compile<{ password: string; confirmRelinquish?: boolean }>({
+ type: 'object',
+ properties: {
+ password: { type: 'string' },
+ confirmRelinquish: { type: 'boolean', nullable: true },
+ },
+ required: ['password'],
+ additionalProperties: false,
+ }),
+ response: {
+ 200: ajv.compile({
+ type: 'object',
+ properties: {
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ if (!settings.get('Accounts_AllowDeleteOwnAccount')) {
+ throw new Meteor.Error('error-not-allowed', 'Not allowed');
+ }
- const { confirmRelinquish = false } = this.bodyParams;
+ const { confirmRelinquish = false } = this.bodyParams;
- await deleteUserOwnAccount(this.userId, password, confirmRelinquish);
+ await deleteUserOwnAccount(this.userId, this.bodyParams.password, confirmRelinquish);
- return API.v1.success();
- },
+ return API.v1.success();
},
);
-API.v1.addRoute(
+API.v1.post(
'users.setActiveStatus',
{
authRequired: true,
- validateParams: isUserSetActiveStatusParamsPOST,
+ body: isUserSetActiveStatusParamsPOST,
permissionsRequired: {
POST: { permissions: ['edit-other-user-active-status', 'manage-moderation-actions'], operation: 'hasAny' },
},
+ response: {
+ 200: ajv.compile<{ user: Pick }>({
+ type: 'object',
+ properties: {
+ user: {
+ type: 'object',
+ properties: {
+ _id: { type: 'string' },
+ active: { type: 'boolean' },
+ },
+ required: ['_id', 'active'],
+ additionalProperties: false,
+ },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['user', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
},
- {
- async post() {
- const { userId, activeStatus, confirmRelinquish = false } = this.bodyParams;
- await executeSetUserActiveStatus(this.userId, userId, activeStatus, confirmRelinquish);
+ async function action() {
+ const { userId, activeStatus, confirmRelinquish = false } = this.bodyParams;
+ await executeSetUserActiveStatus(this.userId, userId, activeStatus, confirmRelinquish);
- const user = await Users.findOneById(this.bodyParams.userId, { projection: { active: 1 } });
- if (!user) {
- return API.v1.failure('User not found');
- }
- return API.v1.success({
- user,
- });
- },
+ const user = await Users.findOneById(this.bodyParams.userId, { projection: { active: 1 } });
+ if (!user) {
+ return API.v1.failure('User not found');
+ }
+ return API.v1.success({
+ user,
+ });
},
);
-API.v1.addRoute(
+API.v1.post(
'users.deactivateIdle',
- { authRequired: true, validateParams: isUserDeactivateIdleParamsPOST, permissionsRequired: ['edit-other-user-active-status'] },
{
- async post() {
- const { daysIdle, role = 'user' } = this.bodyParams;
+ authRequired: true,
+ body: isUserDeactivateIdleParamsPOST,
+ permissionsRequired: ['edit-other-user-active-status'],
+ response: {
+ 200: ajv.compile<{ count: number }>({
+ type: 'object',
+ properties: {
+ count: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['count', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const { daysIdle, role = 'user' } = this.bodyParams;
- const lastLoggedIn = new Date();
- lastLoggedIn.setDate(lastLoggedIn.getDate() - daysIdle);
+ const lastLoggedIn = new Date();
+ lastLoggedIn.setDate(lastLoggedIn.getDate() - daysIdle);
- // since we're deactiving users that are not logged in, there is no need to send data through WS
- const { modifiedCount: count } = await Users.setActiveNotLoggedInAfterWithRole(lastLoggedIn, role, false);
+ // since we're deactiving users that are not logged in, there is no need to send data through WS
+ const { modifiedCount: count } = await Users.setActiveNotLoggedInAfterWithRole(lastLoggedIn, role, false);
- return API.v1.success({
- count,
- });
- },
+ return API.v1.success({
+ count,
+ });
},
);
-API.v1.addRoute(
+API.v1.get(
'users.info',
- { authRequired: true, validateParams: isUsersInfoParamsGetProps },
{
- async get() {
- const searchTerms: [string, 'id' | 'username' | 'importId'] | 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']);
-
- if (!searchTerms) {
- return API.v1.failure('Invalid search query.');
- }
-
- const user = await getFullUserDataByIdOrUsernameOrImportId(this.userId, ...searchTerms);
+ authRequired: true,
+ query: isUsersInfoParamsGetProps,
+ response: {
+ // user shape varies by projection, permissions, and includeUserRooms
+ 200: userObjectResponse,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const searchTerms: [string, 'id' | 'username' | 'importId' | 'email'] | 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']);
+
+ if (!searchTerms) {
+ return API.v1.failure('Invalid search query.');
+ }
- 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: {
- ...user,
- rooms: await Subscriptions.findByUserId(user._id, {
- projection: {
- rid: 1,
- name: 1,
- t: 1,
- roles: 1,
- unread: 1,
- federated: 1,
- },
- sort: {
- t: 1,
- name: 1,
- },
- }).toArray(),
- },
- });
- }
+ const user = await getFullUserDataByIdOrUsernameOrImportIdOrEmail(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,
+ user: {
+ ...user,
+ rooms: await Subscriptions.findByUserId(user._id, {
+ projection: {
+ rid: 1,
+ name: 1,
+ t: 1,
+ roles: 1,
+ unread: 1,
+ federated: 1,
+ },
+ sort: {
+ t: 1,
+ name: 1,
+ },
+ }).toArray(),
+ },
});
- },
+ }
+
+ return API.v1.success({
+ user,
+ });
},
);
+// users.list accepts arbitrary query filter fields (name, username, etc.)
+// that cannot be statically defined — keeping as addRoute until params are known
API.v1.addRoute(
'users.list',
{
authRequired: true,
queryOperations: ['$or', '$and'],
permissionsRequired: ['view-d-room'],
+ query: isUsersListParamsGET,
},
{
async get() {
@@ -491,6 +632,7 @@ API.v1.addRoute(
) {
return API.v1.forbidden();
}
+ const canViewFullOtherUserInfo = await hasPermissionAsync(this.userId, 'view-full-other-user-info');
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort, fields, query } = await this.parseJsonQuery();
@@ -501,7 +643,18 @@ API.v1.addRoute(
const inclusiveFieldsKeys = Object.keys(inclusiveFields);
- const nonEmptyQuery = getNonEmptyQuery(query, await hasPermissionAsync(this.userId, 'view-full-other-user-info'));
+ const nonEmptyQuery = getNonEmptyQuery(query, canViewFullOtherUserInfo);
+
+ if ('email' in this.queryParams && this.queryParams.email) {
+ if (!canViewFullOtherUserInfo) {
+ return API.v1.forbidden();
+ }
+ const escapedEmail = escapeRegExp(this.queryParams.email as string);
+ nonEmptyQuery['emails.address'] = {
+ $regex: `^${escapedEmail}$`,
+ $options: 'i',
+ };
+ }
// if user provided a query, validate it with their allowed operators
// otherwise we use the default query (with $regex and $options)
@@ -590,77 +743,103 @@ API.v1.addRoute(
},
);
-API.v1.addRoute(
+API.v1.get(
'users.listByStatus',
{
authRequired: true,
- validateParams: isUsersListStatusProps,
+ query: isUsersListStatusProps,
permissionsRequired: ['view-d-room'],
+ response: {
+ 200: ajv.compile<{ users: IUser[]; count: number; offset: number; total: number }>({
+ type: 'object',
+ properties: {
+ // user shape varies by projection and permissions
+ users: { type: 'array' },
+ count: { type: 'number' },
+ offset: { type: 'number' },
+ total: { type: 'number' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['users', 'count', 'offset', 'total', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
},
- {
- async get() {
- if (
- settings.get('API_Apply_permission_view-outside-room_on_users-list') &&
- !(await hasPermissionAsync(this.userId, 'view-outside-room'))
- ) {
- return API.v1.forbidden();
- }
+ async function action() {
+ if (
+ settings.get('API_Apply_permission_view-outside-room_on_users-list') &&
+ !(await hasPermissionAsync(this.userId, 'view-outside-room'))
+ ) {
+ return API.v1.forbidden();
+ }
- const { offset, count } = await getPaginationItems(this.queryParams);
- const { sort } = await this.parseJsonQuery();
- const { status, hasLoggedIn, type, roles, searchTerm, inactiveReason } = this.queryParams;
-
- return API.v1.success(
- await findPaginatedUsersByStatus({
- uid: this.userId,
- offset,
- count,
- sort,
- status,
- roles,
- searchTerm,
- hasLoggedIn,
- type,
- inactiveReason,
- }),
- );
- },
+ const { offset, count } = await getPaginationItems(this.queryParams);
+ const { sort } = await this.parseJsonQuery();
+ const { status, hasLoggedIn, type, roles, searchTerm, inactiveReason } = this.queryParams;
+
+ return API.v1.success(
+ await findPaginatedUsersByStatus({
+ uid: this.userId,
+ offset,
+ count,
+ sort,
+ status,
+ roles,
+ searchTerm,
+ hasLoggedIn,
+ type,
+ inactiveReason,
+ }),
+ );
},
);
-API.v1.addRoute(
+API.v1.post(
'users.sendWelcomeEmail',
{
authRequired: true,
- validateParams: isUsersSendWelcomeEmailProps,
+ body: isUsersSendWelcomeEmailProps,
permissionsRequired: ['send-mail'],
+ response: {
+ 200: ajv.compile({
+ type: 'object',
+ properties: {
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
},
- {
- async post() {
- const { email } = this.bodyParams;
+ async function action() {
+ const { email } = this.bodyParams;
- if (!isSMTPConfigured()) {
- throw new MeteorError('error-email-send-failed', 'SMTP is not configured', {
- method: 'sendWelcomeEmail',
- });
- }
+ if (!isSMTPConfigured()) {
+ throw new MeteorError('error-email-send-failed', 'SMTP is not configured', {
+ method: 'sendWelcomeEmail',
+ });
+ }
- const user = await Users.findOneByEmailAddress(email.trim(), { projection: { name: 1 } });
+ const user = await Users.findOneByEmailAddress(email.trim(), { projection: { name: 1 } });
- if (!user) {
- throw new MeteorError('error-invalid-user', 'Invalid user', {
- method: 'sendWelcomeEmail',
- });
- }
+ if (!user) {
+ throw new MeteorError('error-invalid-user', 'Invalid user', {
+ method: 'sendWelcomeEmail',
+ });
+ }
- await sendWelcomeEmail({ ...user, email });
+ await sendWelcomeEmail({ ...user, email });
- return API.v1.success();
- },
+ return API.v1.success();
},
);
-API.v1.addRoute(
+API.v1.post(
'users.register',
{
authRequired: false,
@@ -668,89 +847,105 @@ API.v1.addRoute(
numRequestsAllowed: settings.get('Rate_Limiter_Limit_RegisterUser') ?? 1,
intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default') ?? 60000,
},
- validateParams: isUserRegisterParamsPOST,
+ body: isUserRegisterParamsPOST,
+ response: {
+ 200: userObjectResponse,
+ 400: validateBadRequestErrorResponse,
+ },
},
- {
- async post() {
- const { secret: secretURL, ...params } = this.bodyParams;
+ async function action() {
+ const { secret: secretURL, ...params } = this.bodyParams;
- if (this.userId) {
- return API.v1.failure('Logged in users can not register again.');
- }
+ if (this.userId) {
+ return API.v1.failure('Logged in users can not register again.');
+ }
- if (params.name && !validateNameChars(params.name)) {
- return API.v1.failure('Name contains invalid characters');
- }
+ if (params.name && !validateNameChars(params.name)) {
+ return API.v1.failure('Name contains invalid characters');
+ }
- if (!validateUsername(this.bodyParams.username)) {
- return API.v1.failure(`The username provided is not valid`);
- }
+ if (!validateUsername(this.bodyParams.username)) {
+ return API.v1.failure(`The username provided is not valid`);
+ }
- if (!(await checkUsernameAvailability(this.bodyParams.username))) {
- return API.v1.failure('Username is already in use');
- }
- if (!(await checkEmailAvailability(this.bodyParams.email))) {
- return API.v1.failure('Email already exists');
- }
- if (this.bodyParams.customFields) {
- try {
- await validateCustomFields(this.bodyParams.customFields);
- } catch (e) {
- return API.v1.failure(e);
- }
+ if (!(await checkUsernameAvailability(this.bodyParams.username))) {
+ return API.v1.failure('Username is already in use');
+ }
+ if (!(await checkEmailAvailability(this.bodyParams.email))) {
+ return API.v1.failure('Email already exists');
+ }
+ if (this.bodyParams.customFields) {
+ try {
+ validateCustomFields(this.bodyParams.customFields);
+ } catch (e) {
+ return API.v1.failure(e);
}
+ }
- // Register the user
- const userId = await registerUser({
- ...params,
- ...(secretURL && { secretURL }),
- });
+ // Register the user
+ const userId = await registerUser({
+ ...params,
+ ...(secretURL && { secretURL }),
+ });
- if (typeof userId !== 'string') {
- return API.v1.failure('Error creating user');
- }
+ if (typeof userId !== 'string') {
+ return API.v1.failure('Error creating user');
+ }
- // Now set their username
- const { fields } = await this.parseJsonQuery();
- await setUsernameWithValidation(userId, this.bodyParams.username);
+ // Now set their username
+ const { fields } = await this.parseJsonQuery();
+ await setUsernameWithValidation(userId, this.bodyParams.username);
- const user = await Users.findOneById(userId, { projection: fields });
- if (!user) {
- return API.v1.failure('User not found');
- }
+ const user = await Users.findOneById(userId, { projection: fields });
+ if (!user) {
+ return API.v1.failure('User not found');
+ }
- if (this.bodyParams.customFields) {
- await saveCustomFields(userId, this.bodyParams.customFields);
- }
+ if (this.bodyParams.customFields) {
+ await saveCustomFields(userId, this.bodyParams.customFields);
+ }
- return API.v1.success({ user });
- },
+ return API.v1.success({ user });
},
);
-API.v1.addRoute(
+API.v1.post(
'users.resetAvatar',
- { authRequired: true },
{
- async post() {
- const user = await getUserFromParams(this.bodyParams);
-
- if (settings.get('Accounts_AllowUserAvatarChange') && user._id === this.userId) {
- await resetAvatar(this.userId, this.userId);
- } else if (
- (await hasPermissionAsync(this.userId, 'edit-other-user-avatar')) ||
- (await hasPermissionAsync(this.userId, 'manage-moderation-actions'))
- ) {
- await resetAvatar(this.userId, user._id);
- } else {
- throw new Meteor.Error('error-not-allowed', 'Reset avatar is not allowed', {
- method: 'users.resetAvatar',
- });
- }
-
- return API.v1.success();
+ authRequired: true,
+ body: ajv.compile<{ userId?: string; username?: string; user?: string }>({
+ type: 'object',
+ properties: {
+ userId: { type: 'string' },
+ username: { type: 'string' },
+ user: { type: 'string' },
+ },
+ additionalProperties: false,
+ }),
+ response: {
+ 200: voidSuccessResponse,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
},
},
+ async function action() {
+ const user = await getUserFromParams(this.bodyParams);
+
+ if (settings.get('Accounts_AllowUserAvatarChange') && user._id === this.userId) {
+ await resetAvatar(this.userId, this.userId);
+ } else if (
+ (await hasPermissionAsync(this.userId, 'edit-other-user-avatar')) ||
+ (await hasPermissionAsync(this.userId, 'manage-moderation-actions'))
+ ) {
+ await resetAvatar(this.userId, user._id);
+ } else {
+ throw new Meteor.Error('error-not-allowed', 'Reset avatar is not allowed', {
+ method: 'users.resetAvatar',
+ });
+ }
+
+ return API.v1.success();
+ },
);
const usersEndpoints = API.v1
@@ -789,7 +984,7 @@ const usersEndpoints = API.v1
minLength: 1,
},
},
- required: ['userId'],
+ required: ['userId', 'authToken'],
additionalProperties: false,
},
success: {
@@ -880,113 +1075,230 @@ const usersEndpoints = API.v1
},
);
-API.v1.addRoute(
+API.v1.get(
'users.getPreferences',
- { authRequired: true },
{
- async get() {
- const user = await Users.findOneById(this.userId);
- if (user?.settings) {
- const { preferences = {} } = user?.settings;
- preferences.language = user?.language;
-
- return API.v1.success({
- preferences,
- });
- }
- return API.v1.failure(i18n.t('Accounts_Default_User_Preferences_not_available').toUpperCase());
+ authRequired: true,
+ response: {
+ 200: ajv.compile<{ preferences: Record }>({
+ type: 'object',
+ properties: {
+ // preferences is a dynamic key-value object that varies per user
+ preferences: { type: 'object' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['preferences', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
},
},
+ async function action() {
+ const user = await Users.findOneById(this.userId);
+ if (user?.settings) {
+ const { preferences = {} } = user?.settings;
+ preferences.language = user?.language;
+
+ return API.v1.success({
+ preferences,
+ });
+ }
+ return API.v1.failure(i18n.t('Accounts_Default_User_Preferences_not_available').toUpperCase());
+ },
);
-API.v1.addRoute(
- 'users.forgotPassword',
- { authRequired: false },
- {
- async post() {
+API.v1
+ .post(
+ 'users.forgotPassword',
+ {
+ authRequired: false,
+ body: ajv.compile<{ email: string }>({
+ type: 'object',
+ properties: {
+ email: { type: 'string' },
+ },
+ required: ['email'],
+ additionalProperties: false,
+ }),
+ response: {
+ 200: ajv.compile({
+ type: 'object',
+ properties: {
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ },
+ },
+ async function action() {
const isPasswordResetEnabled = settings.get('Accounts_PasswordReset');
if (!isPasswordResetEnabled) {
return API.v1.failure('Password reset is not enabled');
}
- const { email } = this.bodyParams;
- if (!email) {
- return API.v1.failure("The 'email' param is required");
- }
-
- await sendForgotPasswordEmail(email.toLowerCase());
+ await sendForgotPasswordEmail(this.bodyParams.email.toLowerCase());
return API.v1.success();
},
- },
-);
-
-API.v1.addRoute(
- 'users.getUsernameSuggestion',
- { authRequired: true, userWithoutUsername: true },
- {
- async get() {
+ )
+ .get(
+ 'users.getUsernameSuggestion',
+ {
+ authRequired: true,
+ userWithoutUsername: true,
+ response: {
+ 200: ajv.compile<{ result: string }>({
+ type: 'object',
+ properties: {
+ result: { type: 'string' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['result', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const result = await generateUsernameSuggestion(this.user);
+ if (!result) {
+ return API.v1.failure('No username suggestion found');
+ }
+
return API.v1.success({ result });
},
+ );
+
+const tokenNameBodySchema = ajv.compile<{ tokenName: string; bypassTwoFactor?: boolean }>({
+ type: 'object',
+ properties: {
+ tokenName: { type: 'string' },
+ bypassTwoFactor: { type: 'boolean', nullable: true },
},
-);
+ required: ['tokenName'],
+ additionalProperties: false,
+});
-API.v1.addRoute(
- 'users.checkUsernameAvailability',
- {
- authRequired: true,
- validateParams: isUsersCheckUsernameAvailabilityParamsGET,
+const tokenResponseSchema = ajv.compile<{ token: string }>({
+ type: 'object',
+ properties: {
+ token: { type: 'string' },
+ success: { type: 'boolean', enum: [true] },
},
- {
- async get() {
+ required: ['token', 'success'],
+ additionalProperties: false,
+});
+
+API.v1
+ .get(
+ 'users.checkUsernameAvailability',
+ {
+ authRequired: true,
+ query: isUsersCheckUsernameAvailabilityParamsGET,
+ response: {
+ 200: ajv.compile<{ result: boolean }>({
+ type: 'object',
+ properties: {
+ result: { type: 'boolean' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['result', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const { username } = this.queryParams;
const result = await checkUsernameAvailabilityWithValidation(this.userId, username);
return API.v1.success({ result });
},
- },
-);
-
-API.v1.addRoute(
- 'users.generatePersonalAccessToken',
- { authRequired: true, twoFactorRequired: true },
- {
- async post() {
+ )
+ .post(
+ 'users.generatePersonalAccessToken',
+ {
+ authRequired: true,
+ twoFactorRequired: true,
+ body: tokenNameBodySchema,
+ response: {
+ 200: tokenResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const { tokenName, bypassTwoFactor = false } = this.bodyParams;
- if (!tokenName) {
- return API.v1.failure("The 'tokenName' param is required");
- }
const token = await generatePersonalAccessTokenOfUser({ tokenName, userId: this.userId, bypassTwoFactor });
return API.v1.success({ token });
},
- },
-);
-
-API.v1.addRoute(
- 'users.regeneratePersonalAccessToken',
- { authRequired: true, twoFactorRequired: true },
- {
- async post() {
+ )
+ .post(
+ 'users.regeneratePersonalAccessToken',
+ {
+ authRequired: true,
+ twoFactorRequired: true,
+ body: ajv.compile<{ tokenName: string }>({
+ type: 'object',
+ properties: {
+ tokenName: { type: 'string' },
+ },
+ required: ['tokenName'],
+ additionalProperties: false,
+ }),
+ response: {
+ 200: tokenResponseSchema,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const { tokenName } = this.bodyParams;
- if (!tokenName) {
- return API.v1.failure("The 'tokenName' param is required");
- }
const token = await regeneratePersonalAccessTokenOfUser(tokenName, this.userId);
return API.v1.success({ token });
},
- },
-);
-
-API.v1.addRoute(
- 'users.getPersonalAccessTokens',
- { authRequired: true, permissionsRequired: ['create-personal-access-tokens'] },
- {
- async get() {
+ )
+ .get(
+ 'users.getPersonalAccessTokens',
+ {
+ authRequired: true,
+ permissionsRequired: ['create-personal-access-tokens'],
+ response: {
+ 200: ajv.compile<{ tokens: { name: string; createdAt: string; lastTokenPart: string; bypassTwoFactor: boolean }[] }>({
+ type: 'object',
+ properties: {
+ tokens: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ createdAt: { type: 'string' },
+ lastTokenPart: { type: 'string' },
+ bypassTwoFactor: { type: 'boolean' },
+ },
+ required: ['name', 'createdAt', 'lastTokenPart', 'bypassTwoFactor'],
+ additionalProperties: false,
+ },
+ },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['tokens', 'success'],
+ additionalProperties: false,
+ }),
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const user = (await Users.getLoginTokensByUserId(this.userId).toArray())[0] as unknown as IUser | undefined;
const isPersonalAccessToken = (loginToken: ILoginToken | IPersonalAccessToken): loginToken is IPersonalAccessToken =>
@@ -1002,30 +1314,45 @@ API.v1.addRoute(
})) || [],
});
},
- },
-);
-
-API.v1.addRoute(
- 'users.removePersonalAccessToken',
- { authRequired: true, twoFactorRequired: true },
- {
- async post() {
- const { tokenName } = this.bodyParams;
- if (!tokenName) {
- return API.v1.failure("The 'tokenName' param is required");
- }
- await removePersonalAccessTokenOfUser(tokenName, this.userId);
+ )
+ .post(
+ 'users.removePersonalAccessToken',
+ {
+ authRequired: true,
+ twoFactorRequired: true,
+ body: ajv.compile<{ tokenName: string }>({
+ type: 'object',
+ properties: {
+ tokenName: { type: 'string' },
+ },
+ required: ['tokenName'],
+ additionalProperties: false,
+ }),
+ response: {
+ 200: voidSuccessResponse,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ await removePersonalAccessTokenOfUser(this.bodyParams.tokenName, this.userId);
return API.v1.success();
},
- },
-);
+ );
-API.v1.addRoute(
- 'users.2fa.enableEmail',
- { authRequired: true },
- {
- async post() {
+API.v1
+ .post(
+ 'users.2fa.enableEmail',
+ {
+ authRequired: true,
+ response: {
+ 200: voidSuccessResponse,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const hasUnverifiedEmail = this.user.emails?.some((email) => !email.verified);
if (hasUnverifiedEmail) {
throw new MeteorError('error-invalid-user', 'You need to verify your emails before setting up 2FA');
@@ -1062,14 +1389,20 @@ API.v1.addRoute(
return API.v1.success();
},
- },
-);
-
-API.v1.addRoute(
- 'users.2fa.disableEmail',
- { authRequired: true, twoFactorRequired: true, twoFactorOptions: { disableRememberMe: true } },
- {
- async post() {
+ )
+ .post(
+ 'users.2fa.disableEmail',
+ {
+ authRequired: true,
+ twoFactorRequired: true,
+ twoFactorOptions: { disableRememberMe: true },
+ response: {
+ 200: voidSuccessResponse,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
await Users.disableEmail2FAByUserId(this.userId);
void notifyOnUserChangeAsync(async () => {
@@ -1087,116 +1420,160 @@ API.v1.addRoute(
return API.v1.success();
},
- },
-);
+ )
+ .post(
+ 'users.2fa.sendEmailCode',
+ {
+ body: ajv.compile<{ emailOrUsername: string }>({
+ type: 'object',
+ properties: {
+ emailOrUsername: { type: 'string' },
+ },
+ required: ['emailOrUsername'],
+ additionalProperties: false,
+ }),
+ response: {
+ 200: voidSuccessResponse,
+ 400: validateBadRequestErrorResponse,
+ },
+ },
+ async function action() {
+ const { emailOrUsername } = this.bodyParams;
-API.v1.addRoute('users.2fa.sendEmailCode', {
- async post() {
- const { emailOrUsername } = this.bodyParams;
+ const method = emailOrUsername.includes('@') ? 'findOneByEmailAddress' : 'findOneByUsername';
+ const userId = this.userId || (await Users[method](emailOrUsername, { projection: { _id: 1 } }))?._id;
- if (!emailOrUsername) {
- throw new Meteor.Error('error-parameter-required', 'emailOrUsername is required');
- }
+ if (!userId) {
+ // this.logger.error('[2fa] User was not found when requesting 2fa email code');
+ return API.v1.success();
+ }
+ const user = await getUserForCheck(userId);
+ if (!user) {
+ // this.logger.error('[2fa] User was not found when requesting 2fa email code');
+ return API.v1.success();
+ }
- const method = emailOrUsername.includes('@') ? 'findOneByEmailAddress' : 'findOneByUsername';
- const userId = this.userId || (await Users[method](emailOrUsername, { projection: { _id: 1 } }))?._id;
+ await emailCheck.sendEmailCode(user);
- if (!userId) {
- // this.logger.error('[2fa] User was not found when requesting 2fa email code');
- return API.v1.success();
- }
- const user = await getUserForCheck(userId);
- if (!user) {
- // this.logger.error('[2fa] User was not found when requesting 2fa email code');
return API.v1.success();
- }
-
- await emailCheck.sendEmailCode(user);
-
- return API.v1.success();
- },
-});
+ },
+ );
-API.v1.addRoute(
+API.v1.post(
'users.sendConfirmationEmail',
{
authRequired: true,
- validateParams: isUsersSendConfirmationEmailParamsPOST,
+ body: isUsersSendConfirmationEmailParamsPOST,
rateLimiterOptions: {
numRequestsAllowed: 1,
intervalTimeInMS: 60000,
},
+ response: {
+ 200: voidSuccessResponse,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
},
- {
- async post() {
- const { email } = this.bodyParams;
+ async function action() {
+ const { email } = this.bodyParams;
- if (await sendConfirmationEmail(email)) {
- return API.v1.success();
- }
- return API.v1.failure();
+ if (await sendConfirmationEmail(email)) {
+ return API.v1.success();
+ }
+ return API.v1.failure();
+ },
+);
+
+API.v1.get(
+ 'users.presence',
+ {
+ authRequired: true,
+ query: isUsersPresenceParamsGET,
+ response: {
+ 200: ajv.compile<{ users: object[]; full: boolean }>({
+ type: 'object',
+ properties: {
+ // user shape varies by projection and permissions
+ users: { type: 'array' },
+ full: { type: 'boolean' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['users', 'full', 'success'],
+ additionalProperties: false,
+ }),
+ 401: validateUnauthorizedErrorResponse,
},
},
-);
+ async function action() {
+ // if presence broadcast is disabled, return an empty array (all users are "offline")
+ if (settings.get('Presence_broadcast_disabled')) {
+ return API.v1.success({
+ users: [],
+ full: true,
+ });
+ }
-API.v1.addRoute(
- 'users.presence',
- { authRequired: true },
- {
- async get() {
- // if presence broadcast is disabled, return an empty array (all users are "offline")
- if (settings.get('Presence_broadcast_disabled')) {
- return API.v1.success({
- users: [],
- full: true,
- });
- }
+ const { from, ids } = this.queryParams;
- const { from, ids } = this.queryParams;
+ const options = {
+ projection: {
+ username: 1,
+ name: 1,
+ status: 1,
+ utcOffset: 1,
+ statusText: 1,
+ avatarETag: 1,
+ },
+ };
- const options = {
- projection: {
- username: 1,
- name: 1,
- status: 1,
- utcOffset: 1,
- statusText: 1,
- avatarETag: 1,
- },
- };
+ if (ids) {
+ return API.v1.success({
+ users: await Users.findNotOfflineByIds(Array.isArray(ids) ? ids : ids.split(','), options).toArray(),
+ full: false,
+ });
+ }
+
+ if (from) {
+ const ts = new Date(from);
+ const diff = (Date.now() - Number(ts)) / 1000 / 60;
- if (ids) {
+ if (diff < 10) {
return API.v1.success({
- users: await Users.findNotOfflineByIds(Array.isArray(ids) ? ids : ids.split(','), options).toArray(),
+ users: await Users.findNotIdUpdatedFrom(this.userId, ts, options).toArray(),
full: false,
});
}
+ }
- if (from) {
- const ts = new Date(from);
- const diff = (Date.now() - Number(ts)) / 1000 / 60;
-
- if (diff < 10) {
- return API.v1.success({
- users: await Users.findNotIdUpdatedFrom(this.userId, ts, options).toArray(),
- full: false,
- });
- }
- }
-
- return API.v1.success({
- users: await Users.findUsersNotOffline(options).toArray(),
- full: true,
- });
- },
+ return API.v1.success({
+ users: await Users.findUsersNotOffline(options).toArray(),
+ full: true,
+ });
},
);
-API.v1.addRoute(
- 'users.requestDataDownload',
- { authRequired: true },
- {
- async get() {
+API.v1
+ .get(
+ 'users.requestDataDownload',
+ {
+ authRequired: true,
+ query: isUsersRequestDataDownloadParamsGET,
+ response: {
+ 200: ajv.compile<{ requested: boolean; exportOperation: IExportOperation }>({
+ type: 'object',
+ properties: {
+ requested: { type: 'boolean' },
+ // IExportOperation has complex/dynamic shape not yet in typia
+ exportOperation: { type: 'object' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['requested', 'exportOperation', 'success'],
+ additionalProperties: false,
+ }),
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const { fullExport = false } = this.queryParams;
const result = (await requestDataDownload({ userData: this.user, fullExport: fullExport === 'true' })) as {
requested: boolean;
@@ -1208,14 +1585,26 @@ API.v1.addRoute(
exportOperation: result.exportOperation,
});
},
- },
-);
-
-API.v1.addRoute(
- 'users.logoutOtherClients',
- { authRequired: true },
- {
- async post() {
+ )
+ .post(
+ 'users.logoutOtherClients',
+ {
+ authRequired: true,
+ response: {
+ 200: ajv.compile<{ token: string; tokenExpires: string }>({
+ type: 'object',
+ properties: {
+ token: { type: 'string' },
+ tokenExpires: { type: 'string' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['token', 'tokenExpires', 'success'],
+ additionalProperties: false,
+ }),
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
const xAuthToken = this.request.headers.get('x-auth-token') as string;
if (!xAuthToken) {
@@ -1246,58 +1635,93 @@ API.v1.addRoute(
tokenExpires: tokenExpires?.toISOString() || '',
});
},
- },
-);
+ );
-API.v1.addRoute(
+API.v1.get(
'users.autocomplete',
- { authRequired: true, validateParams: isUsersAutocompleteProps },
{
- async get() {
- const { selector: selectorRaw } = this.queryParams;
+ authRequired: true,
+ query: isUsersAutocompleteProps,
+ response: {
+ 200: ajv.compile<{ items: object[] }>({
+ type: 'object',
+ properties: {
+ // autocomplete items shape varies by permissions
+ items: { type: 'array' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['items', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ const { selector: selectorRaw } = this.queryParams;
- const selector: { exceptions: Required['username'][]; conditions: Filter; term: string } = JSON.parse(selectorRaw);
+ const selector: { exceptions: Required['username'][]; conditions: Filter; term: string } = JSON.parse(selectorRaw);
- try {
- if (selector?.conditions) {
- const canViewFullInfo = await hasPermissionAsync(this.userId, 'view-full-other-user-info');
- const allowedFields = canViewFullInfo ? [...Object.keys(defaultFields), ...Object.keys(fullFields)] : Object.keys(defaultFields);
+ try {
+ if (selector?.conditions) {
+ const canViewFullInfo = await hasPermissionAsync(this.userId, 'view-full-other-user-info');
+ const allowedFields = canViewFullInfo ? [...Object.keys(defaultFields), ...Object.keys(fullFields)] : Object.keys(defaultFields);
- if (!isValidQuery(selector.conditions, allowedFields, ['$and', '$ne', '$exists'])) {
- throw new Error('error-invalid-query');
- }
+ if (!isValidQuery(selector.conditions, allowedFields, ['$and', '$ne', '$exists'])) {
+ throw new Error('error-invalid-query');
}
- } catch (e) {
- return API.v1.failure(e);
}
+ } catch (e) {
+ return API.v1.failure(e);
+ }
- return API.v1.success(
- await findUsersToAutocomplete({
- uid: this.userId,
- selector,
- }),
- );
- },
+ return API.v1.success(
+ await findUsersToAutocomplete({
+ uid: this.userId,
+ selector,
+ }),
+ );
},
);
-API.v1.addRoute(
- 'users.removeOtherTokens',
- { authRequired: true },
- {
- async post() {
- return API.v1.success(await Users.removeNonLoginTokensExcept(this.userId, this.token));
+API.v1
+ .post(
+ 'users.removeOtherTokens',
+ {
+ authRequired: true,
+ response: {
+ 200: voidSuccessResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
},
- },
-);
-
-API.v1.addRoute(
- 'users.resetE2EKey',
- { authRequired: true, twoFactorRequired: true, twoFactorOptions: { disableRememberMe: true } },
- {
- async post() {
+ async function action() {
+ await Users.removeNonLoginTokensExcept(this.userId, this.token);
+ return API.v1.success();
+ },
+ )
+ .post(
+ 'users.resetE2EKey',
+ {
+ authRequired: true,
+ twoFactorRequired: true,
+ twoFactorOptions: { disableRememberMe: true },
+ body: ajv.compile<{ userId?: string; username?: string; user?: string }>({
+ type: 'object',
+ properties: {
+ userId: { type: 'string' },
+ username: { type: 'string' },
+ user: { type: 'string' },
+ },
+ additionalProperties: false,
+ }),
+ response: {
+ 200: voidSuccessResponse,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
if ('userId' in this.bodyParams || 'username' in this.bodyParams || 'user' in this.bodyParams) {
- // reset other user keys
const user = await getUserFromParams(this.bodyParams);
if (!user) {
throw new Meteor.Error('error-invalid-user-id', 'Invalid user id');
@@ -1316,17 +1740,30 @@ API.v1.addRoute(
await resetUserE2EEncriptionKey(this.userId, false);
return API.v1.success();
},
- },
-);
-
-API.v1.addRoute(
- 'users.resetTOTP',
- { authRequired: true, twoFactorRequired: true, twoFactorOptions: { disableRememberMe: true } },
- {
- async post() {
- // // reset own keys
+ )
+ .post(
+ 'users.resetTOTP',
+ {
+ authRequired: true,
+ twoFactorRequired: true,
+ twoFactorOptions: { disableRememberMe: true },
+ body: ajv.compile<{ userId?: string; username?: string; user?: string }>({
+ type: 'object',
+ properties: {
+ userId: { type: 'string' },
+ username: { type: 'string' },
+ user: { type: 'string' },
+ },
+ additionalProperties: false,
+ }),
+ response: {
+ 200: voidSuccessResponse,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
if ('userId' in this.bodyParams || 'username' in this.bodyParams || 'user' in this.bodyParams) {
- // reset other user keys
if (!(await hasPermissionAsync(this.userId, 'edit-other-user-totp'))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed');
}
@@ -1347,14 +1784,29 @@ API.v1.addRoute(
await resetTOTP(this.userId, false);
return API.v1.success();
},
- },
-);
+ );
-API.v1.addRoute(
- 'users.listTeams',
- { authRequired: true, validateParams: isUsersListTeamsProps },
- {
- async get() {
+API.v1
+ .get(
+ 'users.listTeams',
+ {
+ authRequired: true,
+ query: isUsersListTeamsProps,
+ response: {
+ 200: ajv.compile<{ teams: unknown[] }>({
+ type: 'object',
+ properties: {
+ teams: { type: 'array' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['teams', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
check(
this.queryParams,
Match.ObjectIncluding({
@@ -1373,14 +1825,28 @@ API.v1.addRoute(
teams,
});
},
- },
-);
-
-API.v1.addRoute(
- 'users.logout',
- { authRequired: true, validateParams: isUserLogoutParamsPOST },
- {
- async post() {
+ )
+ .post(
+ 'users.logout',
+ {
+ authRequired: true,
+ body: isUserLogoutParamsPOST,
+ response: {
+ 200: ajv.compile<{ message: string }>({
+ type: 'object',
+ properties: {
+ message: { type: 'string' },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['message', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
+ },
+ async function action() {
const userId = this.bodyParams.userId || this.userId;
if (userId !== this.userId && !(await hasPermissionAsync(this.userId, 'logout-other-user'))) {
@@ -1400,14 +1866,33 @@ API.v1.addRoute(
message: `User ${userId} has been logged out!`,
});
},
- },
-);
+ );
-API.v1.addRoute(
- 'users.getPresence',
- { authRequired: true },
- {
- async get() {
+const statusType = { type: 'string', enum: ['online', 'offline', 'away', 'busy'] } as const;
+
+API.v1
+ .get(
+ 'users.getPresence',
+ {
+ authRequired: true,
+ query: isUsersGetPresenceParamsGET,
+ response: {
+ 200: ajv.compile<{ presence: UserStatus; connectionStatus?: string; lastLogin?: Date }>({
+ type: 'object',
+ properties: {
+ presence: statusType,
+ connectionStatus: { type: 'string', nullable: true },
+ lastLogin: { type: 'string', nullable: true },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['presence', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
if (isUserFromParams(this.queryParams, this.userId, this.user)) {
const user = await Users.findOneById(this.userId);
return API.v1.success({
@@ -1423,20 +1908,40 @@ API.v1.addRoute(
presence: user.status || ('offline' as UserStatus),
});
},
- },
-);
-
-API.v1.addRoute(
- 'users.setStatus',
- {
- authRequired: true,
- rateLimiterOptions: {
- numRequestsAllowed: 5,
- intervalTimeInMS: 60000,
+ )
+ .post(
+ 'users.setStatus',
+ {
+ authRequired: true,
+ rateLimiterOptions: {
+ numRequestsAllowed: 5,
+ intervalTimeInMS: 60000,
+ },
+ body: ajv.compile<{
+ status?: UserStatus;
+ message?: string;
+ userId?: string;
+ username?: string;
+ user?: string;
+ }>({
+ type: 'object',
+ properties: {
+ status: { type: 'string', enum: ['online', 'away', 'offline', 'busy'] },
+ message: { type: 'string', nullable: true },
+ userId: { type: 'string' },
+ username: { type: 'string' },
+ user: { type: 'string' },
+ },
+ additionalProperties: false,
+ }),
+ response: {
+ 200: voidSuccessResponse,
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateForbiddenErrorResponse,
+ },
},
- },
- {
- async post() {
+ async function action() {
check(
this.bodyParams,
Match.OneOf(
@@ -1514,27 +2019,35 @@ API.v1.addRoute(
return API.v1.success();
},
- },
-);
-
-// status: 'online' | 'offline' | 'away' | 'busy';
-// message?: string;
-// _id: string;
-// connectionStatus?: 'online' | 'offline' | 'away' | 'busy';
-// };
-
-API.v1.addRoute(
- 'users.getStatus',
- { authRequired: true },
- {
- async get() {
+ )
+ .get(
+ 'users.getStatus',
+ {
+ authRequired: true,
+ query: isUsersGetStatusParamsGET,
+ response: {
+ 200: ajv.compile<{ _id: string; status: string; connectionStatus?: string }>({
+ type: 'object',
+ properties: {
+ _id: { type: 'string' },
+ status: statusType,
+ connectionStatus: { type: 'string', nullable: true },
+ success: { type: 'boolean', enum: [true] },
+ },
+ required: ['_id', 'status', 'success'],
+ additionalProperties: false,
+ }),
+ 400: validateBadRequestErrorResponse,
+ 401: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
if (isUserFromParams(this.queryParams, this.userId, this.user)) {
- const user: IUser | null = await Users.findOneById(this.userId);
return API.v1.success({
- _id: user?._id,
+ _id: this.userId,
// message: user.statusText,
- connectionStatus: (user?.statusConnection || 'offline') as 'online' | 'offline' | 'away' | 'busy',
- status: (user?.status || 'offline') as 'online' | 'offline' | 'away' | 'busy',
+ connectionStatus: (this.user.statusConnection || 'offline') as 'online' | 'offline' | 'away' | 'busy',
+ status: (this.user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy',
});
}
@@ -1546,8 +2059,7 @@ API.v1.addRoute(
status: (user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy',
});
},
- },
-);
+ );
settings.watch('Rate_Limiter_Limit_RegisterUser', (value) => {
const userRegisterRoute = '/api/v1/users.registerpost';
diff --git a/apps/meteor/app/apps/server/bridges/http.ts b/apps/meteor/app/apps/server/bridges/http.ts
index aab0d56d301f0..cd297009c4afc 100644
--- a/apps/meteor/app/apps/server/bridges/http.ts
+++ b/apps/meteor/app/apps/server/bridges/http.ts
@@ -3,6 +3,7 @@ import type { IHttpResponse } from '@rocket.chat/apps-engine/definition/accessor
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';
import { settings } from '../../../settings/server';
@@ -72,7 +73,7 @@ export class AppHttpBridge extends HttpBridge {
// end comptability with old HTTP.call API
- this.orch.debugLog(`The App ${info.appId} is requesting from the outter webs:`, info);
+ this.orch.debugLog({ msg: `The App ${info.appId} is requesting from the outter webs:`, info: { ...info, url: censorUrl(info.url) } });
const shouldIgnoreSsrf = request.ssrfValidation !== true;
const fetchOptions: ExtendedFetchOptions = {
diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts
index 6952ac6f5457c..c73bbda1d3580 100644
--- a/apps/meteor/app/apps/server/bridges/livechat.ts
+++ b/apps/meteor/app/apps/server/bridges/livechat.ts
@@ -1,6 +1,13 @@
import type { IAppServerOrchestrator, IAppsLivechatMessage, IAppsMessage } from '@rocket.chat/apps';
import type { IExtraRoomParams } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator';
-import type { IVisitor, ILivechatRoom, ILivechatTransferData, IDepartment } from '@rocket.chat/apps-engine/definition/livechat';
+import type {
+ IVisitorExternalIdentifier,
+ IVisitor,
+ ILivechatRoom,
+ ILivechatTransferData,
+ IDepartment,
+ ResolveVisitorContactData,
+} 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';
@@ -16,6 +23,7 @@ import { setCustomFields } from '../../../livechat/server/lib/custom-fields';
import { getRoomMessages } from '../../../livechat/server/lib/getRoomMessages';
import type { ILivechatMessage } from '../../../livechat/server/lib/localTypes';
import { updateMessage, sendMessage } from '../../../livechat/server/lib/messages';
+import { resolveVisitor } from '../../../livechat/server/lib/resolveVisitor';
import { createRoom } from '../../../livechat/server/lib/rooms';
import { online } from '../../../livechat/server/lib/service-status';
import { transfer } from '../../../livechat/server/lib/transfer';
@@ -198,6 +206,10 @@ export class AppLivechatBridge extends LivechatBridge {
return Promise.all(result.map((room) => this.orch.getConverters()?.get('rooms').convertRoom(room) as Promise));
}
+ /**
+ * @deprecated Use `createAndReturnVisitor` instead.
+ * Note: This method does not support `externalIds`.
+ */
protected async createVisitor(visitor: IVisitor, appId: string): Promise {
this.orch.debugLog(`The App ${appId} is creating a livechat visitor.`);
@@ -214,6 +226,7 @@ export class AppLivechatBridge extends LivechatBridge {
const livechatVisitor = await registerGuest(registerData, {
shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'),
+ shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'),
});
if (!livechatVisitor) {
@@ -226,6 +239,9 @@ export class AppLivechatBridge extends LivechatBridge {
protected async createAndReturnVisitor(visitor: IVisitor, appId: string): Promise {
this.orch.debugLog(`The App ${appId} is creating a livechat visitor.`);
+ // Add appId to each externalId entry
+ const externalIds = visitor.externalIds?.map((entry) => ({ ...entry, appId }));
+
const registerData = {
department: visitor.department,
username: visitor.username,
@@ -235,10 +251,12 @@ export class AppLivechatBridge extends LivechatBridge {
id: visitor.id,
...(visitor.phone?.length && { phone: { number: visitor.phone[0].phoneNumber } }),
...(visitor.visitorEmails?.length && { email: visitor.visitorEmails[0].address }),
+ ...(externalIds?.length && { externalIds }),
};
const livechatVisitor = await registerGuest(registerData, {
shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'),
+ shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'),
});
return this.orch.getConverters()?.get('visitors').convertVisitor(livechatVisitor);
@@ -335,6 +353,34 @@ export class AppLivechatBridge extends LivechatBridge {
.convertVisitor(await LivechatVisitors.findOneVisitorByPhone(phoneNumber));
}
+ protected async resolveVisitor(
+ externalId: Omit,
+ contactData: ResolveVisitorContactData | undefined,
+ appId: string,
+ ): Promise {
+ this.orch.debugLog(`The App ${appId} is resolving a livechat visitor by external ID.`);
+
+ const visitor = await resolveVisitor({
+ appId,
+ externalId,
+ contactData,
+ });
+
+ return this.orch.getConverters()?.get('visitors').convertVisitor(visitor);
+ }
+
+ protected async updateVisitorExternalId(
+ visitorId: string,
+ externalId: Omit,
+ appId: string,
+ ): Promise {
+ this.orch.debugLog(`The App ${appId} is updating externalId for visitor ${visitorId}.`);
+
+ const visitor = await LivechatVisitors.updateExternalIdById(visitorId, appId, externalId);
+
+ return this.orch.getConverters()?.get('visitors').convertVisitor(visitor);
+ }
+
protected async findDepartmentByIdOrName(value: string, appId: string): Promise {
this.orch.debugLog(`The App ${appId} is looking for livechat departments.`);
diff --git a/apps/meteor/app/apps/server/bridges/persistence.ts b/apps/meteor/app/apps/server/bridges/persistence.ts
index 857f6a561ed6f..e1dedd270ac6e 100644
--- a/apps/meteor/app/apps/server/bridges/persistence.ts
+++ b/apps/meteor/app/apps/server/bridges/persistence.ts
@@ -15,7 +15,7 @@ export class AppPersistenceBridge extends PersistenceBridge {
}
protected async create(data: object, appId: string): Promise {
- this.orch.debugLog(`The App ${appId} is storing a new object in their persistence.`, data);
+ this.orch.debugLog(`The App ${appId} is storing a new object in their persistence.`);
if (typeof data !== 'object') {
throw new Error('Attempted to store an invalid data type, it must be an object.');
@@ -28,11 +28,10 @@ export class AppPersistenceBridge extends PersistenceBridge {
}
protected async createWithAssociations(data: object, associations: Array, appId: string): Promise {
- this.orch.debugLog(
- `The App ${appId} is storing a new object in their persistence that is associated with some models.`,
- data,
+ this.orch.debugLog({
+ msg: `The App ${appId} is storing a new object in their persistence that is associated with some models.`,
associations,
- );
+ });
if (typeof data !== 'object') {
throw new Error('Attempted to store an invalid data type, it must be an object.');
@@ -53,7 +52,7 @@ export class AppPersistenceBridge extends PersistenceBridge {
}
protected async readByAssociations(associations: Array, appId: string): Promise> {
- this.orch.debugLog(`The App ${appId} is searching for records that are associated with the following:`, associations);
+ this.orch.debugLog({ msg: `The App ${appId} is searching for records that are associated with the following:`, associations });
const records = await this.orch
.getPersistenceModel()
@@ -84,7 +83,7 @@ export class AppPersistenceBridge extends PersistenceBridge {
associations: Array,
appId: string,
): Promise | undefined> {
- this.orch.debugLog(`The App ${appId} is removing records with the following associations:`, associations);
+ this.orch.debugLog({ msg: `The App ${appId} is removing records with the following associations:`, associations });
const query = {
appId,
@@ -105,7 +104,7 @@ export class AppPersistenceBridge extends PersistenceBridge {
}
protected async update(id: string, data: object, _upsert: boolean, appId: string): Promise {
- this.orch.debugLog(`The App ${appId} is updating the record "${id}" to:`, data);
+ this.orch.debugLog(`The App ${appId} is updating the record "${id}"`);
if (typeof data !== 'object') {
throw new Error('Attempted to store an invalid data type, it must be an object.');
@@ -120,7 +119,7 @@ export class AppPersistenceBridge extends PersistenceBridge {
upsert = true,
appId: string,
): Promise {
- this.orch.debugLog(`The App ${appId} is updating the record with association to data as follows:`, associations, data);
+ this.orch.debugLog({ msg: `The App ${appId} is updating the record with association to data as follows:`, associations });
if (typeof data !== 'object') {
throw new Error('Attempted to store an invalid data type, it must be an object.');
diff --git a/apps/meteor/app/apps/server/bridges/users.ts b/apps/meteor/app/apps/server/bridges/users.ts
index 3c27eab902521..43cd7f9ea89e1 100644
--- a/apps/meteor/app/apps/server/bridges/users.ts
+++ b/apps/meteor/app/apps/server/bridges/users.ts
@@ -9,6 +9,7 @@ import { Random } from '@rocket.chat/random';
import { checkUsernameAvailability } from '../../../lib/server/functions/checkUsernameAvailability';
import { deleteUser } from '../../../lib/server/functions/deleteUser';
import { getUserCreatedByApp } from '../../../lib/server/functions/getUserCreatedByApp';
+import { setStatusText } from '../../../lib/server/functions/setStatusText';
import { setUserActiveStatus } from '../../../lib/server/functions/setUserActiveStatus';
import { setUserAvatar } from '../../../lib/server/functions/setUserAvatar';
import { notifyOnUserChange, notifyOnUserChangeById } from '../../../lib/server/lib/notifyListener';
@@ -127,20 +128,31 @@ export class AppUserBridge extends UserBridge {
throw new Error('User not provided');
}
- if (!Object.keys(fields).length) {
- return true;
- }
-
- const { status } = fields;
- delete fields.status;
+ const { status, statusText, ...updateFields } = fields;
if (status) {
- await Presence.setStatus(user.id, status as UserStatus, fields.statusText);
+ await Presence.setStatus(user.id, status as UserStatus, statusText);
+ } else if (typeof statusText === 'string') {
+ await setStatusText(
+ {
+ _id: user.id,
+ username: user.username,
+ name: user.name,
+ status: user.status as UserStatus,
+ roles: user.roles,
+ statusText: user.statusText,
+ },
+ statusText,
+ );
+ }
+
+ if (!Object.keys(updateFields).length) {
+ return true;
}
- await Users.updateOne({ _id: user.id }, { $set: fields as any });
+ await Users.updateOne({ _id: user.id }, { $set: updateFields as any });
- void notifyOnUserChange({ clientAction: 'updated', id: user.id, diff: fields });
+ void notifyOnUserChange({ clientAction: 'updated', id: user.id, diff: updateFields });
return true;
}
diff --git a/apps/meteor/app/apps/server/converters/visitors.js b/apps/meteor/app/apps/server/converters/visitors.js
index 00b8b3888ae74..62d38bc88cc3b 100644
--- a/apps/meteor/app/apps/server/converters/visitors.js
+++ b/apps/meteor/app/apps/server/converters/visitors.js
@@ -37,6 +37,7 @@ export class AppVisitorsConverter {
livechatData: 'livechatData',
status: 'status',
activity: 'activity',
+ externalIds: 'externalIds',
};
return transformMappedData(visitor, map);
@@ -57,6 +58,7 @@ export class AppVisitorsConverter {
status: visitor.status || 'online',
...(visitor.visitorEmails && { visitorEmails: visitor.visitorEmails }),
...(visitor.department && { department: visitor.department }),
+ ...(visitor.externalIds && { externalIds: visitor.externalIds }),
};
return Object.assign(newVisitor, visitor._unmappedProperties_);
diff --git a/apps/meteor/app/authentication/server/startup/index.js b/apps/meteor/app/authentication/server/startup/index.js
index 3e25537c38ed2..0d3e12e7c6e49 100644
--- a/apps/meteor/app/authentication/server/startup/index.js
+++ b/apps/meteor/app/authentication/server/startup/index.js
@@ -2,7 +2,7 @@ import { Apps, AppEvents } from '@rocket.chat/apps';
import { User } from '@rocket.chat/core-services';
import { Roles, Settings, Users } from '@rocket.chat/models';
import { escapeRegExp, escapeHTML } from '@rocket.chat/string-helpers';
-import { getLoginExpirationInDays } from '@rocket.chat/tools';
+import { getLoginExpirationInDays, removeEmpty } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { Match } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
@@ -214,11 +214,12 @@ const onCreateUserAsync = async function (options, user = {}) {
if (!options.skipBeforeCreateUserCallback) {
await beforeCreateUserCallback.run(options, user);
}
-
user.status = 'offline';
user.active = user.active !== undefined ? user.active : !settings.get('Accounts_ManuallyApproveNewUsers');
- user.inactiveReason = settings.get('Accounts_ManuallyApproveNewUsers') && !user.active ? 'pending_approval' : undefined;
+ if (settings.get('Accounts_ManuallyApproveNewUsers') && !user.active) {
+ user.inactiveReason = 'pending_approval';
+ }
if (!user.name) {
if (options.profile) {
@@ -235,8 +236,9 @@ const onCreateUserAsync = async function (options, user = {}) {
const verified = settings.get('Accounts_Verify_Email_For_External_Accounts');
for (const service of Object.values(user.services)) {
- if (!user.name) {
- user.name = service.name || service.username;
+ const suggestedName = service.name || service.username;
+ if (!user.name && suggestedName) {
+ user.name = suggestedName;
}
if (!user.emails && service.email) {
@@ -283,7 +285,7 @@ const onCreateUserAsync = async function (options, user = {}) {
throw new Meteor.Error(403, 'User validation failed');
}
- return user;
+ return removeEmpty(user);
};
Accounts.onCreateUser(function (...args) {
diff --git a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts
index 76b45d04e18fa..1c294c2023137 100644
--- a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts
+++ b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts
@@ -70,17 +70,6 @@ export const AutoTranslate = {
}
}
- if (attachment.description && attachment.translations && attachment.translations[language]) {
- attachment.translations.original = attachment.description;
-
- if (autoTranslateShowInverse) {
- attachment.description = attachment.translations.original;
- } else {
- attachment.description = attachment.translations[language];
- }
- }
-
- // @ts-expect-error - not sure what to do with this
if (attachment.attachments && attachment.attachments.length > 0) {
// @ts-expect-error - not sure what to do with this
attachment.attachments = this.translateAttachments(attachment.attachments, language);
diff --git a/apps/meteor/app/autotranslate/server/autotranslate.ts b/apps/meteor/app/autotranslate/server/autotranslate.ts
index 2f91e02463d58..3e04f6d39eb30 100644
--- a/apps/meteor/app/autotranslate/server/autotranslate.ts
+++ b/apps/meteor/app/autotranslate/server/autotranslate.ts
@@ -320,7 +320,7 @@ export abstract class AutoTranslate {
if (message.attachments && message.attachments.length > 0) {
setImmediate(async () => {
for (const [index, attachment] of message.attachments?.entries() ?? []) {
- if (attachment.description || attachment.text) {
+ if (attachment.text) {
// Removes the initial link `[ ](quoterl)` from quote message before translation
const translatedText = attachment?.text?.replace(/\[(.*?)\]\(.*?\)/g, '$1') || attachment?.text;
const attachmentMessage = { ...attachment, text: translatedText };
diff --git a/apps/meteor/app/autotranslate/server/deeplTranslate.ts b/apps/meteor/app/autotranslate/server/deeplTranslate.ts
index d76a7ea2e4901..35f73e1755da6 100644
--- a/apps/meteor/app/autotranslate/server/deeplTranslate.ts
+++ b/apps/meteor/app/autotranslate/server/deeplTranslate.ts
@@ -196,7 +196,7 @@ class DeeplAutoTranslate extends AutoTranslate {
params: {
auth_key: this.apiKey,
target_lang: language,
- text: attachment.description || attachment.text || '',
+ text: attachment.text || '',
},
});
if (!result.ok) {
diff --git a/apps/meteor/app/autotranslate/server/googleTranslate.ts b/apps/meteor/app/autotranslate/server/googleTranslate.ts
index 9667ae53c967a..53b9bb7c1d5ea 100644
--- a/apps/meteor/app/autotranslate/server/googleTranslate.ts
+++ b/apps/meteor/app/autotranslate/server/googleTranslate.ts
@@ -195,7 +195,7 @@ class GoogleAutoTranslate extends AutoTranslate {
key: this.apiKey,
target: language,
format: 'text',
- q: attachment.description || attachment.text || '',
+ q: attachment.text || '',
},
});
if (!result.ok) {
diff --git a/apps/meteor/app/autotranslate/server/msTranslate.ts b/apps/meteor/app/autotranslate/server/msTranslate.ts
index ddb345d3c895a..6508734a1c0da 100644
--- a/apps/meteor/app/autotranslate/server/msTranslate.ts
+++ b/apps/meteor/app/autotranslate/server/msTranslate.ts
@@ -192,7 +192,7 @@ class MsAutoTranslate extends AutoTranslate {
return this._translate(
[
{
- Text: attachment.description || attachment.text || '',
+ Text: attachment.text || '',
},
],
targetLanguages,
diff --git a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts
index e105d1962d895..17aceed6deb31 100644
--- a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts
+++ b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts
@@ -19,6 +19,7 @@ import { omit } from '../../../../lib/utils/omit';
import { callbacks } from '../../../../server/lib/callbacks';
import { SystemLogger } from '../../../../server/lib/logger/system';
import { canAccessRoomAsync } from '../../../authorization/server/functions/canAccessRoom';
+import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger';
import { executeSendMessage } from '../../../lib/server/methods/sendMessage';
import { FileUpload } from '../lib/FileUpload';
@@ -235,6 +236,7 @@ export const sendFileMessage = async (
Meteor.methods({
async sendFileMessage(roomId, _store, file, msgData = {}) {
+ methodDeprecationLogger.method('sendFileMessage', '9.0.0', '/v1/rooms.mediaConfirm/:rid/:fileId');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
diff --git a/apps/meteor/app/importer/server/classes/converters/MessageConverter.ts b/apps/meteor/app/importer/server/classes/converters/MessageConverter.ts
index cb4cf8047470d..e9e7295078c8d 100644
--- a/apps/meteor/app/importer/server/classes/converters/MessageConverter.ts
+++ b/apps/meteor/app/importer/server/classes/converters/MessageConverter.ts
@@ -1,5 +1,6 @@
import type { IImportMessageRecord, IMessage as IDBMessage, IImportMessage, IImportMessageReaction } from '@rocket.chat/core-typings';
import { Rooms } from '@rocket.chat/models';
+import { removeEmpty } from '@rocket.chat/tools';
import limax from 'limax';
import type { UserIdentification, MentionedChannel } from './ConverterCache';
@@ -84,7 +85,7 @@ export class MessageConverter extends RecordConverter {
const mentions = data.mentions && (await this.convertMessageMentions(data));
const channels = data.channels && (await this.convertMessageChannels(data));
- return {
+ return removeEmpty({
rid,
u: {
_id: creator._id,
@@ -110,7 +111,7 @@ export class MessageConverter extends RecordConverter {
alias: data.alias,
...(data._id ? { _id: data._id } : {}),
...(data.reactions ? { reactions: await this.convertMessageReactions(data.reactions) } : {}),
- };
+ });
}
protected async convertMessageChannels(message: IImportMessage): Promise {
diff --git a/apps/meteor/app/integrations/server/lib/compileIntegrationScript.ts b/apps/meteor/app/integrations/server/lib/compileIntegrationScript.ts
new file mode 100644
index 0000000000000..5e366cd1e18ea
--- /dev/null
+++ b/apps/meteor/app/integrations/server/lib/compileIntegrationScript.ts
@@ -0,0 +1,71 @@
+import vm from 'node:vm';
+
+import { transformSync } from '@babel/core';
+import presetEnv from '@babel/preset-env';
+
+/**
+ * Compile or validate a user-supplied integration script for storage in
+ * `scriptCompiled`.
+ *
+ * When `transpile` is `true` (the default, controlled by each integration's
+ * `skipTranspile` flag), the script is transpiled with `@babel/core +
+ * @babel/preset-env` — the historical behavior. When `false`, the script is
+ * validated with Node's built-in `vm.Script` and stored as-is, matching the
+ * 9.0.0 default where Babel transpilation is removed entirely.
+ *
+ * Integration scripts run inside `isolated-vm`, which embeds modern V8 and
+ * handles ES2023+ natively. The transpilation only exists to preserve the
+ * sloppy-mode semantics (implicit globals in class methods, `this` in nested
+ * functions, etc.) that early scripts relied on. Admins can flip
+ * `skipTranspile: true` per integration to test strict-mode compatibility
+ * before the 9.0.0 upgrade.
+ *
+ * Returns `{ script }` on success or `{ error }` with the same
+ * `{ name, message, stack }` shape persisted in `scriptError`.
+ */
+export function compileIntegrationScript(
+ script: string,
+ { transpile }: { transpile: boolean },
+): { script: string; error?: undefined } | { script?: undefined; error: Pick } {
+ if (!transpile) {
+ return validateOnly(script);
+ }
+
+ return transpileWithBabel(script);
+}
+
+function validateOnly(
+ script: string,
+): { script: string; error?: undefined } | { script?: undefined; error: Pick } {
+ try {
+ new vm.Script(`(function(){${script}})`);
+ return { script };
+ } catch (e) {
+ if (e instanceof SyntaxError) {
+ const { name, message, stack } = e;
+ return { error: { name, message, stack } };
+ }
+ throw e;
+ }
+}
+
+function transpileWithBabel(
+ script: string,
+): { script: string; error?: undefined } | { script?: undefined; error: Pick } {
+ try {
+ const result = transformSync(script, {
+ presets: [presetEnv],
+ compact: true,
+ minified: true,
+ comments: false,
+ });
+
+ return { script: result?.code ?? script };
+ } catch (e) {
+ if (e instanceof Error) {
+ const { name, message, stack } = e;
+ return { error: { name, message, stack } };
+ }
+ throw e;
+ }
+}
diff --git a/apps/meteor/app/integrations/server/lib/triggerHandler.ts b/apps/meteor/app/integrations/server/lib/triggerHandler.ts
index 09cc1f906ca09..e2b2fb84bb99d 100644
--- a/apps/meteor/app/integrations/server/lib/triggerHandler.ts
+++ b/apps/meteor/app/integrations/server/lib/triggerHandler.ts
@@ -35,7 +35,7 @@ type ArgumentsObject = {
user?: IUser;
};
type IntegrationData = {
- token: string;
+ token?: string;
bot: boolean;
trigger_word?: string;
channel_id?: string;
diff --git a/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts b/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts
index fabe8cae7688d..b4656f629d834 100644
--- a/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts
+++ b/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts
@@ -1,10 +1,9 @@
import type { IUser, INewOutgoingIntegration, IOutgoingIntegration, IUpdateOutgoingIntegration } from '@rocket.chat/core-typings';
import { Subscriptions, Users, Rooms } from '@rocket.chat/models';
-import { pick } from '@rocket.chat/tools';
-import { Babel } from 'meteor/babel-compiler';
import { Match } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
+import { compileIntegrationScript } from './compileIntegrationScript';
import { isScriptEngineFrozen } from './validateScriptEngine';
import { parseCSV } from '../../../../lib/utils/parseCSV';
import { hasPermissionAsync, hasAllPermissionAsync } from '../../../authorization/server/functions/hasPermission';
@@ -171,26 +170,20 @@ export const validateOutgoingIntegration = async function (
delete integrationData.triggerWords;
}
- // Only compile the script if it is enabled and using a sandbox that is not frozen
+ // Default to transpiling with Babel for backwards compatibility; integrations
+ // can opt-out per-record by setting `skipTranspile: true` (removed in 9.0.0).
+ const skipTranspile = integration.skipTranspile === true;
+ integrationData.skipTranspile = skipTranspile;
+
if (
!isScriptEngineFrozen(integrationData.scriptEngine) &&
integration.scriptEnabled === true &&
integration.script &&
integration.script.trim() !== ''
) {
- try {
- const babelOptions = Object.assign(Babel.getDefaultOptions({ runtime: false }), {
- compact: true,
- minified: true,
- comments: false,
- });
-
- integrationData.scriptCompiled = Babel.compile(integration.script, babelOptions).code;
- integrationData.scriptError = undefined;
- } catch (e) {
- integrationData.scriptCompiled = undefined;
- integrationData.scriptError = e instanceof Error ? pick(e, 'name', 'message', 'stack') : undefined;
- }
+ const { script, error } = compileIntegrationScript(integration.script, { transpile: !skipTranspile });
+ integrationData.scriptCompiled = script;
+ integrationData.scriptError = error;
}
if (typeof integration.runOnEdits !== 'undefined') {
diff --git a/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts b/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts
index a64e0c40c7afe..49116d1335ee8 100644
--- a/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts
+++ b/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts
@@ -3,14 +3,13 @@ import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Integrations, Subscriptions, Users, Rooms } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { removeEmpty } from '@rocket.chat/tools';
-import { Babel } from 'meteor/babel-compiler';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
-import _ from 'underscore';
import { addUserRolesAsync } from '../../../../../server/lib/roles/addUserRoles';
import { hasPermissionAsync, hasAllPermissionAsync } from '../../../../authorization/server/functions/hasPermission';
import { notifyOnIntegrationChanged } from '../../../../lib/server/lib/notifyListener';
+import { compileIntegrationScript } from '../../lib/compileIntegrationScript';
import { validateScriptEngine, isScriptEngineFrozen } from '../../lib/validateScriptEngine';
const validChannelChars = ['@', '#'];
@@ -91,9 +90,14 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn
});
}
+ // Default to transpiling with Babel for backwards compatibility; integrations
+ // can opt-out per-record by setting `skipTranspile: true` (removed in 9.0.0).
+ const skipTranspile = integration.skipTranspile === true;
+
const integrationData: IIncomingIntegration = {
...integration,
scriptEngine: integration.scriptEngine ?? 'isolated-vm',
+ skipTranspile,
type: 'webhook-incoming',
channel: channels,
overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled ?? false,
@@ -103,22 +107,19 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn
_createdBy: await Users.findOne({ _id: userId }, { projection: { username: 1 } }),
};
- // Only compile the script if it is enabled and using a sandbox that is not frozen
if (
!isScriptEngineFrozen(integrationData.scriptEngine) &&
integration.scriptEnabled === true &&
integration.script &&
integration.script.trim() !== ''
) {
- try {
- let babelOptions = Babel.getDefaultOptions({ runtime: false });
- babelOptions = _.extend(babelOptions, { compact: true, minified: true, comments: false });
-
- integrationData.scriptCompiled = Babel.compile(integration.script, babelOptions).code;
- delete integrationData.scriptError;
- } catch (e) {
+ const { script, error } = compileIntegrationScript(integration.script, { transpile: !skipTranspile });
+ if (error) {
integrationData.scriptCompiled = undefined;
- integrationData.scriptError = e instanceof Error ? _.pick(e, 'name', 'message', 'stack') : undefined;
+ integrationData.scriptError = error;
+ } else {
+ integrationData.scriptCompiled = script;
+ delete integrationData.scriptError;
}
}
@@ -162,13 +163,14 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn
const { insertedId } = await Integrations.insertOne(strippedIntegrationData);
- if (insertedId) {
- void notifyOnIntegrationChanged({ ...integrationData, _id: insertedId }, 'inserted');
- }
+ const integrationStored = await Integrations.findOne({ _id: insertedId });
- integrationData._id = insertedId;
+ if (!integrationStored) {
+ throw new Error('Error inserting integration');
+ }
+ void notifyOnIntegrationChanged({ ...integrationStored, _id: insertedId }, 'inserted');
- return integrationData;
+ return integrationStored as IIncomingIntegration;
};
Meteor.methods({
diff --git a/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts b/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts
index 9e63ce5473b86..894b5b31bd405 100644
--- a/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts
+++ b/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts
@@ -2,13 +2,12 @@ import type { IIntegration, INewIncomingIntegration, IUpdateIncomingIntegration
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Integrations, Subscriptions, Users, Rooms } from '@rocket.chat/models';
import { wrapExceptions } from '@rocket.chat/tools';
-import { Babel } from 'meteor/babel-compiler';
import { Meteor } from 'meteor/meteor';
-import _ from 'underscore';
import { addUserRolesAsync } from '../../../../../server/lib/roles/addUserRoles';
import { hasAllPermissionAsync, hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission';
import { notifyOnIntegrationChanged } from '../../../../lib/server/lib/notifyListener';
+import { compileIntegrationScript } from '../../lib/compileIntegrationScript';
import { isScriptEngineFrozen, validateScriptEngine } from '../../lib/validateScriptEngine';
const validChannelChars = ['@', '#'];
@@ -84,44 +83,28 @@ export const updateIncomingIntegration = async (
const isFrozen = isScriptEngineFrozen(scriptEngine);
- if (!isFrozen) {
- let scriptCompiled: string | undefined;
- let scriptError: Pick | undefined;
-
- if (integration.scriptEnabled === true && integration.script && integration.script.trim() !== '') {
- try {
- let babelOptions = Babel.getDefaultOptions({ runtime: false });
- babelOptions = _.extend(babelOptions, { compact: true, minified: true, comments: false });
-
- scriptCompiled = Babel.compile(integration.script, babelOptions).code;
- scriptError = undefined;
- await Integrations.updateOne(
- { _id: integrationId },
- {
- $set: {
- scriptCompiled,
- },
- $unset: { scriptError: 1 as const },
- },
- );
- } catch (e) {
- scriptCompiled = undefined;
- if (e instanceof Error) {
- const { name, message, stack } = e;
- scriptError = { name, message, stack };
- }
- await Integrations.updateOne(
- { _id: integrationId },
- {
- $set: {
- scriptError,
- },
- $unset: {
- scriptCompiled: 1 as const,
- },
- },
- );
- }
+ // Default to transpiling with Babel for backwards compatibility; integrations
+ // can opt-out per-record by setting `skipTranspile: true` (removed in 9.0.0).
+ const skipTranspile = integration.skipTranspile === true;
+
+ if (!isFrozen && integration.scriptEnabled === true && integration.script && integration.script.trim() !== '') {
+ const { script, error } = compileIntegrationScript(integration.script, { transpile: !skipTranspile });
+ if (error) {
+ await Integrations.updateOne(
+ { _id: integrationId },
+ {
+ $set: { scriptError: error, skipTranspile },
+ $unset: { scriptCompiled: 1 as const },
+ },
+ );
+ } else {
+ await Integrations.updateOne(
+ { _id: integrationId },
+ {
+ $set: { scriptCompiled: script, skipTranspile },
+ $unset: { scriptError: 1 as const },
+ },
+ );
}
}
@@ -176,17 +159,18 @@ export const updateIncomingIntegration = async (
$set: {
enabled: integration.enabled,
name: integration.name,
- avatar: integration.avatar,
- emoji: integration.emoji,
- alias: integration.alias,
- channel: channels,
+ ...(typeof integration.avatar !== 'undefined' && { avatar: integration.avatar }),
+ ...(typeof integration.emoji !== 'undefined' && { emoji: integration.emoji }),
+ ...(typeof integration.alias !== 'undefined' && { alias: integration.alias }),
+ ...(channels && { channel: channels }),
...('username' in integration && { username: user.username, userId: user._id }),
...(isFrozen
? {}
: {
- script: integration.script,
+ ...(typeof integration.script !== 'undefined' && { script: integration.script }),
scriptEnabled: integration.scriptEnabled,
- scriptEngine,
+ ...(scriptEngine && { scriptEngine }),
+ skipTranspile,
}),
...(typeof integration.overrideDestinationChannelEnabled !== 'undefined' && {
overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled,
diff --git a/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts b/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts
index 29b150a60011a..aee22f252efab 100644
--- a/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts
+++ b/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts
@@ -87,6 +87,7 @@ export const updateOutgoingIntegration = async (
script: integration.script,
scriptEnabled: integration.scriptEnabled,
scriptEngine,
+ skipTranspile: integration.skipTranspile,
...(integration.scriptCompiled ? { scriptCompiled: integration.scriptCompiled } : { scriptError: integration.scriptError }),
}),
triggerWords: integration.triggerWords,
diff --git a/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts b/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts
index 2bea0914ee00f..1e3e3faeb15b5 100644
--- a/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts
+++ b/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts
@@ -1,6 +1,6 @@
import { api } from '@rocket.chat/core-services';
import type { IRoom } from '@rocket.chat/core-typings';
-import { Messages, Rooms, Subscriptions, ReadReceipts, Users } from '@rocket.chat/models';
+import { Messages, Rooms, Subscriptions, ReadReceipts, ReadReceiptsArchive } from '@rocket.chat/models';
import { deleteRoom } from './deleteRoom';
import { NOTIFICATION_ATTACHMENT_COLOR } from '../../../../lib/constants';
@@ -143,13 +143,9 @@ export async function cleanRoomHistory({
selectedMessageIds,
);
- if (!limit) {
- const uids = await Users.findByUsernames(fromUsers, { projection: { _id: 1 } })
- .map((user) => user._id)
- .toArray();
- await ReadReceipts.removeByIdPinnedTimestampLimitAndUsers(rid, excludePinned, ignoreDiscussion, ts, uids, ignoreThreads);
- } else if (selectedMessageIds) {
+ if (limit && selectedMessageIds) {
await ReadReceipts.removeByMessageIds(selectedMessageIds);
+ await ReadReceiptsArchive.removeByMessageIds(selectedMessageIds);
}
if (count) {
diff --git a/apps/meteor/app/lib/server/functions/deleteMessage.ts b/apps/meteor/app/lib/server/functions/deleteMessage.ts
index 24176cb529dd8..da3cb2e37eef8 100644
--- a/apps/meteor/app/lib/server/functions/deleteMessage.ts
+++ b/apps/meteor/app/lib/server/functions/deleteMessage.ts
@@ -1,7 +1,7 @@
import { AppEvents, Apps } from '@rocket.chat/apps';
import { api, Message } from '@rocket.chat/core-services';
import { isThreadMessage, type AtLeast, type IMessage, type IRoom, type IThreadMessage, type IUser } from '@rocket.chat/core-typings';
-import { Messages, Rooms, Uploads, Users, ReadReceipts, Subscriptions } from '@rocket.chat/models';
+import { Messages, Rooms, Uploads, Users, ReadReceipts, ReadReceiptsArchive, Subscriptions } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { callbacks } from '../../../../server/lib/callbacks';
@@ -69,6 +69,7 @@ export async function deleteMessage(message: IMessage, user: IUser): Promise => ({
...getCustomFields(canViewAllInfo),
});
-export async function getFullUserDataByIdOrUsernameOrImportId(
+const findTargetUser = (type: string, value: string, opts: any) => {
+ if (type === 'importId') return Users.findOneByImportId(value, opts);
+ if (type === 'email') return Users.findOneByEmailAddress(value, opts);
+ return Users.findOneByIdOrUsername(value, opts);
+};
+
+export async function getFullUserDataByIdOrUsernameOrImportIdOrEmail(
userId: string,
searchValue: string,
- searchType: 'id' | 'username' | 'importId',
+ searchType: 'id' | 'username' | 'importId' | 'email',
): Promise {
- const caller = await Users.findOneById(userId, { projection: { username: 1, importIds: 1 } });
+ const caller = await Users.findOneById(userId, { projection: { username: 1, importIds: 1, emails: 1 } });
if (!caller) {
return null;
}
const myself =
(searchType === 'id' && searchValue === userId) ||
(searchType === 'username' && searchValue === caller.username) ||
- (searchType === 'importId' && caller.importIds?.includes(searchValue));
+ (searchType === 'importId' && caller.importIds?.includes(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 if the user has permission to view the import id
- if (searchType === 'importId' && !canViewAllInfo) {
+ // Only search for importId/email if the user has permission to view them
+ if (['importId', 'email'].includes(searchType) && !canViewAllInfo) {
return null;
}
@@ -104,9 +112,8 @@ export async function getFullUserDataByIdOrUsernameOrImportId(
},
};
- const user = await (searchType === 'importId'
- ? Users.findOneByImportId(searchValue, options)
- : Users.findOneByIdOrUsername(searchValue, options));
+ const user = await findTargetUser(searchType, searchValue, options);
+
if (!user) {
return null;
}
diff --git a/apps/meteor/app/lib/server/functions/notifications/email.js b/apps/meteor/app/lib/server/functions/notifications/email.js
index a27699bc1d111..7bdbd0d6e990a 100644
--- a/apps/meteor/app/lib/server/functions/notifications/email.js
+++ b/apps/meteor/app/lib/server/functions/notifications/email.js
@@ -77,13 +77,8 @@ export async function getEmailContent({ message, user, room }) {
}
if (hasFiles) {
- const attachments = message.attachments || [];
- const fileParts = files.map((file, index) => {
- let part = escapeHTML(file.name);
- if (attachments[index]?.description) {
- part += `
${escapeHTML(attachments[index].description)}`;
- }
- return part;
+ const fileParts = files.map((file) => {
+ return escapeHTML(file.name);
});
contentParts.push(fileParts.join('
'));
}
diff --git a/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts b/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts
index 18ae8ac0e7dc9..31a168a19240a 100644
--- a/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts
+++ b/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts
@@ -1,5 +1,5 @@
import type { IRoom } from '@rocket.chat/core-typings';
-import { Messages, Rooms, Subscriptions, ReadReceipts, Team } from '@rocket.chat/models';
+import { Messages, Rooms, Subscriptions, ReadReceipts, ReadReceiptsArchive, Team } from '@rocket.chat/models';
import type { SubscribedRoomsForUserWithDetails } from './getRoomsWithSingleOwner';
import { addUserRolesAsync } from '../../../../server/lib/roles/addUserRoles';
@@ -36,7 +36,7 @@ const bulkRoomCleanUp = async (rids: string[]) => {
// no bulk deletion for files
await Promise.all(rids.map((rid) => FileUpload.removeFilesByRoomId(rid)));
- const [, , , deletedRoomIds] = await Promise.all([
+ const [, , , , deletedRoomIds] = await Promise.all([
Subscriptions.removeByRoomIds(rids, {
async onTrash(doc) {
void notifyOnSubscriptionChanged(doc, 'removed');
@@ -44,6 +44,7 @@ const bulkRoomCleanUp = async (rids: string[]) => {
}),
Messages.removeByRoomIds(rids),
ReadReceipts.removeByRoomIds(rids),
+ ReadReceiptsArchive.removeByRoomIds(rids),
bulkTeamCleanup(rids),
]);
diff --git a/apps/meteor/app/lib/server/functions/sendMessage.ts b/apps/meteor/app/lib/server/functions/sendMessage.ts
index 5500b1bdf9453..73fa409999182 100644
--- a/apps/meteor/app/lib/server/functions/sendMessage.ts
+++ b/apps/meteor/app/lib/server/functions/sendMessage.ts
@@ -13,9 +13,10 @@ import { afterSaveMessage } from '../lib/afterSaveMessage';
import { notifyOnRoomChangedById } from '../lib/notifyListener';
import { validateCustomMessageFields } from '../lib/validateCustomMessageFields';
-type SendMessageOptions = {
+export type SendMessageOptions = {
upsert?: boolean;
previewUrls?: string[];
+ skipNotifications?: boolean;
};
// TODO: most of the types here are wrong, but I don't want to change them now
@@ -289,7 +290,7 @@ export const sendMessage = async function (user: any, message: any, room: any, o
void Apps.self?.triggerEvent(messageEvent, message);
}
- await afterSaveMessage(message, room, user);
+ await afterSaveMessage(message, room, user, { options });
void notifyOnRoomChangedById(message.rid);
diff --git a/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts b/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts
index 8ebfe0c7449bc..b8fa5c86b3b1f 100644
--- a/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts
+++ b/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts
@@ -113,7 +113,7 @@ export async function setUserActiveStatus(
}
if (user.username) {
- const { modifiedCount } = await Subscriptions.setArchivedByUsername(user.username, !active);
+ const { modifiedCount } = await Subscriptions.setArchivedForDMsWithUsername(user.username, !active);
if (modifiedCount) {
void notifyOnSubscriptionChangedByNameAndRoomType({ t: 'd', name: user.username });
}
diff --git a/apps/meteor/app/lib/server/functions/unarchiveRoom.ts b/apps/meteor/app/lib/server/functions/unarchiveRoom.ts
index 699f9c3701b1c..353124a02037c 100644
--- a/apps/meteor/app/lib/server/functions/unarchiveRoom.ts
+++ b/apps/meteor/app/lib/server/functions/unarchiveRoom.ts
@@ -1,14 +1,40 @@
import { Message } from '@rocket.chat/core-services';
import type { IMessage } from '@rocket.chat/core-typings';
-import { Rooms, Subscriptions } from '@rocket.chat/models';
+import { Rooms, Subscriptions, Users } from '@rocket.chat/models';
import { notifyOnRoomChangedById, notifyOnSubscriptionChangedByRoomId } from '../lib/notifyListener';
+const BATCH_SIZE = 100_000;
+
+async function getActiveUserIds(userIds: string[]): Promise> {
+ const activeUserIds = new Set();
+
+ for (let i = 0; i < userIds.length; i += BATCH_SIZE) {
+ const batch = await Users.findActiveByIds(userIds.slice(i, i + BATCH_SIZE), { projection: { _id: 1 } }).toArray();
+ for (const u of batch) {
+ activeUserIds.add(u._id);
+ }
+ }
+
+ return activeUserIds;
+}
+
+async function unarchiveSubscriptionsByIds(ids: string[]): Promise {
+ for (let i = 0; i < ids.length; i += BATCH_SIZE) {
+ await Subscriptions.unarchiveByIds(ids.slice(i, i + BATCH_SIZE));
+ }
+}
+
export const unarchiveRoom = async function (rid: string, user: IMessage['u']): Promise {
await Rooms.unarchiveById(rid);
- const unarchiveResponse = await Subscriptions.unarchiveByRoomId(rid);
- if (unarchiveResponse.modifiedCount) {
+ const archivedSubs = await Subscriptions.findArchivedByRoomId(rid, { projection: { 'u._id': 1 } }).toArray();
+
+ if (archivedSubs.length > 0) {
+ const activeUserIds = await getActiveUserIds(archivedSubs.map((s) => s.u._id));
+ const idsToUnarchive = archivedSubs.filter((s) => activeUserIds.has(s.u._id)).map((s) => s._id);
+
+ await unarchiveSubscriptionsByIds(idsToUnarchive);
void notifyOnSubscriptionChangedByRoomId(rid);
}
diff --git a/apps/meteor/app/lib/server/functions/unarchiveUserSubscriptions.ts b/apps/meteor/app/lib/server/functions/unarchiveUserSubscriptions.ts
new file mode 100644
index 0000000000000..aafb4389506c9
--- /dev/null
+++ b/apps/meteor/app/lib/server/functions/unarchiveUserSubscriptions.ts
@@ -0,0 +1,40 @@
+import { Rooms, Subscriptions } from '@rocket.chat/models';
+
+const BATCH_SIZE = 100_000;
+
+async function getArchivedRoomIds(rids: string[]): Promise> {
+ const archivedRoomIds = new Set();
+
+ for (let i = 0; i < rids.length; i += BATCH_SIZE) {
+ const batch = await Rooms.findManyArchivedByRoomIds(rids.slice(i, i + BATCH_SIZE), { projection: { _id: 1 } }).toArray();
+ for (const r of batch) {
+ archivedRoomIds.add(r._id);
+ }
+ }
+
+ return archivedRoomIds;
+}
+
+async function unarchiveSubscriptionsByIds(ids: string[]): Promise {
+ for (let i = 0; i < ids.length; i += BATCH_SIZE) {
+ await Subscriptions.unarchiveByIds(ids.slice(i, i + BATCH_SIZE));
+ }
+}
+
+export const unarchiveUserSubscriptions = async (userId: string): Promise => {
+ const archivedSubs = await Subscriptions.findArchivedByUserId(userId, { projection: { rid: 1 } }).toArray();
+
+ if (!archivedSubs.length) {
+ return false;
+ }
+
+ const archivedRoomIds = await getArchivedRoomIds(archivedSubs.map((s) => s.rid));
+ const idsToUnarchive = archivedSubs.filter((s) => !archivedRoomIds.has(s.rid)).map((s) => s._id);
+
+ if (!idsToUnarchive.length) {
+ return false;
+ }
+
+ await unarchiveSubscriptionsByIds(idsToUnarchive);
+ return true;
+};
diff --git a/apps/meteor/app/lib/server/lib/afterSaveMessage.ts b/apps/meteor/app/lib/server/lib/afterSaveMessage.ts
index 3ab96e1ab7478..d1cf0e15927bf 100644
--- a/apps/meteor/app/lib/server/lib/afterSaveMessage.ts
+++ b/apps/meteor/app/lib/server/lib/afterSaveMessage.ts
@@ -4,10 +4,28 @@ import type { Updater } from '@rocket.chat/models';
import { Rooms } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
-
-export async function afterSaveMessage(message: IMessage, room: IRoom, user: IUser, roomUpdater?: Updater): Promise {
+import type { SendMessageOptions } from '../functions/sendMessage';
+
+export async function afterSaveMessage(
+ message: IMessage,
+ room: IRoom,
+ user: IUser,
+ {
+ roomUpdater,
+ options,
+ }: {
+ roomUpdater?: Updater;
+ options?: SendMessageOptions;
+ } = {},
+): Promise {
const updater = roomUpdater ?? Rooms.getUpdater();
- const data: IMessage = (await callbacks.run('afterSaveMessage', message, { room, user, roomUpdater: updater })) as unknown as IMessage;
+
+ const data: IMessage = (await callbacks.run('afterSaveMessage', message, {
+ room,
+ user,
+ roomUpdater: updater,
+ options,
+ })) as unknown as IMessage;
if (!roomUpdater && updater.hasChanges()) {
await Rooms.updateFromUpdater({ _id: room._id }, updater);
@@ -19,8 +37,21 @@ export async function afterSaveMessage(message: IMessage, room: IRoom, user: IUs
return data;
}
-export function afterSaveMessageAsync(message: IMessage, room: IRoom, user: IUser, roomUpdater: Updater = Rooms.getUpdater()): void {
- callbacks.runAsync('afterSaveMessage', message, { room, user, roomUpdater });
+export function afterSaveMessageAsync(
+ message: IMessage,
+ room: IRoom,
+ user: IUser,
+ {
+ roomUpdater: updater,
+ options,
+ }: {
+ roomUpdater?: Updater;
+ options?: SendMessageOptions;
+ } = {},
+): void {
+ const roomUpdater = updater ?? Rooms.getUpdater();
+
+ callbacks.runAsync('afterSaveMessage', message, { room, user, roomUpdater, options });
if (roomUpdater.hasChanges()) {
void Rooms.updateFromUpdater({ _id: room._id }, roomUpdater);
diff --git a/apps/meteor/app/lib/server/lib/afterUserActions.ts b/apps/meteor/app/lib/server/lib/afterUserActions.ts
new file mode 100644
index 0000000000000..cbc5410acefc9
--- /dev/null
+++ b/apps/meteor/app/lib/server/lib/afterUserActions.ts
@@ -0,0 +1,24 @@
+import type { IUser } from '@rocket.chat/core-typings';
+import { Subscriptions } from '@rocket.chat/models';
+
+import { notifyOnSubscriptionChangedByUserId } from './notifyListener';
+import { callbacks } from '../../../../server/lib/callbacks';
+import { unarchiveUserSubscriptions } from '../functions/unarchiveUserSubscriptions';
+
+const handleDeactivateUser = async (user: IUser): Promise => {
+ const { modifiedCount } = await Subscriptions.setArchivedByUserId(user._id, true);
+ if (modifiedCount) {
+ void notifyOnSubscriptionChangedByUserId(user._id);
+ }
+};
+
+const handleActivateUser = async (user: IUser): Promise => {
+ const unarchived = await unarchiveUserSubscriptions(user._id);
+ if (unarchived) {
+ void notifyOnSubscriptionChangedByUserId(user._id);
+ }
+};
+
+callbacks.add('afterDeactivateUser', handleDeactivateUser, callbacks.priority.LOW, 'subscription-archive-on-deactivate');
+
+callbacks.add('afterActivateUser', handleActivateUser, callbacks.priority.LOW, 'subscription-unarchive-on-activate');
diff --git a/apps/meteor/app/lib/server/lib/index.ts b/apps/meteor/app/lib/server/lib/index.ts
index 1794b9927aff4..be0fad1f89f41 100644
--- a/apps/meteor/app/lib/server/lib/index.ts
+++ b/apps/meteor/app/lib/server/lib/index.ts
@@ -5,6 +5,7 @@
for the *server* pieces of code which does include the shared
library files.
*/
+import './afterUserActions';
import './notifyUsersOnMessage';
export { sendNotification } from './sendNotificationsOnMessage';
diff --git a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts
index ae6255a5dfd18..498cef1624624 100644
--- a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts
+++ b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts
@@ -195,8 +195,6 @@ export const sendNotification = async ({
const firstAttachment = message.attachments?.length && message.attachments.shift();
if (firstAttachment) {
- firstAttachment.description =
- typeof firstAttachment.description === 'string' ? emojione.shortnameToUnicode(firstAttachment.description) : undefined;
firstAttachment.text = typeof firstAttachment.text === 'string' ? emojione.shortnameToUnicode(firstAttachment.text) : undefined;
}
@@ -417,7 +415,13 @@ settings.watch('Troubleshoot_Disable_Notifications', (value) => {
callbacks.add(
'afterSaveMessage',
- (message, { room }) => sendAllNotifications(message, room),
+ (message, { room, options }) => {
+ if (options?.skipNotifications) {
+ return message;
+ }
+
+ return sendAllNotifications(message, room);
+ },
callbacks.priority.LOW,
'sendNotificationsOnMessage',
);
diff --git a/apps/meteor/app/lib/server/methods/updateMessage.ts b/apps/meteor/app/lib/server/methods/updateMessage.ts
index 45ba42f25f000..833b4403c0eca 100644
--- a/apps/meteor/app/lib/server/methods/updateMessage.ts
+++ b/apps/meteor/app/lib/server/methods/updateMessage.ts
@@ -34,7 +34,7 @@ export async function executeUpdateMessage(
// IF the message has custom fields, always update
// Ideally, we'll compare the custom fields to check for change, but since we don't know the shape of
// custom fields, as it's user defined, we're gonna update
- const msgText = originalMessage?.attachments?.[0]?.description ?? originalMessage.msg;
+ const msgText = originalMessage.msg;
if (msgText === message.msg && !previewUrls && !message.customFields) {
return;
}
@@ -86,13 +86,6 @@ export async function executeUpdateMessage(
}
await canSendMessageAsync(message.rid, { uid: user._id, username: user.username ?? undefined, ...user });
- // It is possible to have an empty array as the attachments property, so ensure both things exist
- if (originalMessage.attachments && originalMessage.attachments.length > 0 && originalMessage.attachments[0].description !== undefined) {
- originalMessage.attachments[0].description = message.msg;
- message.attachments = originalMessage.attachments;
- message.msg = originalMessage.msg;
- }
-
message.u = originalMessage.u;
return updateMessage(message, user, originalMessage, previewUrls);
diff --git a/apps/meteor/app/livechat/imports/server/rest/rooms.ts b/apps/meteor/app/livechat/imports/server/rest/rooms.ts
index f7462495013bf..4fe1e8bb279f6 100644
--- a/apps/meteor/app/livechat/imports/server/rest/rooms.ts
+++ b/apps/meteor/app/livechat/imports/server/rest/rooms.ts
@@ -63,7 +63,7 @@ API.v1.addRoute(
agents,
roomName,
departmentId,
- ...(isBoolean(open) && { open: open === 'true' }),
+ ...(isBoolean(open) && { open: open === true || open === 'true' }),
createdAt: createdAtParam,
closedAt: closedAtParam,
tags,
diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.ts b/apps/meteor/app/livechat/imports/server/rest/sms.ts
index e0fdf587e60e3..533409d342ac2 100644
--- a/apps/meteor/app/livechat/imports/server/rest/sms.ts
+++ b/apps/meteor/app/livechat/imports/server/rest/sms.ts
@@ -73,7 +73,8 @@ const defineVisitor = async (smsNumber: string, targetDepartment?: string) => {
data.department = targetDepartment;
}
- const livechatVisitor = await registerGuest(data, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle') });
+ const livechatVisitor = await registerGuest(data, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'),
+ shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents')});
if (!livechatVisitor) {
throw new Meteor.Error('error-invalid-visitor', 'Invalid visitor');
diff --git a/apps/meteor/app/livechat/server/api/v1/message.ts b/apps/meteor/app/livechat/server/api/v1/message.ts
index 3e90c1df1efe5..8d5d99352c355 100644
--- a/apps/meteor/app/livechat/server/api/v1/message.ts
+++ b/apps/meteor/app/livechat/server/api/v1/message.ts
@@ -272,7 +272,10 @@ API.v1.addRoute(
guest.connectionData = normalizeHttpHeaderData(this.request.headers);
}
- visitor = await registerGuest(guest, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle') });
+ visitor = await registerGuest(guest, {
+ shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'),
+ shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'),
+ });
if (!visitor) {
throw new Error('error-livechat-visitor-registration');
}
diff --git a/apps/meteor/app/livechat/server/api/v1/visitor.ts b/apps/meteor/app/livechat/server/api/v1/visitor.ts
index 706260f80afba..ec4c93d4ff5cf 100644
--- a/apps/meteor/app/livechat/server/api/v1/visitor.ts
+++ b/apps/meteor/app/livechat/server/api/v1/visitor.ts
@@ -59,7 +59,10 @@ API.v1.addRoute(
connectionData: normalizeHttpHeaderData(this.request.headers),
};
- const visitor = await registerGuest(guest, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle') });
+ const visitor = await registerGuest(guest, {
+ shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'),
+ shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'),
+ });
if (!visitor) {
throw new Meteor.Error('error-livechat-visitor-registration', 'Error registering visitor', {
method: 'livechat/visitor',
diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts
index 7d0f81d02b2e7..89bdd382efd39 100644
--- a/apps/meteor/app/livechat/server/lib/Helper.ts
+++ b/apps/meteor/app/livechat/server/lib/Helper.ts
@@ -496,7 +496,12 @@ export const forwardRoomToAgent = async (room: IOmnichannelRoom, transferData: T
if (!agentId) {
throw new Error('error-invalid-agent');
}
- const user = await Users.findOneOnlineAgentById(agentId, settings.get('Livechat_enabled_when_agent_idle'));
+ const user = await Users.findOneOnlineAgentById(
+ agentId,
+ settings.get('Livechat_enabled_when_agent_idle'),
+ settings.get('Livechat_accept_chats_with_no_agents'),
+ {},
+ );
if (!user) {
logger.debug({
msg: 'Agent is offline. Cannot forward',
@@ -657,7 +662,12 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi
departmentId,
agentId,
});
- const user = await Users.findOneOnlineAgentById(agentId, settings.get('Livechat_enabled_when_agent_idle'));
+ const user = await Users.findOneOnlineAgentById(
+ agentId,
+ settings.get('Livechat_enabled_when_agent_idle'),
+ settings.get('Livechat_accept_chats_with_no_agents'),
+ {},
+ );
if (!user) {
throw new Error('error-user-is-offline');
}
diff --git a/apps/meteor/app/livechat/server/lib/QueueManager.ts b/apps/meteor/app/livechat/server/lib/QueueManager.ts
index c012a8a7d8a0b..858db7279981e 100644
--- a/apps/meteor/app/livechat/server/lib/QueueManager.ts
+++ b/apps/meteor/app/livechat/server/lib/QueueManager.ts
@@ -443,7 +443,12 @@ export class QueueManager {
let defaultAgent: SelectedAgent | undefined;
const isAgentAvailable = (username: string) =>
- Users.findOneOnlineAgentByUserList(username, { projection: { _id: 1 } }, settings.get('Livechat_enabled_when_agent_idle'));
+ Users.findOneOnlineAgentByUserList(
+ username,
+ { projection: { _id: 1 } },
+ settings.get('Livechat_enabled_when_agent_idle'),
+ settings.get('Livechat_accept_chats_with_no_agents'),
+ );
if (servedBy?.username && (await isAgentAvailable(servedBy.username))) {
defaultAgent = { agentId: servedBy._id, username: servedBy.username };
diff --git a/apps/meteor/app/livechat/server/lib/RoutingManager.ts b/apps/meteor/app/livechat/server/lib/RoutingManager.ts
index 8c6fb51c08b2d..581ecfe1f6d94 100644
--- a/apps/meteor/app/livechat/server/lib/RoutingManager.ts
+++ b/apps/meteor/app/livechat/server/lib/RoutingManager.ts
@@ -111,7 +111,12 @@ export const RoutingManager: Routing = {
if (
!agent ||
(agent.username &&
- !(await Users.findOneOnlineAgentByUserList(agent.username, {}, settings.get('Livechat_enabled_when_agent_idle'))) &&
+ !(await Users.findOneOnlineAgentByUserList(
+ agent.username,
+ {},
+ settings.get('Livechat_enabled_when_agent_idle'),
+ settings.get('Livechat_accept_chats_with_no_agents'),
+ )) &&
!(await allowAgentSkipQueue(agent)))
) {
logger.debug({ msg: 'Agent offline or invalid. Using routing method to get next agent', inquiryId: inquiry._id });
diff --git a/apps/meteor/app/livechat/server/lib/departmentsLib.ts b/apps/meteor/app/livechat/server/lib/departmentsLib.ts
index 3612ba48415a6..3b063672781c9 100644
--- a/apps/meteor/app/livechat/server/lib/departmentsLib.ts
+++ b/apps/meteor/app/livechat/server/lib/departmentsLib.ts
@@ -295,6 +295,7 @@ export async function checkOnlineForDepartment(departmentId: string) {
depUsers.map((agent) => agent.username),
{ projection: { _id: 1 } },
settings.get('Livechat_enabled_when_agent_idle'),
+ settings.get('Livechat_accept_chats_with_no_agents'),
);
return !!onlineForDep;
@@ -304,5 +305,9 @@ export async function getOnlineForDepartment(departmentId: string) {
const agents = await LivechatDepartmentAgents.findByDepartmentId(departmentId, { projection: { username: 1 } }).toArray();
const usernames = agents.map(({ username }) => username);
- return Users.findOnlineUserFromList([...new Set(usernames)], settings.get('Livechat_enabled_when_agent_idle'));
+ return Users.findOnlineUserFromList(
+ [...new Set(usernames)],
+ settings.get('Livechat_enabled_when_agent_idle'),
+ settings.get('Livechat_accept_chats_with_no_agents'),
+ );
}
diff --git a/apps/meteor/app/livechat/server/lib/guests.ts b/apps/meteor/app/livechat/server/lib/guests.ts
index f913ef83c05d6..228008e2d18b7 100644
--- a/apps/meteor/app/livechat/server/lib/guests.ts
+++ b/apps/meteor/app/livechat/server/lib/guests.ts
@@ -7,6 +7,7 @@ import {
LivechatRooms,
Messages,
ReadReceipts,
+ ReadReceiptsArchive,
Subscriptions,
LivechatContacts,
Users,
@@ -120,6 +121,7 @@ async function cleanGuestHistory(_id: string) {
FileUpload.removeFilesByRoomId(room._id),
Messages.removeByRoomId(room._id),
ReadReceipts.removeByRoomId(room._id),
+ ReadReceiptsArchive.removeByRoomId(room._id),
]);
}
diff --git a/apps/meteor/app/livechat/server/lib/resolveVisitor.ts b/apps/meteor/app/livechat/server/lib/resolveVisitor.ts
new file mode 100644
index 0000000000000..7275f7b4c2e6b
--- /dev/null
+++ b/apps/meteor/app/livechat/server/lib/resolveVisitor.ts
@@ -0,0 +1,23 @@
+import type { IVisitorExternalIdentifier, ILivechatVisitor } from '@rocket.chat/core-typings';
+import { LivechatVisitors } from '@rocket.chat/models';
+
+type ResolveVisitorContactData = { phone: string } | { email: string };
+
+type ResolveVisitorParams = {
+ appId: string;
+ externalId: Omit;
+ contactData?: ResolveVisitorContactData;
+};
+
+export async function resolveVisitor({ appId, externalId, contactData }: ResolveVisitorParams): Promise {
+ const visitorByExternalId = await LivechatVisitors.findOneByExternalId(externalId.entityId);
+ if (visitorByExternalId) {
+ return visitorByExternalId;
+ }
+
+ if (contactData && (('phone' in contactData && contactData.phone) || ('email' in contactData && contactData.email))) {
+ return LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId(contactData, appId, externalId);
+ }
+
+ return null;
+}
diff --git a/apps/meteor/app/livechat/server/lib/rooms.ts b/apps/meteor/app/livechat/server/lib/rooms.ts
index 03dfbb1dd704a..90755eb5bf002 100644
--- a/apps/meteor/app/livechat/server/lib/rooms.ts
+++ b/apps/meteor/app/livechat/server/lib/rooms.ts
@@ -20,6 +20,7 @@ import {
Subscriptions,
Users,
ReadReceipts,
+ ReadReceiptsArchive,
} from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
@@ -293,6 +294,7 @@ export async function removeOmnichannelRoom(rid: string) {
}),
LivechatInquiry.removeByRoomId(rid),
LivechatRooms.removeById(rid),
+ ReadReceiptsArchive.removeByRoomId(rid),
]);
if (result[3]?.status === 'fulfilled' && result[3].value?.deletedCount && inquiry) {
diff --git a/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts b/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts
index 25feaa8de1e11..1accf6347e3e8 100644
--- a/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts
+++ b/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts
@@ -35,10 +35,16 @@ class AutoSelection implements IRoutingMethod {
settings.get('Livechat_enabled_when_agent_idle'),
ignoreAgentId,
extraQuery,
+ settings.get('Livechat_accept_chats_with_no_agents'),
);
}
- return Users.getNextAgent(ignoreAgentId, extraQuery, settings.get('Livechat_enabled_when_agent_idle'));
+ return Users.getNextAgent(
+ ignoreAgentId,
+ extraQuery,
+ settings.get('Livechat_enabled_when_agent_idle'),
+ settings.get('Livechat_accept_chats_with_no_agents'),
+ );
}
}
diff --git a/apps/meteor/app/livechat/server/lib/routing/External.ts b/apps/meteor/app/livechat/server/lib/routing/External.ts
index b5aaad05472e1..98222c318c10b 100644
--- a/apps/meteor/app/livechat/server/lib/routing/External.ts
+++ b/apps/meteor/app/livechat/server/lib/routing/External.ts
@@ -61,6 +61,7 @@ class ExternalQueue implements IRoutingMethod {
result.username,
{},
settings.get('Livechat_enabled_when_agent_idle'),
+ settings.get('Livechat_accept_chats_with_no_agents'),
);
if (!agent?.username) {
diff --git a/apps/meteor/app/livechat/server/lib/sendTranscript.ts b/apps/meteor/app/livechat/server/lib/sendTranscript.ts
index 199275f6a516b..f52ac3f516710 100644
--- a/apps/meteor/app/livechat/server/lib/sendTranscript.ts
+++ b/apps/meteor/app/livechat/server/lib/sendTranscript.ts
@@ -108,7 +108,7 @@ export async function sendTranscript({
const messageType = MessageTypes.getType(message);
- let messageContent = messageType?.system
+ const messageContent = messageType?.system
? DOMPurify.sanitize(`
${messageType.text(i18n.cloneInstance({ interpolation: { escapeValue: false } }).t, message)}}`)
: escapeHtml(message.msg);
@@ -116,9 +116,6 @@ export async function sendTranscript({
let filesHTML = '';
if (message.attachments && message.attachments?.length > 0) {
- messageContent = message.attachments[0].description || '';
- escapeHtml(messageContent);
-
for await (const attachment of message.attachments) {
if (!isFileAttachment(attachment)) {
continue;
diff --git a/apps/meteor/app/livechat/server/lib/service-status.ts b/apps/meteor/app/livechat/server/lib/service-status.ts
index 471ab72558dda..94d1e06aeb64b 100644
--- a/apps/meteor/app/livechat/server/lib/service-status.ts
+++ b/apps/meteor/app/livechat/server/lib/service-status.ts
@@ -8,13 +8,21 @@ import { settings } from '../../../settings/server';
export async function getOnlineAgents(department?: string, agent?: SelectedAgent | null): Promise | undefined> {
if (agent?.agentId) {
- return Users.findOnlineAgents(agent.agentId, settings.get('Livechat_enabled_when_agent_idle'));
+ return Users.findOnlineAgents(
+ agent.agentId,
+ settings.get('Livechat_enabled_when_agent_idle'),
+ settings.get('Livechat_accept_chats_with_no_agents'),
+ );
}
if (department) {
return getOnlineForDepartment(department);
}
- return Users.findOnlineAgents(undefined, settings.get('Livechat_enabled_when_agent_idle'));
+ return Users.findOnlineAgents(
+ undefined,
+ settings.get('Livechat_enabled_when_agent_idle'),
+ settings.get('Livechat_accept_chats_with_no_agents'),
+ );
}
export async function online(department?: string, skipNoAgentSetting = false, skipFallbackCheck = false): Promise {
@@ -43,7 +51,11 @@ export async function online(department?: string, skipNoAgentSetting = false, sk
export async function checkOnlineAgents(department?: string, agent?: { agentId: string }, skipFallbackCheck = false): Promise {
if (agent?.agentId) {
- return Users.checkOnlineAgents(agent.agentId, settings.get('Livechat_enabled_when_agent_idle'));
+ return Users.checkOnlineAgents(
+ agent.agentId,
+ settings.get('Livechat_enabled_when_agent_idle'),
+ settings.get('Livechat_accept_chats_with_no_agents'),
+ );
}
if (department) {
@@ -62,7 +74,11 @@ export async function checkOnlineAgents(department?: string, agent?: { agentId:
return checkOnlineAgents(dep?.fallbackForwardDepartment);
}
- return Users.checkOnlineAgents(undefined, settings.get('Livechat_enabled_when_agent_idle'));
+ return Users.checkOnlineAgents(
+ undefined,
+ settings.get('Livechat_enabled_when_agent_idle'),
+ settings.get('Livechat_accept_chats_with_no_agents'),
+ );
}
async function countBotAgents(department?: string) {
diff --git a/apps/meteor/app/livechat/server/lib/takeInquiry.ts b/apps/meteor/app/livechat/server/lib/takeInquiry.ts
index 3bea195ceaca2..ae2298bea9504 100644
--- a/apps/meteor/app/livechat/server/lib/takeInquiry.ts
+++ b/apps/meteor/app/livechat/server/lib/takeInquiry.ts
@@ -26,12 +26,18 @@ export const takeInquiry = async (
});
}
- const user = await Users.findOneOnlineAgentById(userId, settings.get('Livechat_enabled_when_agent_idle'));
+ const user = await Users.findOneOnlineAgentById(
+ userId,
+ settings.get('Livechat_enabled_when_agent_idle'),
+ settings.get('Livechat_accept_chats_with_no_agents'),
+ {},
+ );
if (!user) {
throw new Meteor.Error('error-agent-status-service-offline', 'Agent status is offline or Omnichannel service is not active', {
method: 'livechat:takeInquiry',
...(process.env.TEST_MODE && {
Livechat_enabled_when_agent_idle: settings.get('Livechat_enabled_when_agent_idle'),
+ Livechat_accept_chats_with_no_agents: settings.get('Livechat_accept_chats_with_no_agents'),
user: await Users.findOneById(userId),
}),
});
diff --git a/apps/meteor/app/message-pin/server/pinMessage.ts b/apps/meteor/app/message-pin/server/pinMessage.ts
index 204868d9dcda3..58f295ec02991 100644
--- a/apps/meteor/app/message-pin/server/pinMessage.ts
+++ b/apps/meteor/app/message-pin/server/pinMessage.ts
@@ -3,7 +3,7 @@ import { Message } from '@rocket.chat/core-services';
import { isQuoteAttachment, isRegisterUser } from '@rocket.chat/core-typings';
import type { IMessage, MessageAttachment, MessageQuoteAttachment } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
-import { Messages, Rooms, Subscriptions, Users, ReadReceipts } from '@rocket.chat/models';
+import { Messages, Rooms, Subscriptions, Users } from '@rocket.chat/models';
import { isTruthy } from '@rocket.chat/tools';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
@@ -91,9 +91,6 @@ export async function pinMessage(message: IMessage, userId: string, pinnedAt?: D
originalMessage = await Message.beforeSave({ message: originalMessage, room, user: me });
await Messages.setPinnedByIdAndUserId(originalMessage._id, originalMessage.pinnedBy, originalMessage.pinned);
- if (settings.get('Message_Read_Receipt_Store_Users')) {
- await ReadReceipts.setPinnedByMessageId(originalMessage._id, originalMessage.pinned);
- }
if (isTheLastMessage(room, originalMessage)) {
await Rooms.setLastMessagePinned(room._id, originalMessage.pinnedBy, originalMessage.pinned);
}
@@ -119,7 +116,7 @@ export async function pinMessage(message: IMessage, userId: string, pinnedAt?: D
text: originalMessage.msg,
author_name: originalMessage.u.username,
author_icon: getUserAvatarURL(originalMessage.u.username),
- content: originalMessage.content,
+ ...(originalMessage.content && { content: originalMessage.content }),
ts: originalMessage.ts,
attachments: attachments.map(recursiveRemove),
},
@@ -136,7 +133,7 @@ export const unpinMessage = async (userId: string, message: IMessage) => {
}
let originalMessage = await Messages.findOneById(message._id);
- if (originalMessage == null || originalMessage._id == null) {
+ if (originalMessage?._id == null) {
throw new Meteor.Error('error-invalid-message', 'Message you are unpinning was not found', {
method: 'unpinMessage',
action: 'Message_pinning',
@@ -192,9 +189,6 @@ export const unpinMessage = async (userId: string, message: IMessage) => {
await Apps.self?.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, me, originalMessage.pinned);
await Messages.setPinnedByIdAndUserId(originalMessage._id, originalMessage.pinnedBy, originalMessage.pinned);
- if (settings.get('Message_Read_Receipt_Store_Users')) {
- await ReadReceipts.setPinnedByMessageId(originalMessage._id, originalMessage.pinned);
- }
void notifyOnMessageChange({
id: message._id,
});
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 51a6f4aa2faf3..cac4a5ae0376d 100644
--- a/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts
+++ b/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts
@@ -74,7 +74,7 @@ export class SAML {
case 'logout':
return this.processLogoutAction(req, res, service);
case 'sloRedirect':
- return this.processSLORedirectAction(req, res);
+ return this.processSLORedirectAction(req, res, service);
case 'authorize':
return this.processAuthorizeAction(res, service, samlObject);
case 'validate':
@@ -384,18 +384,58 @@ export class SAML {
await logOutUser(inResponseTo);
} finally {
res.writeHead(302, {
- Location: req.query.RelayState,
+ Location: Meteor.absoluteUrl(),
});
res.end();
}
});
}
- private static processSLORedirectAction(req: IIncomingMessage, res: ServerResponse): void {
+ private static processSLORedirectAction(req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions): void {
+ const { idpSLORedirectURL } = service;
+ const userRedirect = req.query.redirect as string;
+
+ if (!idpSLORedirectURL) {
+ res.writeHead(500);
+ res.end('SLO redirect not configured');
+ return;
+ }
+
+ if (!userRedirect || typeof userRedirect !== 'string') {
+ res.writeHead(400);
+ res.end('Missing redirect parameter');
+ return;
+ }
+
+ let configuredURL: URL;
+ let requestURL: URL;
+
+ try {
+ configuredURL = new URL(idpSLORedirectURL);
+ requestURL = new URL(userRedirect);
+ } catch {
+ res.writeHead(400);
+ res.end('Invalid URL format');
+ return;
+ }
+
+ if (configuredURL.origin !== requestURL.origin) {
+ res.writeHead(403);
+ res.end('Unauthorized redirect origin');
+ return;
+ }
+
+ const normalizePath = (p: string): string => p.replace(/\/+$/, '') || '/';
+ if (normalizePath(configuredURL.pathname) !== normalizePath(requestURL.pathname)) {
+ res.writeHead(403);
+ res.end('Unauthorized redirect path');
+ return;
+ }
+
res.writeHead(302, {
- // credentialToken here is the SAML LogOut Request that we'll send back to IDP
- Location: req.query.redirect,
+ Location: requestURL.toString(),
});
+
res.end();
}
diff --git a/apps/meteor/app/push/server/apn.ts b/apps/meteor/app/push/server/apn.ts
index e8732a9daae5f..d5d4415d61316 100644
--- a/apps/meteor/app/push/server/apn.ts
+++ b/apps/meteor/app/push/server/apn.ts
@@ -1,5 +1,5 @@
import apn from '@parse/node-apn';
-import type { IPushToken, RequiredField } from '@rocket.chat/core-typings';
+import type { RequiredField } from '@rocket.chat/core-typings';
import EJSON from 'ejson';
import type { PushOptions, PendingPushNotification } from './definition';
@@ -24,7 +24,7 @@ export const sendAPN = ({
}: {
userToken: string;
notification: PendingPushNotification & { topic: string };
- _removeToken: (token: IPushToken['token']) => void;
+ _removeToken: (token: string) => void;
}) => {
if (!apnConnection) {
throw new Error('Apn Connection not initialized.');
@@ -34,7 +34,15 @@ export const sendAPN = ({
const note = new apn.Notification();
- note.expiry = Math.floor(Date.now() / 1000) + 3600; // Expires 1 hour from now.
+ // Expires 1 hour from now, unless configured otherwise.
+ const expirationSeconds = notification.apn?.expirationSeconds ?? 3600;
+
+ if (notification.useVoipToken) {
+ note.pushType = 'voip';
+ }
+
+ note.expiry = Math.floor(Date.now() / 1000) + expirationSeconds;
+
if (notification.badge !== undefined) {
note.badge = notification.badge;
}
@@ -50,10 +58,16 @@ export const sendAPN = ({
// adds category support for iOS8 custom actions as described here:
// https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/
// RemoteNotificationsPG/Chapters/IPhoneOSClientImp.html#//apple_ref/doc/uid/TP40008194-CH103-SW36
- note.category = notification.apn?.category;
+ if (notification.apn?.category) {
+ note.category = notification.apn.category;
+ }
- note.body = notification.text;
- note.title = notification.title;
+ if (notification.text) {
+ note.body = notification.text;
+ }
+ if (notification.title) {
+ note.title = notification.title;
+ }
if (notification.notId != null) {
note.threadId = String(notification.notId);
@@ -62,7 +76,9 @@ export const sendAPN = ({
// Allow the user to set payload data
note.payload = notification.payload ? { ejson: EJSON.stringify(notification.payload) } : {};
- note.payload.messageFrom = notification.from;
+ if (notification.from) {
+ note.payload.messageFrom = notification.from;
+ }
note.priority = priority;
note.topic = notification.topic;
@@ -81,9 +97,7 @@ export const sendAPN = ({
msg: 'Removing APN token',
token: userToken,
});
- _removeToken({
- apn: userToken,
- });
+ _removeToken(userToken);
}
});
});
diff --git a/apps/meteor/app/push/server/definition.ts b/apps/meteor/app/push/server/definition.ts
index c849d06c11b88..d194f66664c68 100644
--- a/apps/meteor/app/push/server/definition.ts
+++ b/apps/meteor/app/push/server/definition.ts
@@ -18,14 +18,15 @@ export type PushOptions = {
};
export type PendingPushNotification = {
- from: string;
- title: string;
- text: string;
+ from?: string;
+ title?: string;
+ text?: string;
badge?: number;
sound?: string;
notId?: number;
apn?: {
category?: string;
+ expirationSeconds?: number;
};
gcm?: {
style?: string;
@@ -42,4 +43,5 @@ export type PendingPushNotification = {
priority?: number;
contentAvailable?: 1 | 0;
+ useVoipToken?: boolean;
};
diff --git a/apps/meteor/app/push/server/fcm.ts b/apps/meteor/app/push/server/fcm.ts
index 9a3529d02e1f4..afe365784ef03 100644
--- a/apps/meteor/app/push/server/fcm.ts
+++ b/apps/meteor/app/push/server/fcm.ts
@@ -9,8 +9,8 @@ import type { NativeNotificationParameters } from './push';
type FCMDataField = Record;
type FCMNotificationField = {
- title: string;
- body: string;
+ title?: string;
+ body?: string;
image?: string;
};
@@ -140,13 +140,13 @@ function getFCMMessagesFromPushData(userTokens: string[], notification: PendingP
// then we will create the notification field
const notificationField: FCMNotificationField = {
- title: notification.title,
- body: notification.text,
+ ...(notification.title && { title: notification.title }),
+ ...(notification.text && { body: notification.text }),
};
// then we will create the message
const message: FCMMessage = {
- notification: notificationField,
+ ...(Object.keys(notificationField).length && { notification: notificationField }),
data,
android: {
priority: 'HIGH',
@@ -185,7 +185,7 @@ export const sendFCM = function ({ userTokens, notification, _removeToken, optio
const removeToken = () => {
const { token } = fcmRequest.message;
- token && _removeToken({ gcm: token });
+ token && _removeToken(token);
};
const response = fetchWithRetry(url, removeToken, {
diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts
index 860900a92471c..63b33832c19ec 100644
--- a/apps/meteor/app/push/server/push.ts
+++ b/apps/meteor/app/push/server/push.ts
@@ -18,6 +18,7 @@ export const _matchToken = Match.OneOf({ apn: String }, { gcm: String });
const PUSH_TITLE_LIMIT = 65;
const PUSH_MESSAGE_BODY_LIMIT = 240;
+const PUSH_GATEWAY_MAX_RETRIES = 5;
type FCMCredentials = {
type: string;
@@ -78,9 +79,9 @@ export const isFCMCredentials = ajv.compile(FCMCredentialsValida
// This type must match the type defined in the push gateway
type GatewayNotification = {
uniqueId: string;
- from: string;
- title: string;
- text: string;
+ from?: string;
+ title?: string;
+ text?: string;
badge?: number;
sound?: string;
notId?: number;
@@ -95,6 +96,7 @@ type GatewayNotification = {
sound?: string;
notId?: number;
category?: string;
+ expirationSeconds?: number;
};
gcm?: {
from?: string;
@@ -123,8 +125,7 @@ type GatewayNotification = {
export type NativeNotificationParameters = {
userTokens: string | string[];
notification: PendingPushNotification;
- _replaceToken: (currentToken: IPushToken['token'], newToken: IPushToken['token']) => void;
- _removeToken: (token: IPushToken['token']) => void;
+ _removeToken: (token: string) => void;
options: RequiredField