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 = ` @@ -323,10 +326,14 @@ API.v1.addRoute( ` - .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; }; @@ -167,12 +168,10 @@ class PushClass { } } - private replaceToken(currentToken: IPushToken['token'], newToken: IPushToken['token']): void { - void PushToken.updateMany({ token: currentToken }, { $set: { token: newToken } }); - } - - private removeToken(token: IPushToken['token']): void { - void PushToken.deleteOne({ token }); + private removeToken(token: string): void { + void PushToken.removeOrUnsetByTokenString(token).catch((err) => { + logger.error({ msg: 'Failed to remove push token', err }); + }); } private shouldUseGateway(): boolean { @@ -188,10 +187,13 @@ class PushClass { logger.debug({ msg: 'send to token', token: app.token }); if ('apn' in app.token && app.token.apn) { - countApn.push(app._id); + const userToken = notification.useVoipToken ? app.voipToken : app.token.apn; + const topic = notification.useVoipToken ? `${app.appName}.voip` : app.appName; + // Send to APN - if (this.options.apn) { - sendAPN({ userToken: app.token.apn, notification: { topic: app.appName, ...notification }, _removeToken: this.removeToken }); + if (this.options.apn && userToken) { + countApn.push(app._id); + sendAPN({ userToken, notification: { topic, ...notification }, _removeToken: this.removeToken }); } } else if ('gcm' in app.token && app.token.gcm) { countGcm.push(app._id); @@ -210,7 +212,6 @@ class PushClass { sendFCM({ userTokens: app.token.gcm, notification, - _replaceToken: this.replaceToken, _removeToken: this.removeToken, options: sendGCMOptions as RequiredField, }); @@ -255,7 +256,7 @@ class PushClass { service: 'apn' | 'gcm', token: string, notification: Optional, - tries = 0, + retryOptions: { tries: number; maxRetries: number } = { tries: 0, maxRetries: PUSH_GATEWAY_MAX_RETRIES }, ): Promise { notification.uniqueId = this.options.uniqueId; @@ -275,16 +276,7 @@ class PushClass { if (result.status === 406) { logger.info({ msg: 'removing push token', token }); - await PushToken.deleteMany({ - $or: [ - { - 'token.apn': token, - }, - { - 'token.gcm': token, - }, - ], - }); + this.removeToken(token); return; } @@ -302,22 +294,24 @@ class PushClass { return; } + const { tries, maxRetries } = retryOptions; + logger.error({ msg: 'Error sending push to gateway', tries, err: response }); - if (tries <= 4) { + if (tries < maxRetries) { // [1, 2, 4, 8, 16] minutes (total 31) const ms = 60000 * Math.pow(2, tries); logger.log({ msg: 'Retrying push to gateway', tries: tries + 1, in: ms }); - setTimeout(() => this.sendGatewayPush(gateway, service, token, notification, tries + 1), ms); + setTimeout(() => this.sendGatewayPush(gateway, service, token, notification, { tries: tries + 1, maxRetries }), ms); } } private getGatewayNotificationData(notification: PendingPushNotification): Omit { - // Gateway currently accepts every attribute from the PendingPushNotification type, except for the priority + // Gateway currently accepts every attribute from the PendingPushNotification type, except for the priority and useVoipToken // If new attributes are added to the PendingPushNotification type, they'll need to be removed here as well. - const { priority: _priority, ...notifData } = notification; + const { priority: _priority, useVoipToken: _useVoipToken, ...notifData } = notification; return { ...notifData, @@ -335,35 +329,47 @@ class PushClass { } const gatewayNotification = this.getGatewayNotificationData(notification); + const retryOptions = { + tries: 0, + maxRetries: notification.useVoipToken ? 0 : PUSH_GATEWAY_MAX_RETRIES, + }; for (const gateway of this.options.gateways) { logger.debug({ msg: 'send to token', token: app.token }); if ('apn' in app.token && app.token.apn) { - countApn.push(app._id); - return this.sendGatewayPush(gateway, 'apn', app.token.apn, { topic: app.appName, ...gatewayNotification }); + const token = notification.useVoipToken ? app.voipToken : app.token.apn; + const topic = notification.useVoipToken ? `${app.appName}.voip` : app.appName; + + if (token) { + countApn.push(app._id); + return this.sendGatewayPush(gateway, 'apn', token, { topic, ...gatewayNotification }, retryOptions); + } } if ('gcm' in app.token && app.token.gcm) { countGcm.push(app._id); - return this.sendGatewayPush(gateway, 'gcm', app.token.gcm, gatewayNotification); + return this.sendGatewayPush(gateway, 'gcm', app.token.gcm, gatewayNotification, retryOptions); } } } - private async sendNotification(notification: PendingPushNotification): Promise<{ apn: string[]; gcm: string[] }> { + private async sendNotification( + notification: PendingPushNotification, + options: { skipTokenId?: IPushToken['_id'] } = {}, + ): Promise<{ apn: string[]; gcm: string[] }> { logger.debug({ msg: 'Sending notification', notification }); const countApn: string[] = []; const countGcm: string[] = []; - if (notification.from !== String(notification.from)) { + if (notification.from && notification.from !== String(notification.from)) { throw new Error('Push.send: option "from" not a string'); } - if (notification.title !== String(notification.title)) { + if (notification.title && notification.title !== String(notification.title)) { throw new Error('Push.send: option "title" not a string'); } - if (notification.text !== String(notification.text)) { + if (notification.text && notification.text !== String(notification.text)) { throw new Error('Push.send: option "text" not a string'); } @@ -373,12 +379,9 @@ class PushClass { userId: notification.userId, }); - const query = { - userId: notification.userId, - $or: [{ 'token.apn': { $exists: true } }, { 'token.gcm': { $exists: true } }], - }; - - const appTokens = PushToken.find(query); + const appTokens = options.skipTokenId + ? PushToken.findTokensByUserIdExceptId(notification.userId, options.skipTokenId) + : PushToken.findAllTokensByUserId(notification.userId); for await (const app of appTokens) { logger.debug({ msg: 'send to token', token: app.token }); @@ -427,9 +430,9 @@ class PushClass { private _validateDocument(notification: PendingPushNotification): void { // Check the general notification check(notification, { - from: String, - title: String, - text: String, + from: Match.Optional(String), + title: Match.Optional(String), + text: Match.Optional(String), sent: Match.Optional(Boolean), sending: Match.Optional(Match.Integer), badge: Match.Optional(Match.Integer), @@ -438,6 +441,7 @@ class PushClass { contentAvailable: Match.Optional(Match.Integer), apn: Match.Optional({ category: Match.Optional(String), + expirationSeconds: Match.Optional(Match.Integer), }), gcm: Match.Optional({ image: Match.Optional(String), @@ -448,6 +452,7 @@ class PushClass { createdAt: Date, createdBy: Match.OneOf(String, null), priority: Match.Optional(Match.Integer), + useVoipToken: Match.Optional(Boolean), }); if (!notification.userId) { @@ -470,15 +475,15 @@ class PushClass { createdBy: '', sent: false, sending: 0, - title: truncateString(options.title, PUSH_TITLE_LIMIT), - text: truncateString(options.text, PUSH_MESSAGE_BODY_LIMIT), + ...(options.title && { title: truncateString(options.title, PUSH_TITLE_LIMIT) }), + ...(options.text && { text: truncateString(options.text, PUSH_MESSAGE_BODY_LIMIT) }), - ...pick(options, 'from', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority'), + ...pick(options, 'from', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority', 'useVoipToken'), ...(this.hasApnOptions(options) ? { apn: { - ...pick(options.apn, 'category'), + ...pick(options.apn, 'category', 'expirationSeconds'), }, } : {}), @@ -495,7 +500,7 @@ class PushClass { this._validateDocument(notification); try { - await this.sendNotification(notification); + await this.sendNotification(notification, { skipTokenId: options.skipTokenId }); } catch (error: any) { logger.debug({ msg: 'Could not send notification to user', diff --git a/apps/meteor/app/reactions/server/setReaction.ts b/apps/meteor/app/reactions/server/setReaction.ts index 483b1fff8ed7a..1a6d8dfa537f6 100644 --- a/apps/meteor/app/reactions/server/setReaction.ts +++ b/apps/meteor/app/reactions/server/setReaction.ts @@ -119,8 +119,7 @@ export async function executeSetReaction( throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' }); } - const userAlreadyReacted = - message.reactions && Boolean(message.reactions[reaction]) && message.reactions[reaction].usernames.includes(user.username as string); + const userAlreadyReacted = Boolean(message.reactions?.[reaction]?.usernames?.includes(user.username as string)); // When shouldReact was not informed, toggle the reaction. if (shouldReact === undefined) { diff --git a/apps/meteor/app/settings/server/functions/validateSetting.ts b/apps/meteor/app/settings/server/functions/validateSetting.ts index 659bb12225c4d..d0ba5bbd8990f 100644 --- a/apps/meteor/app/settings/server/functions/validateSetting.ts +++ b/apps/meteor/app/settings/server/functions/validateSetting.ts @@ -1,4 +1,4 @@ -import type { ISetting } from '@rocket.chat/core-typings'; +import { isActionSettingWithEndpoint, type ISetting } from '@rocket.chat/core-typings'; export const validateSetting = (_id: T['_id'], type: T['type'], value: T['value'] | unknown): boolean => { switch (type) { @@ -7,6 +7,11 @@ export const validateSetting = (_id: T['_id'], type: T['type throw new Error(`Setting ${_id} is of type ${type} but got ${typeof value}`); } break; + case 'action': + if (typeof value !== 'string' && !isActionSettingWithEndpoint(value as never)) { + throw new Error(`Setting ${_id} is of type ${type} but got ${typeof value}`); + } + break; case 'string': case 'relativeUrl': case 'password': @@ -14,7 +19,6 @@ export const validateSetting = (_id: T['_id'], type: T['type case 'color': case 'font': case 'code': - case 'action': case 'roomPick': case 'group': if (typeof value !== 'string') { diff --git a/apps/meteor/app/slackbridge/server/RocketAdapter.ts b/apps/meteor/app/slackbridge/server/RocketAdapter.ts index 925bb4ef2dc4d..cdf4cd8085af9 100644 --- a/apps/meteor/app/slackbridge/server/RocketAdapter.ts +++ b/apps/meteor/app/slackbridge/server/RocketAdapter.ts @@ -203,14 +203,11 @@ export default class RocketAdapter { if (rocketMessage.file.name) { let fileName = rocketMessage.file.name; - let text = rocketMessage.msg; + const text = rocketMessage.msg; const attachment = this.getMessageAttachment(rocketMessage); if (attachment) { fileName = Meteor.absoluteUrl(attachment.title_link); - if (!text) { - text = attachment.description; - } } await slack.postMessage(slack.getSlackChannel(rocketMessage.rid), { ...rocketMessage, msg: `${text} ${fileName}` }); diff --git a/apps/meteor/app/slackbridge/server/SlackAdapter.ts b/apps/meteor/app/slackbridge/server/SlackAdapter.ts index c0eae9cfb3076..831ca8a67cbce 100644 --- a/apps/meteor/app/slackbridge/server/SlackAdapter.ts +++ b/apps/meteor/app/slackbridge/server/SlackAdapter.ts @@ -9,7 +9,7 @@ import https from 'https'; import url from 'url'; import { Message } from '@rocket.chat/core-services'; -import { Messages, Rooms, Users, ReadReceipts } from '@rocket.chat/models'; +import { Messages, Rooms, Users } from '@rocket.chat/models'; import { App as SlackApp } from '@slack/bolt'; import { RTMClient } from '@slack/rtm-api'; import { Meteor } from 'meteor/meteor'; @@ -1201,9 +1201,6 @@ export default class SlackAdapter { if (!isImporting && slackMessage.attachments[0].channel_id && slackMessage.attachments[0].ts) { const messageId = this.createSlackMessageId(slackMessage.attachments[0].ts, slackMessage.attachments[0].channel_id); await Messages.setPinnedByIdAndUserId(messageId, rocketMsgObj.u, true, new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000)); - if (settings.get('Message_Read_Receipt_Store_Users')) { - await ReadReceipts.setPinnedByMessageId(messageId, true); - } } return rocketMsgObj; @@ -1412,9 +1409,6 @@ export default class SlackAdapter { const messageId = this.createSlackMessageId(pin.message.ts, pin.channel); await Messages.setPinnedByIdAndUserId(messageId, msgObj.u, true, new Date(parseInt(pin.message.ts.split('.')[0]) * 1000)); - if (settings.get('Message_Read_Receipt_Store_Users')) { - await ReadReceipts.setPinnedByMessageId(messageId, true); - } } } } diff --git a/apps/meteor/app/theme/client/rocketchat.font.css b/apps/meteor/app/theme/client/rocketchat.font.css index 4db1230558f54..92da53d1e7162 100644 --- a/apps/meteor/app/theme/client/rocketchat.font.css +++ b/apps/meteor/app/theme/client/rocketchat.font.css @@ -3,11 +3,5 @@ font-weight: 400; font-style: normal; font-display: block; - src: url('/fonts/rocketchat.eot'); - src: - url('/fonts/rocketchat.eot?#iefix') format('embedded-opentype'), - url('/fonts/rocketchat.woff2') format('woff2'), - url('/fonts/rocketchat.woff') format('woff'), - url('/fonts/rocketchat.ttf') format('truetype'), - url('/fonts/rocketchat.svg#RocketChat') format('svg'); + src: url('/fonts/rocketchat.woff2') format('woff2'); } diff --git a/apps/meteor/app/threads/server/functions.ts b/apps/meteor/app/threads/server/functions.ts index a32d591ad238f..4c5db1ddf70b5 100644 --- a/apps/meteor/app/threads/server/functions.ts +++ b/apps/meteor/app/threads/server/functions.ts @@ -1,6 +1,6 @@ import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import { isEditedMessage } from '@rocket.chat/core-typings'; -import { Messages, Subscriptions, ReadReceipts, NotificationQueue } from '@rocket.chat/models'; +import { Messages, Subscriptions, NotificationQueue } from '@rocket.chat/models'; import { callbacks } from '../../../server/lib/callbacks'; import { @@ -41,10 +41,7 @@ export async function reply({ tmid }: { tmid?: string }, message: IMessage, pare // Notify message mentioned users and highlights const mentionedUsers = [...new Set([...mentionIds, ...highlightsUids])]; - const promises = [ - ReadReceipts.setAsThreadById(tmid), - Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, threadFollowersUids, tmid, notifyOptions), - ]; + const promises = [Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, threadFollowersUids, tmid, notifyOptions)]; if (mentionedUsers.length) { promises.push(Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, mentionedUsers, tmid, { userMention: true })); diff --git a/apps/meteor/app/threads/server/hooks/aftersavemessage.ts b/apps/meteor/app/threads/server/hooks/aftersavemessage.ts index 5a3eea24f47b3..fd63a30d64c69 100644 --- a/apps/meteor/app/threads/server/hooks/aftersavemessage.ts +++ b/apps/meteor/app/threads/server/hooks/aftersavemessage.ts @@ -4,6 +4,7 @@ import { Messages } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../../server/lib/callbacks'; +import type { SendMessageOptions } from '../../../lib/server/functions/sendMessage'; import { notifyOnMessageChange } from '../../../lib/server/lib/notifyListener'; import { updateThreadUsersSubscriptions, getMentions } from '../../../lib/server/lib/notifyUsersOnMessage'; import { sendMessageNotifications } from '../../../lib/server/lib/sendNotificationsOnMessage'; @@ -39,7 +40,7 @@ const notification = async (message: IMessage, room: IRoom, replies: string[]) = return message; }; -export async function processThreads(message: IMessage, room: IRoom) { +export async function processThreads(message: IMessage, room: IRoom, options?: SendMessageOptions) { if (!message.tmid) { return message; } @@ -61,7 +62,9 @@ export async function processThreads(message: IMessage, room: IRoom) { await notifyUsersOnReply(message, replies); await metaData(message, parentMessage, replies); - await notification(message, room, replies); + if (!options?.skipNotifications) { + await notification(message, room, replies); + } void notifyOnMessageChange({ id: message.tmid, }); @@ -77,8 +80,8 @@ Meteor.startup(() => { } callbacks.add( 'afterSaveMessage', - async (message, { room }) => { - return processThreads(message, room); + async (message, { room, options }) => { + return processThreads(message, room, options); }, callbacks.priority.LOW, 'threads-after-save-message', diff --git a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts index 876a4bea7f0f8..4922bc150d3e6 100644 --- a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts +++ b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts @@ -224,7 +224,7 @@ export const createComposerAPI = ( stopFormatterTracker.stop(); }; - const wrapSelection = (pattern: string): void => { + const wrapSelection = (pattern: string): { selectionStart: number; selectionEnd: number; value: string } => { const { selectionEnd = input.value.length, selectionStart = 0 } = input; const initText = input.value.slice(0, selectionStart); const selectedText = input.value.slice(selectionStart, selectionEnd); @@ -254,7 +254,11 @@ export const createComposerAPI = ( triggerEvent(input, 'change'); focus(); - return; + return { + selectionStart: input.selectionStart, + selectionEnd: input.selectionEnd, + value: input.value, + }; } } @@ -268,6 +272,12 @@ export const createComposerAPI = ( triggerEvent(input, 'change'); focus(); + + return { + selectionStart: input.selectionStart, + selectionEnd: input.selectionEnd, + value: input.value, + }; }; const insertNewLine = (): void => insertText('\n'); diff --git a/apps/meteor/app/ui/client/lib/ChatMessages.ts b/apps/meteor/app/ui/client/lib/ChatMessages.ts index a6febf3fdfef9..70b64201979ba 100644 --- a/apps/meteor/app/ui/client/lib/ChatMessages.ts +++ b/apps/meteor/app/ui/client/lib/ChatMessages.ts @@ -120,7 +120,7 @@ export class ChatMessages implements ChatAPI { }, editMessage: async (message: IMessage, { cursorAtStart = false }: { cursorAtStart?: boolean } = {}) => { this.composer?.uploads.clear(); - const text = (await this.data.getDraft(message._id)) || message.attachments?.[0]?.description || message.msg; + const text = (await this.data.getDraft(message._id)) || message.msg; await this.currentEditingMessage.stop(); diff --git a/apps/meteor/app/utils/rocketchat.info b/apps/meteor/app/utils/rocketchat.info index 1930b2b5d1bf3..fe691ec31c060 100644 --- a/apps/meteor/app/utils/rocketchat.info +++ b/apps/meteor/app/utils/rocketchat.info @@ -1,3 +1,3 @@ { - "version": "8.3.2" + "version": "8.4.0-rc.2" } diff --git a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx index 77ee3e750e762..2a6964462deaf 100644 --- a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx +++ b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx @@ -60,7 +60,6 @@ const CreateDiscussion = ({ watch, setValue, } = useForm({ - mode: 'onBlur', defaultValues: { name: nameSuggestion || '', parentRoom: '', diff --git a/apps/meteor/client/components/CreateDiscussion/__snapshots__/CreateDiscussion.spec.tsx.snap b/apps/meteor/client/components/CreateDiscussion/__snapshots__/CreateDiscussion.spec.tsx.snap index 675af5a40d946..5691fe063e502 100644 --- a/apps/meteor/client/components/CreateDiscussion/__snapshots__/CreateDiscussion.spec.tsx.snap +++ b/apps/meteor/client/components/CreateDiscussion/__snapshots__/CreateDiscussion.spec.tsx.snap @@ -206,9 +206,9 @@ exports[`CreateDiscussion renders Default without crashing 1`] = ` >
- {[...images].reverse().map(({ _id, path, url }) => ( + {[...images].reverse().map(({ _id, path, url, description }) => (
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events */} - + {description
diff --git a/apps/meteor/client/components/InfoPanel/__snapshots__/RetentionPolicyCallout.spec.tsx.snap b/apps/meteor/client/components/InfoPanel/__snapshots__/RetentionPolicyCallout.spec.tsx.snap index 8b1d477b28c52..4a717b0108484 100644 --- a/apps/meteor/client/components/InfoPanel/__snapshots__/RetentionPolicyCallout.spec.tsx.snap +++ b/apps/meteor/client/components/InfoPanel/__snapshots__/RetentionPolicyCallout.spec.tsx.snap @@ -26,7 +26,7 @@ exports[`renders Default without crashing 1`] = ` >

- a minute June 1, 2024 at 12:30 AM + 1 minute June 1, 2024 at 12:30 AM

@@ -63,7 +63,7 @@ exports[`renders InvalidSettings without crashing 1`] = ` >

- a minute June 12, 2024 at 12:00 AM + 1 minute June 12, 2024 at 12:00 AM

diff --git a/apps/meteor/client/components/InvitationBadge/__snapshots__/InvitationBadge.spec.tsx.snap b/apps/meteor/client/components/InvitationBadge/__snapshots__/InvitationBadge.spec.tsx.snap index da3763aaf4e55..32132535fbba5 100644 --- a/apps/meteor/client/components/InvitationBadge/__snapshots__/InvitationBadge.spec.tsx.snap +++ b/apps/meteor/client/components/InvitationBadge/__snapshots__/InvitationBadge.spec.tsx.snap @@ -7,7 +7,7 @@ exports[`renders WithDateObject without crashing 1`] = ` aria-hidden="false" class="rcx-box rcx-box--full rcx-icon--name-mail rcx-icon rcx-css-1p3fsdk" role="status" - title="Invited January 1, 2025" + title="Invited January 1st, 2025" >  @@ -22,7 +22,7 @@ exports[`renders WithISOStringDate without crashing 1`] = ` aria-hidden="false" class="rcx-box rcx-box--full rcx-icon--name-mail rcx-icon rcx-css-1p3fsdk" role="status" - title="Invited January 1, 2025" + title="Invited January 1st, 2025" >  diff --git a/apps/meteor/client/components/ResultsLiveRegion.tsx b/apps/meteor/client/components/ResultsLiveRegion.tsx new file mode 100644 index 0000000000000..3df599c66a509 --- /dev/null +++ b/apps/meteor/client/components/ResultsLiveRegion.tsx @@ -0,0 +1,14 @@ +import { VisuallyHidden } from '@react-aria/visually-hidden'; +import { useTranslation } from 'react-i18next'; + +const ResultsLiveRegion = ({ shouldAnnounce, itemCount }: { shouldAnnounce: boolean; itemCount: number }) => { + const { t } = useTranslation(); + + if (itemCount === 0) { + return {shouldAnnounce && t('No_results_found')}; + } + + return {shouldAnnounce && t('__count__result_found', { count: itemCount })}; +}; + +export default ResultsLiveRegion; diff --git a/apps/meteor/client/components/Sidebar/SidebarNavigationItem.tsx b/apps/meteor/client/components/Sidebar/SidebarNavigationItem.tsx index a5ea0be9492cb..5ee687b9a374f 100644 --- a/apps/meteor/client/components/Sidebar/SidebarNavigationItem.tsx +++ b/apps/meteor/client/components/Sidebar/SidebarNavigationItem.tsx @@ -28,14 +28,14 @@ const SidebarNavigationItem = ({ badge: Badge, }: SidebarNavigationItemProps) => { const path = pathSection; - const isActive = !!path && currentPath?.includes(path as string); + const isActive = !!path && currentPath?.includes(path); if (permissionGranted === false || (typeof permissionGranted === 'function' && !permissionGranted())) { return null; } return ( - + {icon && } - + {invalidAttempt && {t('Invalid_password')}} diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx index 263c4222f1d05..ff678c68f0603 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx @@ -49,7 +49,15 @@ const TwoFactorTotpModal = ({ onConfirm, onClose, onDismiss, invalidAttempt }: T {t('Enter_the_code_provided_by_your_authentication_app_to_continue')} - + {invalidAttempt && {t('Invalid_password')}} diff --git a/apps/meteor/client/components/UserCard/UserCardDialog.tsx b/apps/meteor/client/components/UserCard/UserCardDialog.tsx index daf138ef3dd60..8fbaee04aa7c0 100644 --- a/apps/meteor/client/components/UserCard/UserCardDialog.tsx +++ b/apps/meteor/client/components/UserCard/UserCardDialog.tsx @@ -1,8 +1,8 @@ +import type { AriaDialogProps } from '@react-aria/dialog'; +import { useDialog } from '@react-aria/dialog'; import { Box } from '@rocket.chat/fuselage'; import type { ComponentProps } from 'react'; import { useRef } from 'react'; -import type { AriaDialogProps } from 'react-aria'; -import { useDialog } from 'react-aria'; type UserCardDialogProps = AriaDialogProps & ComponentProps; diff --git a/apps/meteor/client/components/UserInfo/UserInfo.tsx b/apps/meteor/client/components/UserInfo/UserInfo.tsx index 59ddb7710a921..1f32888e901cf 100644 --- a/apps/meteor/client/components/UserInfo/UserInfo.tsx +++ b/apps/meteor/client/components/UserInfo/UserInfo.tsx @@ -14,7 +14,7 @@ import { } from '@rocket.chat/ui-client'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import type { ReactElement, ReactNode } from 'react'; -import { memo } from 'react'; +import { memo, useId } from 'react'; import { useTranslation } from 'react-i18next'; import { useTimeAgo } from '../../hooks/useTimeAgo'; @@ -84,6 +84,8 @@ const UserInfo = ({ const userDisplayName = useUserDisplayName({ name, username }); const userCustomFields = useUserCustomFields(customFields); + const usernameId = useId(); + return ( @@ -128,9 +130,13 @@ const UserInfo = ({ )} {username && username !== name && ( - - {t('Username')} - {username} + + + {t('Username')} + + + {username} + )} diff --git a/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap b/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap index 7f31acf80c25b..07e5652512bc2 100644 --- a/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap +++ b/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap @@ -164,21 +164,22 @@ exports[`renders Default without crashing 1`] = ` -
-
Username -
-
+
guilherme.gazzo -
-
+ +
@@ -467,21 +468,22 @@ exports[`renders InvitedUser without crashing 1`] = `
-
-
Username -
-
+
guilherme.gazzo -
-
+ +
@@ -539,7 +541,7 @@ exports[`renders InvitedUser without crashing 1`] = `
- January 1, 2025 + January 1st, 2025
@@ -784,21 +786,22 @@ exports[`renders WithABACAttributes without crashing 1`] = ` -
-
Username -
-
+
guilherme.gazzo -
-
+ +
@@ -1147,21 +1150,22 @@ exports[`renders WithVoiceCallExtension without crashing 1`] = `
-
-
Username -
-
+
guilherme.gazzo -
-
+ +
diff --git a/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarEditor.tsx b/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarEditor.tsx index b88acc2781335..98dfc4ebe1646 100644 --- a/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarEditor.tsx +++ b/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarEditor.tsx @@ -1,15 +1,17 @@ import type { IUser, AvatarObject } from '@rocket.chat/core-typings'; -import { Box, Button, Avatar, TextInput, IconButton, Label } from '@rocket.chat/fuselage'; +import { Box, Button, Avatar, IconButton } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, FieldError, TextInput } from '@rocket.chat/fuselage-forms'; import { UserAvatar } from '@rocket.chat/ui-avatar'; import { useToastMessageDispatch, useSetting } from '@rocket.chat/ui-contexts'; import type { ReactElement, ChangeEvent } from 'react'; -import { useId, useState, useCallback } from 'react'; +import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import type { UserAvatarSuggestion } from './UserAvatarSuggestion'; import UserAvatarSuggestions from './UserAvatarSuggestions'; import { readFileAsDataURL } from './readFileAsDataURL'; import { useSingleFileInput } from '../../../hooks/useSingleFileInput'; +import { isSafeAvatarUrl } from '../../../lib/utils/isSafeAvatarUrl'; import { isValidImageFormat } from '../../../lib/utils/isValidImageFormat'; type UserAvatarEditorProps = { @@ -27,8 +29,8 @@ function UserAvatarEditor({ currentUsername, username, setAvatarObj, name, disab const rotateImages = useSetting('FileUpload_RotateImages'); const [avatarFromUrl, setAvatarFromUrl] = useState(''); const [newAvatarSource, setNewAvatarSource] = useState(); - const imageUrlField = useId(); const dispatchToastMessage = useToastMessageDispatch(); + const [avatarUrlError, setAvatarUrlError] = useState(undefined); const setUploadedPreview = useCallback( async (file: File, avatarObj: AvatarObject) => { @@ -48,9 +50,27 @@ function UserAvatarEditor({ currentUsername, username, setAvatarObj, name, disab const [clickUpload] = useSingleFileInput(setUploadedPreview); - const handleAddUrl = (): void => { + const canAddUrl = !disabled && Boolean(avatarFromUrl) && !avatarUrlError; + + const handleAddUrl = async (): Promise => { + if (!canAddUrl) { + return; + } + + if (!isSafeAvatarUrl(avatarFromUrl)) { + setAvatarUrlError(t('error-invalid-image-url')); + return; + } + + if (!(await isValidImageFormat(avatarFromUrl))) { + setAvatarUrlError(t('error-invalid-image-url')); + return; + } + setNewAvatarSource(avatarFromUrl); setAvatarObj({ avatarUrl: avatarFromUrl }); + setAvatarUrlError(undefined); + dispatchToastMessage({ type: 'info', message: t('Avatar_preview_updated') }); }; const clickReset = (): void => { @@ -61,7 +81,11 @@ function UserAvatarEditor({ currentUsername, username, setAvatarObj, name, disab const url = newAvatarSource; const handleAvatarFromUrlChange = (event: ChangeEvent): void => { - setAvatarFromUrl(event.currentTarget.value); + if (avatarUrlError) { + setAvatarUrlError(undefined); + } + const { value } = event.currentTarget; + setAvatarFromUrl(value); }; const handleSelectSuggestion = useCallback( @@ -87,35 +111,46 @@ function UserAvatarEditor({ currentUsername, username, setAvatarObj, name, disab imageOrientation: rotateImages ? 'from-image' : 'none', objectFit: 'contain', }} - onError={() => dispatchToastMessage({ type: 'error', message: t('error-invalid-image-url') })} + onError={() => setAvatarUrlError(t('error-invalid-image-url'))} /> - + - - - + + {t('Use_url_for_avatar')} + + + } + disabled={disabled} + value={avatarFromUrl} + onChange={handleAvatarFromUrlChange} + error={avatarUrlError} + onKeyDown={(event): void => { + if (event.key === 'Enter') { + handleAddUrl(); + } + }} + /> + + {avatarUrlError && {avatarUrlError}} + diff --git a/apps/meteor/client/components/dashboards/periods.ts b/apps/meteor/client/components/dashboards/periods.ts index 637a2e0ac0b9f..dcdcae33b2876 100644 --- a/apps/meteor/client/components/dashboards/periods.ts +++ b/apps/meteor/client/components/dashboards/periods.ts @@ -1,32 +1,92 @@ import type { TranslationKey } from '@rocket.chat/ui-contexts'; -import type { DurationInputArg1, DurationInputArg2 } from 'moment'; -import moment from 'moment'; +import { startOfDay, startOfWeek, startOfMonth, startOfYear, endOfDay, subDays, subMonths } from 'date-fns'; const label = (translationKey: TranslationKey): readonly [translationKey: TranslationKey] => [translationKey]; +function startOfDayUTC(d: Date): Date { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0)); +} + +function endOfDayUTC(d: Date): Date { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 23, 59, 59, 999)); +} + +function startOfWeekUTC(d: Date): Date { + const day = d.getUTCDay(); // 0 = Sunday + const sunday = new Date(d); + sunday.setUTCDate(d.getUTCDate() - day); + return startOfDayUTC(sunday); +} + +function startOfMonthUTC(d: Date): Date { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1, 0, 0, 0, 0)); +} + +function startOfYearUTC(d: Date): Date { + return new Date(Date.UTC(d.getUTCFullYear(), 0, 1, 0, 0, 0, 0)); +} + +type StartOf = 'day' | 'year' | 'month' | 'week'; +type Subtract = { amount: number; unit: 'days' | 'months' }; + export const getClosedPeriod = ({ - startOf, - subtract, + startOf: startOfKey, + subtract: subtractOpt, }: { - startOf: 'day' | 'year' | 'month' | 'week'; - subtract?: { amount: DurationInputArg1; unit: DurationInputArg2 }; + startOf: StartOf; + subtract?: Subtract; }): ((utc: boolean) => { start: Date; end: Date; }) => - (utc): { start: Date; end: Date } => { - const start = utc ? moment().utc() : moment(); - const end = utc ? moment().utc() : moment(); + (utc: boolean): { start: Date; end: Date } => { + const now = new Date(); + + let start: Date; + if (subtractOpt) { + const { amount, unit } = subtractOpt; + start = unit === 'days' ? subDays(now, amount) : subMonths(now, amount); + } else { + start = new Date(now.getTime()); + } + + if (utc) { + switch (startOfKey) { + case 'day': + start = startOfDayUTC(start); + break; + case 'week': + start = startOfWeekUTC(start); + break; + case 'month': + start = startOfMonthUTC(start); + break; + case 'year': + start = startOfYearUTC(start); + break; + } + return { start, end: endOfDayUTC(now) }; + } - if (subtract) { - const { amount, unit } = subtract; - start.subtract(amount, unit); + switch (startOfKey) { + case 'day': + start = startOfDay(start); + break; + case 'week': + start = startOfWeek(start); + break; + case 'month': + start = startOfMonth(start); + break; + case 'year': + start = startOfYear(start); + break; } return { - start: start.startOf(startOf).toDate(), - end: end.endOf('day').toDate(), + start, + end: endOfDay(now), }; }; diff --git a/apps/meteor/client/components/message/content/ThreadMetrics.spec.tsx b/apps/meteor/client/components/message/content/ThreadMetrics.spec.tsx index 363eb1f2870b8..c6138e9dd342d 100644 --- a/apps/meteor/client/components/message/content/ThreadMetrics.spec.tsx +++ b/apps/meteor/client/components/message/content/ThreadMetrics.spec.tsx @@ -112,7 +112,7 @@ describe('Thread Metrics', () => { expect(navigateSpy).toHaveBeenCalledWith('thread', 'rid', 'thread', 'mid'); const threadCount = screen.getByTitle('Last_message__date__'); - expect(threadCount).toHaveTextContent('5 replies July 1, 2024'); + expect(threadCount).toHaveTextContent('5 replies July 1st, 2024'); }); it('should render small not followed with 3 participants and unread', async () => { diff --git a/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx b/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx index 89da067a16135..2776c1ea81d81 100644 --- a/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx @@ -96,7 +96,9 @@ const DefaultAttachment = (attachment: DefaultAttachmentProps): ReactElement => })} /> )} - {attachment.image_url && } + {attachment.image_url && ( + + )} {/* DEPRECATED */} {isActionAttachment(attachment) && } diff --git a/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx index 227874cfb8eaf..9fa94126127e8 100644 --- a/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx @@ -4,17 +4,13 @@ import { useMediaUrl } from '@rocket.chat/ui-contexts'; import { useMemo } from 'react'; import { useReloadOnError } from './hooks/useReloadOnError'; -import MarkdownText from '../../../../MarkdownText'; import MessageCollapsible from '../../../MessageCollapsible'; -import MessageContentBody from '../../../MessageContentBody'; const AudioAttachment = ({ title, audio_url: url, audio_type: type, audio_size: size, - description, - descriptionMd, title_link: link, title_link_download: hasDownload, collapsed, @@ -25,7 +21,6 @@ const AudioAttachment = ({ return ( <> - {descriptionMd ? : } diff --git a/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx index d2dbd4f888257..2e0fb7107d983 100644 --- a/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx @@ -13,9 +13,7 @@ import { useTranslation } from 'react-i18next'; import { getFileExtension } from '../../../../../../lib/utils/getFileExtension'; import { forAttachmentDownload, registerDownloadForUid } from '../../../../../hooks/useDownloadFromServiceWorker'; -import MarkdownText from '../../../../MarkdownText'; import MessageCollapsible from '../../../MessageCollapsible'; -import MessageContentBody from '../../../MessageContentBody'; import AttachmentSize from '../structure/AttachmentSize'; const openDocumentViewer = window.RocketChatDesktop?.openDocumentViewer; @@ -24,8 +22,6 @@ type GenericFileAttachmentProps = MessageAttachmentBase; const GenericFileAttachment = ({ title, - description, - descriptionMd, title_link: link, title_link_download: hasDownload, size, @@ -72,7 +68,6 @@ const GenericFileAttachment = ({ return ( <> - {descriptionMd ? : } - {descriptionMd ? : } diff --git a/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx index 4768e01d41cda..1fdcedb220f2d 100644 --- a/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx @@ -5,17 +5,13 @@ import { useMemo } from 'react'; import { useReloadOnError } from './hooks/useReloadOnError'; import { userAgentMIMETypeFallback } from '../../../../../lib/utils/userAgentMIMETypeFallback'; -import MarkdownText from '../../../../MarkdownText'; import MessageCollapsible from '../../../MessageCollapsible'; -import MessageContentBody from '../../../MessageContentBody'; const VideoAttachment = ({ title, video_url: url, video_type: type, video_size: size, - description, - descriptionMd, title_link: link, title_link_download: hasDownload, collapsed, @@ -26,7 +22,6 @@ const VideoAttachment = ({ return ( <> - {descriptionMd ? : } diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentImage.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentImage.tsx index 072602cc15d5f..572fc622cda9c 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentImage.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentImage.tsx @@ -15,6 +15,7 @@ type AttachmentImageProps = { id: string | undefined; width: number; height: number; + alt?: string; } & ({ loadImage: true } | { loadImage: false; setLoadImage: () => void }); const getDimensions = ( @@ -36,7 +37,7 @@ const getDimensions = ( return { width, height, ratio: (height / width) * 100 }; }; -const AttachmentImage = ({ id, previewUrl, dataSrc, loadImage = true, setLoadImage, src, ...size }: AttachmentImageProps) => { +const AttachmentImage = ({ id, previewUrl, dataSrc, loadImage = true, setLoadImage, src, alt = '', ...size }: AttachmentImageProps) => { const limits = useAttachmentDimensions(); const [error, setError] = useState(false); @@ -82,7 +83,7 @@ const AttachmentImage = ({ id, previewUrl, dataSrc, loadImage = true, setLoadIma className='gallery-item' data-src={dataSrc || src} src={src} - alt='' + alt={alt} width={dimensions.width} height={dimensions.height} loading='lazy' diff --git a/apps/meteor/client/components/message/toolbar/useCopyAction.ts b/apps/meteor/client/components/message/toolbar/useCopyAction.ts index 1a03dac99936d..b8275144abca1 100644 --- a/apps/meteor/client/components/message/toolbar/useCopyAction.ts +++ b/apps/meteor/client/components/message/toolbar/useCopyAction.ts @@ -6,8 +6,8 @@ import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/Me const getMainMessageText = (message: IMessage): IMessage => { const newMessage = { ...message }; - newMessage.msg = newMessage.msg || newMessage.attachments?.[0]?.description || newMessage.attachments?.[0]?.title || ''; - newMessage.md = newMessage.md || newMessage.attachments?.[0]?.descriptionMd || undefined; + newMessage.msg = newMessage.msg || newMessage.attachments?.[0]?.title || ''; + newMessage.md = newMessage.md || undefined; return { ...newMessage }; }; diff --git a/apps/meteor/client/components/message/toolbar/useEditMessageAction.ts b/apps/meteor/client/components/message/toolbar/useEditMessageAction.ts index 92df4f7cea35a..39ef7e94e2fd6 100644 --- a/apps/meteor/client/components/message/toolbar/useEditMessageAction.ts +++ b/apps/meteor/client/components/message/toolbar/useEditMessageAction.ts @@ -1,7 +1,7 @@ import { isRoomFederated } from '@rocket.chat/core-typings'; import type { IRoom, IMessage, ISubscription } from '@rocket.chat/core-typings'; import { usePermission, useSetting, useUser } from '@rocket.chat/ui-contexts'; -import moment from 'moment'; +import { differenceInMinutes } from 'date-fns'; import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction'; import { useChat } from '../../../views/room/contexts/ChatContext'; @@ -32,8 +32,7 @@ export const useEditMessageAction = ( } if (!canBypassBlockTimeLimit && blockEditInMinutes) { - const msgTs = message.ts ? moment(message.ts) : undefined; - const currentTsDiff = msgTs ? moment().diff(msgTs, 'minutes') : undefined; + const currentTsDiff = message.ts ? differenceInMinutes(new Date(), message.ts) : undefined; return typeof currentTsDiff === 'number' && currentTsDiff < blockEditInMinutes; } diff --git a/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts b/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts index 588ab0a607624..dd10e3f56c6af 100644 --- a/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts +++ b/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts @@ -29,7 +29,7 @@ export const useReplyInDMAction = ( [message.u._id, user], ); - const shouldFindRoom = useMemo(() => !!user && canCreateDM && user._id !== message.u._id, [canCreateDM, message.u._id, user]); + const shouldFindRoom = useMemo(() => !!user && !canCreateDM && user._id !== message.u._id, [canCreateDM, message.u._id, user]); const dmRoom = Rooms.use(useShallow((state) => (shouldFindRoom ? state.find(roomPredicate) : undefined))); const subsPredicate = useCallback( @@ -52,7 +52,7 @@ export const useReplyInDMAction = ( if (!subscription || room.t === 'd' || room.t === 'l' || isLayoutEmbedded) { return false; } - if (!!user && user._id !== message.u._id && canCreateDM) { + if (!!user && user._id !== message.u._id && !canCreateDM) { if (!dmRoom || !dmSubs) { return false; } diff --git a/apps/meteor/client/components/message/toolbar/useReportMessageAction.tsx b/apps/meteor/client/components/message/toolbar/useReportMessageAction.tsx index ba281c5a1f5a2..dbbb962032907 100644 --- a/apps/meteor/client/components/message/toolbar/useReportMessageAction.tsx +++ b/apps/meteor/client/components/message/toolbar/useReportMessageAction.tsx @@ -7,8 +7,8 @@ import ReportMessageModal from '../../../views/room/modals/ReportMessageModal'; const getMainMessageText = (message: IMessage): IMessage => { const newMessage = { ...message }; - newMessage.msg = newMessage.msg || newMessage.attachments?.[0]?.description || newMessage.attachments?.[0]?.title || ''; - newMessage.md = newMessage.md || newMessage.attachments?.[0]?.descriptionMd || undefined; + newMessage.msg = newMessage.msg || newMessage.attachments?.[0]?.title || ''; + newMessage.md = newMessage.md || undefined; return { ...newMessage }; }; diff --git a/apps/meteor/client/definitions/cron.d.ts b/apps/meteor/client/definitions/cron.d.ts index 25c4be4f97a7c..f3c1e67e19795 100644 --- a/apps/meteor/client/definitions/cron.d.ts +++ b/apps/meteor/client/definitions/cron.d.ts @@ -1,3 +1,3 @@ declare module 'cron' { - export declare function sendAt(precision: string): Moment; + export declare function sendAt(precision: string): { valueOf(): number }; } diff --git a/apps/meteor/client/hooks/roomActions/useKeyboardShortcutListRoomAction.ts b/apps/meteor/client/hooks/roomActions/useKeyboardShortcutListRoomAction.ts deleted file mode 100644 index 103170c7f4626..0000000000000 --- a/apps/meteor/client/hooks/roomActions/useKeyboardShortcutListRoomAction.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts'; -import { lazy, useMemo } from 'react'; - -const KeyboardShortcuts = lazy(() => import('../../views/room/contextualBar/KeyboardShortcuts')); - -export const useKeyboardShortcutListRoomAction = () => { - return useMemo( - (): RoomToolboxActionConfig => ({ - id: 'keyboard-shortcut-list', - groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'], - title: 'Keyboard_Shortcuts_Title', - icon: 'keyboard', - tabComponent: KeyboardShortcuts, - order: 99, - type: 'customization', - }), - [], - ); -}; diff --git a/apps/meteor/client/hooks/roomActions/useOutlookCalenderRoomAction.ts b/apps/meteor/client/hooks/roomActions/useOutlookCalenderRoomAction.ts index 52e3e31dcf59a..a88ff5854fac4 100644 --- a/apps/meteor/client/hooks/roomActions/useOutlookCalenderRoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useOutlookCalenderRoomAction.ts @@ -1,14 +1,14 @@ -import { useUser } from '@rocket.chat/ui-contexts'; +import { useSetting } from '@rocket.chat/ui-contexts'; import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts'; import { lazy, useMemo } from 'react'; const OutlookEventsRoute = lazy(() => import('../../views/outlookCalendar/OutlookEventsRoute')); export const useOutlookCalenderRoomAction = () => { - const user = useUser(); + const enabled = useSetting('Outlook_Calendar_Enabled', false); return useMemo((): RoomToolboxActionConfig | undefined => { - if (!user?.settings?.calendar?.outlook?.Enabled) { + if (!enabled) { return undefined; } @@ -20,5 +20,5 @@ export const useOutlookCalenderRoomAction = () => { tabComponent: OutlookEventsRoute, order: 999, }; - }, [user?.settings?.calendar?.outlook?.Enabled]); + }, [enabled]); }; diff --git a/apps/meteor/client/hooks/useApplyButtonFilters.spec.ts b/apps/meteor/client/hooks/useApplyButtonFilters.spec.ts new file mode 100644 index 0000000000000..cb386383cf5b4 --- /dev/null +++ b/apps/meteor/client/hooks/useApplyButtonFilters.spec.ts @@ -0,0 +1,212 @@ +import type { IUIActionButton } from '@rocket.chat/apps-engine/definition/ui'; +import { UIActionButtonContext } from '@rocket.chat/apps-engine/definition/ui'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook } from '@testing-library/react'; + +import { useApplyButtonAuthFilter } from './useApplyButtonFilters'; + +describe('useApplyButtonAuthFilter', () => { + describe('Role-based filtering', () => { + it('should filter button when user does not have required role (hasAllRoles)', () => { + const button: IUIActionButton = { + appId: 'test-app', + actionId: 'test-action', + labelI18n: 'test_label', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + when: { + hasAllRoles: ['admin'], + }, + }; + + const { result } = renderHook(() => useApplyButtonAuthFilter(), { + wrapper: mockAppRoot() + .withJohnDoe({ roles: ['user'] }) + .build(), + }); + + expect(result.current(button)).toBe(false); + }); + + it('should show button when user has required role (hasAllRoles)', () => { + const button: IUIActionButton = { + appId: 'test-app', + actionId: 'test-action', + labelI18n: 'test_label', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + when: { + hasAllRoles: ['admin'], + }, + }; + + const { result } = renderHook(() => useApplyButtonAuthFilter(), { + wrapper: mockAppRoot().withJohnDoe().withRole('admin').build(), + }); + + expect(result.current(button)).toBe(true); + }); + + it('should filter button when user does not have any required role (hasOneRole)', () => { + const button: IUIActionButton = { + appId: 'test-app', + actionId: 'test-action', + labelI18n: 'test_label', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + when: { + hasOneRole: ['admin', 'moderator'], + }, + }; + + const { result } = renderHook(() => useApplyButtonAuthFilter(), { + wrapper: mockAppRoot() + .withJohnDoe({ roles: ['user'] }) + .build(), + }); + + expect(result.current(button)).toBe(false); + }); + + it('should show button when user has at least one of the required roles (hasOneRole)', () => { + const button: IUIActionButton = { + appId: 'test-app', + actionId: 'test-action', + labelI18n: 'test_label', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + when: { + hasOneRole: ['admin', 'moderator'], + }, + }; + + const { result } = renderHook(() => useApplyButtonAuthFilter(), { + wrapper: mockAppRoot().withJohnDoe().withRole('moderator').build(), + }); + + expect(result.current(button)).toBe(true); + }); + + it('should show button when no role filter is specified', () => { + const button: IUIActionButton = { + appId: 'test-app', + actionId: 'test-action', + labelI18n: 'test_label', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + }; + + const { result } = renderHook(() => useApplyButtonAuthFilter(), { + wrapper: mockAppRoot() + .withJohnDoe({ roles: ['user'] }) + .build(), + }); + + expect(result.current(button)).toBe(true); + }); + + it('should filter button when user is not logged in and role is required (hasAllRoles)', () => { + const button: IUIActionButton = { + appId: 'test-app', + actionId: 'test-action', + labelI18n: 'test_label', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + when: { + hasAllRoles: ['admin'], + }, + }; + + const { result } = renderHook(() => useApplyButtonAuthFilter(), { + wrapper: mockAppRoot().withAnonymous().build(), + }); + + expect(result.current(button)).toBe(false); + }); + }); + + describe('Permission-based filtering', () => { + it('should filter button when user does not have required permission (hasAllPermissions)', () => { + const button: IUIActionButton = { + appId: 'test-app', + actionId: 'test-action', + labelI18n: 'test_label', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + when: { + hasAllPermissions: ['manage-apps'], + }, + }; + + const { result } = renderHook(() => useApplyButtonAuthFilter(), { + wrapper: mockAppRoot().withJohnDoe().build(), + }); + + expect(result.current(button)).toBe(false); + }); + + it('should show button when user has required permission (hasAllPermissions)', () => { + const button: IUIActionButton = { + appId: 'test-app', + actionId: 'test-action', + labelI18n: 'test_label', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + when: { + hasAllPermissions: ['manage-apps'], + }, + }; + + const { result } = renderHook(() => useApplyButtonAuthFilter(), { + wrapper: mockAppRoot().withJohnDoe().withPermission('manage-apps').build(), + }); + + expect(result.current(button)).toBe(true); + }); + + it('should show button when user has at least one of the required permissions (hasOnePermission)', () => { + const button: IUIActionButton = { + appId: 'test-app', + actionId: 'test-action', + labelI18n: 'test_label', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + when: { + hasOnePermission: ['manage-apps', 'manage-users'], + }, + }; + + const { result } = renderHook(() => useApplyButtonAuthFilter(), { + wrapper: mockAppRoot().withJohnDoe().withPermission('manage-apps').build(), + }); + + expect(result.current(button)).toBe(true); + }); + }); + + describe('Combined filters', () => { + it('should apply both role and permission filters (AND logic)', () => { + const button: IUIActionButton = { + appId: 'test-app', + actionId: 'test-action', + labelI18n: 'test_label', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + when: { + hasAllRoles: ['admin'], + hasAllPermissions: ['manage-apps'], + }, + }; + + const { result: result1 } = renderHook(() => useApplyButtonAuthFilter(), { + wrapper: mockAppRoot() + .withJohnDoe({ roles: ['user'] }) + .build(), + }); + expect(result1.current(button)).toBe(false); + + const { result: result2 } = renderHook(() => useApplyButtonAuthFilter(), { + wrapper: mockAppRoot() + .withJohnDoe({ roles: ['user'] }) + .withPermission('manage-apps') + .build(), + }); + expect(result2.current(button)).toBe(false); + + const { result: result3 } = renderHook(() => useApplyButtonAuthFilter(), { + wrapper: mockAppRoot().withJohnDoe().withRole('admin').withPermission('manage-apps').build(), + }); + expect(result3.current(button)).toBe(true); + }); + }); +}); diff --git a/apps/meteor/client/hooks/useApplyButtonFilters.ts b/apps/meteor/client/hooks/useApplyButtonFilters.ts index 63542c5189c68..66f0422c12e04 100644 --- a/apps/meteor/client/hooks/useApplyButtonFilters.ts +++ b/apps/meteor/client/hooks/useApplyButtonFilters.ts @@ -66,7 +66,7 @@ export const useApplyButtonAuthFilter = (): ((button: IUIActionButton) => boolea const hasAllPermissionsResult = hasAllPermissions ? queryAllPermissions(hasAllPermissions)[1]() : true; const hasOnePermissionResult = hasOnePermission ? queryAtLeastOnePermission(hasOnePermission)[1]() : true; - const hasAllRolesResult = hasAllRoles ? !!uid && hasAllRoles.every((role) => queryRole(role, room?._id)) : true; + const hasAllRolesResult = hasAllRoles ? !!uid && hasAllRoles.every((role) => queryRole(role, room?._id)[1]()) : true; const hasOneRoleResult = hasOneRole ? !!uid && hasOneRole.some((role) => queryRole(role, room?._id)[1]()) : true; return hasAllPermissionsResult && hasOnePermissionResult && hasAllRolesResult && hasOneRoleResult; diff --git a/apps/meteor/client/hooks/useDecryptedMessage.spec.ts b/apps/meteor/client/hooks/useDecryptedMessage.spec.ts index 5b35e8d6e3352..3103e708910f6 100644 --- a/apps/meteor/client/hooks/useDecryptedMessage.spec.ts +++ b/apps/meteor/client/hooks/useDecryptedMessage.spec.ts @@ -53,7 +53,7 @@ describe('useDecryptedMessage', () => { it('should handle E2EE messages with attachments', async () => { (isE2EEMessage as jest.MockedFunction).mockReturnValue(true); (e2e.decryptMessage as jest.Mock).mockResolvedValue({ - attachments: [{ description: 'Attachment description' }], + attachments: [{ title: 'Attachment title' }], }); const message = { msg: 'Encrypted message with attachment' }; @@ -63,7 +63,6 @@ describe('useDecryptedMessage', () => { expect(result.current).toBe('E2E_message_encrypted_placeholder'); }); - expect(result.current).toBe('Attachment description'); expect(e2e.decryptMessage).toHaveBeenCalledWith(message); }); diff --git a/apps/meteor/client/hooks/useDecryptedMessage.ts b/apps/meteor/client/hooks/useDecryptedMessage.ts index e560aacc5b111..771665dc0b631 100644 --- a/apps/meteor/client/hooks/useDecryptedMessage.ts +++ b/apps/meteor/client/hooks/useDecryptedMessage.ts @@ -18,14 +18,11 @@ export const useDecryptedMessage = (message: IMessage): string => { e2e.decryptMessage(message).then((decryptedMsg) => { if (decryptedMsg.msg) { setDecryptedMessage(decryptedMsg.msg); + return; } - if (decryptedMsg.attachments && decryptedMsg.attachments?.length > 0) { - if (decryptedMsg.attachments[0].description) { - setDecryptedMessage(decryptedMsg.attachments[0].description); - } else { - setDecryptedMessage(t('Message_with_attachment')); - } + if (decryptedMsg.attachments && decryptedMsg.attachments.length > 0) { + setDecryptedMessage(t('Message_with_attachment')); } }); }, [message, t, setDecryptedMessage]); diff --git a/apps/meteor/client/hooks/useFormSubmitWithDirtyCheck.ts b/apps/meteor/client/hooks/useFormSubmitWithDirtyCheck.ts new file mode 100644 index 0000000000000..833046de91201 --- /dev/null +++ b/apps/meteor/client/hooks/useFormSubmitWithDirtyCheck.ts @@ -0,0 +1,39 @@ +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +type UseFormSubmitOptions = { + isDirty: boolean; + noChangesMessage?: string; +}; + +/** + * A reusable hook for form submission that implements accessible form validation patterns. + * + * This hook wraps your form submission handler and: + * - Allows submission attempts in both create and edit modes (keeping buttons enabled for a11y) + * - Provides user-friendly feedback when trying to save an unchanged edit form + */ + +export const useFormSubmitWithDirtyCheck = ( + onSubmit: (data: TData) => Promise | void, + { isDirty, noChangesMessage = 'No_changes_to_save' }: UseFormSubmitOptions, +) => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + return useCallback( + async (data: TData): Promise => { + if (!!data && !isDirty) { + dispatchToastMessage({ + type: 'info', + message: t(noChangesMessage), + }); + return; + } + + await onSubmit(data); + }, + [isDirty, onSubmit, dispatchToastMessage, t, noChangesMessage], + ); +}; diff --git a/apps/meteor/client/hooks/useFormatDate.ts b/apps/meteor/client/hooks/useFormatDate.ts index 4beeb1d70b051..35410591ddd9d 100644 --- a/apps/meteor/client/hooks/useFormatDate.ts +++ b/apps/meteor/client/hooks/useFormatDate.ts @@ -1,8 +1,9 @@ import { useSetting } from '@rocket.chat/ui-contexts'; -import moment from 'moment'; import { useCallback } from 'react'; +import { formatDate } from '../lib/utils/dateFormat'; + export const useFormatDate = () => { const format = useSetting('Message_DateFormat'); - return useCallback((time: string | Date | number) => moment(time).format(String(format)), [format]); + return useCallback((time: string | Date | number) => formatDate(time, String(format)), [format]); }; diff --git a/apps/meteor/client/hooks/useFormatDateAndTime.ts b/apps/meteor/client/hooks/useFormatDateAndTime.ts index 9f73cc5718ea2..2f2ddaa182d6f 100644 --- a/apps/meteor/client/hooks/useFormatDateAndTime.ts +++ b/apps/meteor/client/hooks/useFormatDateAndTime.ts @@ -1,8 +1,8 @@ import { useUserPreference, useSetting } from '@rocket.chat/ui-contexts'; -import type { MomentInput } from 'moment'; -import moment from 'moment'; import { useCallback } from 'react'; +import { formatDate } from '../lib/utils/dateFormat'; + type UseFormatDateAndTimeParams = { withSeconds?: boolean; }; @@ -12,15 +12,15 @@ export const useFormatDateAndTime = ({ withSeconds }: UseFormatDateAndTimeParams const format = useSetting('Message_TimeAndDateFormat', 'LLL'); return useCallback( - (time: MomentInput) => { + (time: string | Date | number = new Date()) => { switch (clockMode) { case 1: - return moment(time).format(withSeconds ? 'MMMM D, Y h:mm:ss A' : 'MMMM D, Y h:mm A'); + return formatDate(time, withSeconds ? 'MMMM D, YYYY h:mm:ss A' : 'MMMM D, YYYY h:mm A'); case 2: - return moment(time).format(withSeconds ? 'MMMM D, Y H:mm:ss' : 'MMMM D, Y H:mm'); + return formatDate(time, withSeconds ? 'MMMM D, YYYY H:mm:ss' : 'MMMM D, YYYY H:mm'); default: - return moment(time).format(withSeconds ? 'L LTS' : format); + return formatDate(time, withSeconds ? 'L LTS' : format); } }, [clockMode, format, withSeconds], diff --git a/apps/meteor/client/hooks/useFormatTime.ts b/apps/meteor/client/hooks/useFormatTime.ts index 38b3cf236b703..fcb1c445e03fc 100644 --- a/apps/meteor/client/hooks/useFormatTime.ts +++ b/apps/meteor/client/hooks/useFormatTime.ts @@ -1,8 +1,8 @@ import { useUserPreference, useSetting } from '@rocket.chat/ui-contexts'; -import type { MomentInput } from 'moment'; -import moment from 'moment'; import { useCallback } from 'react'; +import { formatDate } from '../lib/utils/dateFormat'; + const dayFormat = ['h:mm A', 'H:mm'] as const; export const useFormatTime = () => { @@ -11,14 +11,14 @@ export const useFormatTime = () => { const sameDay = clockMode !== undefined ? dayFormat[clockMode - 1] : format; return useCallback( - (time: MomentInput) => { + (time: string | Date | number) => { switch (clockMode) { case 1: case 2: - return moment(time).format(sameDay); + return formatDate(time, sameDay); default: - return moment(time).format(format); + return formatDate(time, format); } }, [clockMode, format, sameDay], diff --git a/apps/meteor/client/hooks/useFormattedRelativeTime.ts b/apps/meteor/client/hooks/useFormattedRelativeTime.ts index b2003c3825b77..555b02d0d9b69 100644 --- a/apps/meteor/client/hooks/useFormattedRelativeTime.ts +++ b/apps/meteor/client/hooks/useFormattedRelativeTime.ts @@ -1,14 +1,5 @@ -import moment from 'moment'; import { useMemo } from 'react'; -export const useFormattedRelativeTime = (timeMs: number): string => - useMemo(() => { - moment.relativeTimeThreshold('s', 60); - moment.relativeTimeThreshold('ss', 0); - moment.relativeTimeThreshold('m', 60); - moment.relativeTimeThreshold('h', 24); - moment.relativeTimeThreshold('d', 31); - moment.relativeTimeThreshold('M', 12); +import { formatDurationMs } from '../lib/utils/dateFormat'; - return moment.duration(timeMs).humanize(); - }, [timeMs]); +export const useFormattedRelativeTime = (timeMs: number): string => useMemo(() => formatDurationMs(timeMs), [timeMs]); diff --git a/apps/meteor/client/hooks/useLdapSync.tsx b/apps/meteor/client/hooks/useLdapSync.tsx new file mode 100644 index 0000000000000..0e4e904685776 --- /dev/null +++ b/apps/meteor/client/hooks/useLdapSync.tsx @@ -0,0 +1,47 @@ +import { useStableCallback } from '@rocket.chat/fuselage-hooks'; +import { GenericModal } from '@rocket.chat/ui-client'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; +import { useSetModal, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; + +export const useLdapSync = () => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const setModal = useSetModal(); + const testConnection = useEndpoint('POST', '/v1/ldap.testConnection'); + const syncNow = useEndpoint('POST', '/v1/ldap.syncNow'); + const closeModal = useStableCallback(() => setModal(null)); + + const handleSyncNow = async (): Promise => { + closeModal(); + + try { + const { message } = await syncNow(); + dispatchToastMessage({ type: 'success', message: t(message as TranslationKey) }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }; + + return async (): Promise => { + try { + await testConnection(); + } catch { + dispatchToastMessage({ type: 'error', message: t('Connection_failed') }); + return; + } + + setModal( + + {t('LDAP_Sync_Now_Description')} + , + ); + }; +}; diff --git a/apps/meteor/client/hooks/usePruneWarningMessage.spec.ts b/apps/meteor/client/hooks/usePruneWarningMessage.spec.ts index a1982f6b81f9d..77d44e6ece876 100644 --- a/apps/meteor/client/hooks/usePruneWarningMessage.spec.ts +++ b/apps/meteor/client/hooks/usePruneWarningMessage.spec.ts @@ -38,9 +38,9 @@ describe('usePruneWarningMessage hook', () => { TTLChannels: 60000, }), }); - expect(result.current).toEqual('a minute June 1, 2024 at 12:30 AM'); + expect(result.current).toEqual('1 minute June 1, 2024 at 12:30 AM'); await jest.advanceTimersByTimeAsync(31 * 60 * 1000); - await waitFor(() => expect(result.current).toEqual('a minute June 1, 2024 at 1:00 AM')); + await waitFor(() => expect(result.current).toEqual('1 minute June 1, 2024 at 1:00 AM')); }); it('Should return the default warning with precision set to every_hour', () => { @@ -52,7 +52,7 @@ describe('usePruneWarningMessage hook', () => { precision: '1', }), }); - expect(result.current).toEqual('a minute June 1, 2024 at 1:00 AM'); + expect(result.current).toEqual('1 minute June 1, 2024 at 1:00 AM'); }); it('Should return the default warning with precision set to every_six_hours', () => { @@ -64,7 +64,7 @@ describe('usePruneWarningMessage hook', () => { precision: '2', }), }); - expect(result.current).toEqual('a minute June 1, 2024 at 6:00 AM'); + expect(result.current).toEqual('1 minute June 1, 2024 at 6:00 AM'); }); it('Should return the default warning with precision set to every_day', () => { @@ -76,7 +76,7 @@ describe('usePruneWarningMessage hook', () => { precision: '3', }), }); - expect(result.current).toEqual('a minute June 2, 2024 at 12:00 AM'); + expect(result.current).toEqual('1 minute June 2, 2024 at 12:00 AM'); }); it('Should return the default warning with advanced precision', () => { @@ -89,7 +89,7 @@ describe('usePruneWarningMessage hook', () => { advancedPrecisionCron: '0 0 1 */1 *', }), }); - expect(result.current).toEqual('a minute July 1, 2024 at 12:00 AM'); + expect(result.current).toEqual('1 minute July 1, 2024 at 12:00 AM'); }); }); @@ -102,7 +102,7 @@ describe('usePruneWarningMessage hook', () => { TTLChannels: 60000, }), }); - expect(result.current).toEqual('a minute June 1, 2024 at 12:30 AM'); + expect(result.current).toEqual('1 minute June 1, 2024 at 12:30 AM'); }); it('Should return the unpinned messages warning', () => { @@ -114,7 +114,7 @@ describe('usePruneWarningMessage hook', () => { doNotPrunePinned: true, }), }); - expect(result.current).toEqual('Unpinned a minute June 1, 2024 at 12:30 AM'); + expect(result.current).toEqual('Unpinned 1 minute June 1, 2024 at 12:30 AM'); }); it('Should return the files only warning', () => { @@ -127,7 +127,7 @@ describe('usePruneWarningMessage hook', () => { filesOnly: true, }), }); - expect(result.current).toEqual('FilesOnly a minute June 1, 2024 at 12:30 AM'); + expect(result.current).toEqual('FilesOnly 1 minute June 1, 2024 at 12:30 AM'); }); it('Should return the unpinned files only warning', () => { @@ -141,7 +141,7 @@ describe('usePruneWarningMessage hook', () => { doNotPrunePinned: true, }), }); - expect(result.current).toEqual('UnpinnedFilesOnly a minute June 1, 2024 at 12:30 AM'); + expect(result.current).toEqual('UnpinnedFilesOnly 1 minute June 1, 2024 at 12:30 AM'); }); }); diff --git a/apps/meteor/client/hooks/useRouterScrollToHash.ts b/apps/meteor/client/hooks/useRouterScrollToHash.ts new file mode 100644 index 0000000000000..477948d683060 --- /dev/null +++ b/apps/meteor/client/hooks/useRouterScrollToHash.ts @@ -0,0 +1,39 @@ +import { usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; +import type { RouterContextValue } from '@rocket.chat/ui-contexts'; +import { useEffect, useRef, useSyncExternalStore } from 'react'; + +/** + * Scrolls to the element matching the current URL hash on route changes. + * + * @see docs/anchor-navigation.md + */ +export const useRouterScrollToHash = (router: RouterContextValue) => { + const hash = useSyncExternalStore(router.subscribeToRouteChange, router.getLocationHash); + const reducedMotion = usePrefersReducedMotion(); + + const previousHash = useRef(null); + + useEffect(() => { + const id = hash.slice(1); + + if (!id) { + previousHash.current = hash; + return; + } + + if (hash === previousHash.current) { + return; + } + + previousHash.current = hash; + + const frame = requestAnimationFrame(() => { + document.getElementById(id)?.scrollIntoView({ + behavior: reducedMotion ? 'auto' : 'smooth', + block: 'start', + }); + }); + + return () => cancelAnimationFrame(frame); + }, [hash, reducedMotion]); +}; diff --git a/apps/meteor/client/hooks/useTimeAgo.ts b/apps/meteor/client/hooks/useTimeAgo.ts index 4d4b7d12b7b7a..c159963ede1f8 100644 --- a/apps/meteor/client/hooks/useTimeAgo.ts +++ b/apps/meteor/client/hooks/useTimeAgo.ts @@ -1,9 +1,8 @@ import { useUserPreference, useSetting } from '@rocket.chat/ui-contexts'; -import type { MomentInput } from 'moment'; -import moment from 'moment'; import { useCallback } from 'react'; import { t } from '../../app/utils/lib/i18n'; +import { formatTimeAgo } from '../lib/utils/dateFormat'; const dayFormat = ['h:mm A', 'H:mm'] as const; @@ -11,13 +10,16 @@ export const useTimeAgo = () => { const clockMode = useUserPreference<1 | 2>('clockMode'); const timeFormat = useSetting('Message_TimeFormat', 'LT'); const format = clockMode !== undefined ? dayFormat[clockMode - 1] : timeFormat; + return useCallback( - (time: MomentInput) => { - return moment(time).calendar(null, { - sameDay: format, - lastDay: moment(time).calendar('lastDay').replace('LT', format), - lastWeek: `dddd ${format}`, - sameElse: 'LL', + (time: string | Date | number) => { + return formatTimeAgo(time, { + sameDayFormat: format, + yesterdayLabel: t('Yesterday_at'), + lastDayFormat: format, + lastWeekFormat: `dddd ${format}`, + otherFormat: 'LL', + otherYearFormat: 'LL', }); }, [format], @@ -28,27 +30,15 @@ export const useShortTimeAgo = () => { const clockMode = useUserPreference<1 | 2>('clockMode'); const timeFormat = useSetting('Message_TimeFormat', 'LT'); const format = clockMode !== undefined ? dayFormat[clockMode - 1] : timeFormat; - return useCallback( - (time: MomentInput) => - moment(time).calendar(null, { - sameDay: format, - lastDay: `[${t('Yesterday')}]`, - lastWeek: 'dddd', - sameElse(this: moment.Moment, now) { - /* - Using only this.isBefore(): - ERRORS: - Cannot invoke an object which is possibly 'undefined'. - This expression is not callable. - Not all constituents of type 'CalendarSpecVal' are callable. - Type 'string' has no call signatures. - */ - if (this.isBefore(now, 'year')) { - return 'LL'; - } - return 'MMM Do'; - }, + return useCallback( + (time: string | Date | number) => + formatTimeAgo(time, { + sameDayFormat: format, + yesterdayLabel: t('Yesterday'), + lastWeekFormat: 'dddd', + otherFormat: 'MMM Do', + otherYearFormat: 'LL', }), [format], ); diff --git a/apps/meteor/client/hooks/useTimeFromNow.ts b/apps/meteor/client/hooks/useTimeFromNow.ts index 3cda1e37c2713..6d8af9aa265e1 100644 --- a/apps/meteor/client/hooks/useTimeFromNow.ts +++ b/apps/meteor/client/hooks/useTimeFromNow.ts @@ -1,5 +1,6 @@ -import moment from 'moment'; import { useCallback } from 'react'; +import { formatFromNow } from '../lib/utils/dateFormat'; + export const useTimeFromNow = (withSuffix: boolean) => - useCallback((date?: Date | string) => moment(date).fromNow(!withSuffix), [withSuffix]); + useCallback((date?: Date | string) => formatFromNow(date ?? new Date(), withSuffix), [withSuffix]); diff --git a/apps/meteor/client/hooks/useTimezoneNameList.ts b/apps/meteor/client/hooks/useTimezoneNameList.ts index 6c8547e037a7c..3c19cba8e56e7 100644 --- a/apps/meteor/client/hooks/useTimezoneNameList.ts +++ b/apps/meteor/client/hooks/useTimezoneNameList.ts @@ -1,4 +1,8 @@ -import moment from 'moment-timezone'; +import { getTimezoneNames } from '@rocket.chat/tools'; import { useMemo } from 'react'; -export const useTimezoneNameList = (): string[] => useMemo(() => moment.tz.names(), []); +export const useTimezoneNameList = (): string[] => + useMemo(() => { + const names = getTimezoneNames(); + return names.includes('UTC') ? names : ['UTC', ...names]; + }, []); diff --git a/apps/meteor/client/hooks/useTimezoneTime.ts b/apps/meteor/client/hooks/useTimezoneTime.ts index d6ce1a7929280..10373f288bd40 100644 --- a/apps/meteor/client/hooks/useTimezoneTime.ts +++ b/apps/meteor/client/hooks/useTimezoneTime.ts @@ -1,11 +1,9 @@ -import moment from 'moment'; import { useState, useEffect } from 'react'; import { useFormatTime } from './useFormatTime'; export const useTimezoneTime = (offset: number, interval = 1000): string => { - const [time, setTime] = useState(() => moment().utcOffset(offset)); - + const [now, setNow] = useState(() => new Date()); const format = useFormatTime(); useEffect(() => { @@ -13,17 +11,10 @@ export const useTimezoneTime = (offset: number, interval = 1000): string => { return; } - const update = (): void => { - setTime(moment().utcOffset(offset)); - }; - - const timer = setInterval(update, interval); - update(); - - return (): void => { - clearInterval(timer); - }; + const timer = setInterval(() => setNow(new Date()), interval); + return () => clearInterval(timer); }, [offset, interval]); - return format(time); + const shifted = new Date(now.getTime() + offset * 3600000 + now.getTimezoneOffset() * 60000); + return format(shifted); }; diff --git a/apps/meteor/client/hooks/useUserDropdownAppsActionButtons.ts b/apps/meteor/client/hooks/useUserDropdownAppsActionButtons.ts index eb1f5a3823fff..a7277b1bc601d 100644 --- a/apps/meteor/client/hooks/useUserDropdownAppsActionButtons.ts +++ b/apps/meteor/client/hooks/useUserDropdownAppsActionButtons.ts @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'; import { useAppActionButtons } from './useAppActionButtons'; import { useApplyButtonAuthFilter } from './useApplyButtonFilters'; import { UiKitTriggerTimeoutError } from '../../app/ui-message/client/UiKitTriggerTimeoutError'; +import { Utilities } from '../../ee/lib/misc/Utilities'; import { useUiKitActionManager } from '../uikit/hooks/useUiKitActionManager'; export const useUserDropdownAppsActionButtons = () => { @@ -25,7 +26,7 @@ export const useUserDropdownAppsActionButtons = () => { return { id: `${action.appId}_${action.actionId}`, // icon: action.icon as GenericMenuItemProps['icon'], - content: action.labelI18n, + content: t(Utilities.getI18nKeyForApp(action.labelI18n, action.appId)), onClick: () => { void actionManager .emitInteraction(action.appId, { diff --git a/apps/meteor/client/lib/chats/ChatAPI.ts b/apps/meteor/client/lib/chats/ChatAPI.ts index b7155a71cd3e5..db2e61ad640af 100644 --- a/apps/meteor/client/lib/chats/ChatAPI.ts +++ b/apps/meteor/client/lib/chats/ChatAPI.ts @@ -23,7 +23,11 @@ export type ComposerAPI = { | ((previous: { readonly start: number; readonly end: number }) => { readonly start?: number; readonly end?: number }); }, ): void; - wrapSelection(pattern: string): void; + wrapSelection(pattern: string): { + selectionStart: number; + selectionEnd: number; + value: string; + }; insertText(text: string): void; insertNewLine(): void; clear(): void; @@ -118,6 +122,7 @@ export type UploadsAPI = { cancel(id: Upload['id']): void; removeUpload(id: Upload['id']): void; editUploadFileName: (id: Upload['id'], fileName: string) => void; + editUploadDescription: (id: Upload['id'], description: string) => void; send(file: File, encrypted?: never): Promise; send(file: File, encrypted: EncryptedFileUploadContent): Promise; }; diff --git a/apps/meteor/client/lib/chats/Upload.ts b/apps/meteor/client/lib/chats/Upload.ts index 798d955032049..6916d90b9101c 100644 --- a/apps/meteor/client/lib/chats/Upload.ts +++ b/apps/meteor/client/lib/chats/Upload.ts @@ -6,6 +6,7 @@ export type NonEncryptedUpload = { readonly url?: string; readonly percentage: number; readonly error?: Error; + readonly description?: string; }; export type EncryptedUpload = NonEncryptedUpload & { diff --git a/apps/meteor/client/lib/chats/data.ts b/apps/meteor/client/lib/chats/data.ts index 5206e802e7d0c..bd02100bb9e87 100644 --- a/apps/meteor/client/lib/chats/data.ts +++ b/apps/meteor/client/lib/chats/data.ts @@ -1,7 +1,7 @@ import { isEncryptedMessageContent, type IEditedMessage, type IMessage, type IRoom, type ISubscription } from '@rocket.chat/core-typings'; import { MessageTypes } from '@rocket.chat/message-types'; import { Random } from '@rocket.chat/random'; -import moment from 'moment'; +import { differenceInMinutes } from 'date-fns'; import type { DataAPI } from './ChatAPI'; import { hasAtLeastOnePermission, hasPermission } from '../../../app/authorization/client'; @@ -77,7 +77,7 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage const blockEditInMinutes = settings.peek('Message_AllowEditing_BlockEditInMinutes') as number | undefined; const bypassBlockTimeLimit = hasPermission('bypass-time-limit-edit-and-delete', message.rid); - const elapsedMinutes = moment().diff(message.ts, 'minutes'); + const elapsedMinutes = differenceInMinutes(new Date(), message.ts); if (!bypassBlockTimeLimit && elapsedMinutes && blockEditInMinutes && elapsedMinutes > blockEditInMinutes) { return false; } @@ -215,7 +215,7 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage const blockDeleteInMinutes = settings.peek('Message_AllowDeleting_BlockDeleteInMinutes') as number | undefined; const bypassBlockTimeLimit = hasPermission('bypass-time-limit-edit-and-delete', message.rid); - const elapsedMinutes = moment().diff(message.ts, 'minutes'); + const elapsedMinutes = differenceInMinutes(new Date(), message.ts); const onTimeForDelete = bypassBlockTimeLimit || !blockDeleteInMinutes || !elapsedMinutes || elapsedMinutes <= blockDeleteInMinutes; return deleteAllowed && onTimeForDelete; diff --git a/apps/meteor/client/lib/chats/flows/processMessageUploads.ts b/apps/meteor/client/lib/chats/flows/processMessageUploads.ts index 2736bd9fbf8d5..10ac33f930e52 100644 --- a/apps/meteor/client/lib/chats/flows/processMessageUploads.ts +++ b/apps/meteor/client/lib/chats/flows/processMessageUploads.ts @@ -59,6 +59,7 @@ const getAttachmentForFile = async (fileToUpload: EncryptedUpload): Promise & { fileName?: string; fileContent?: IE2EEMessage['content'] }; + composedMessage: AtLeast & { + fileName?: string; + fileContent?: IE2EEMessage['content']; + description?: string; + }; })[] = []; const validFiles = filesToUpload.filter((file) => !file.error); @@ -118,7 +123,7 @@ async function continueSendingMessage(store: UploadsAPI, message: IMessage) { confirmFilesQueue.push({ _id: upload.id, name: upload.file.name, - composedMessage: { tmid, msg: currentMsg, fileName: upload.file.name }, + composedMessage: { tmid, msg: currentMsg, fileName: upload.file.name, description: upload.description }, }); continue; } diff --git a/apps/meteor/client/lib/chats/readStateManager.ts b/apps/meteor/client/lib/chats/readStateManager.ts index 4827ecdc97ba7..f464093b79476 100644 --- a/apps/meteor/client/lib/chats/readStateManager.ts +++ b/apps/meteor/client/lib/chats/readStateManager.ts @@ -76,7 +76,9 @@ export class ReadStateManager extends Emitter { const firstUnreadRecord = Messages.state.findFirst( (record) => - record.rid === this.subscription?.rid && record.ts.getTime() > this.subscription.ls.getTime() && record.u._id !== getUserId(), + record.rid === this.subscription?.rid && + record.ts.getTime() > (this.subscription.ls?.getTime() ?? 0) && + record.u._id !== getUserId(), (a, b) => a.ts.getTime() - b.ts.getTime(), ); diff --git a/apps/meteor/client/lib/chats/uploads.ts b/apps/meteor/client/lib/chats/uploads.ts index d243389333a9a..b39542618c24a 100644 --- a/apps/meteor/client/lib/chats/uploads.ts +++ b/apps/meteor/client/lib/chats/uploads.ts @@ -63,6 +63,24 @@ class UploadsStore extends Emitter<{ update: void; [x: `cancelling-${Upload['id' } }; + editUploadDescription = (uploadId: Upload['id'], description: string) => { + this.set( + this.uploads.map((upload) => { + if (upload.id !== uploadId) { + return upload; + } + + return { + ...upload, + description, + ...(isEncryptedUpload(upload) && { + metadataForEncryption: { ...upload.metadataForEncryption, description }, + }), + }; + }), + ); + }; + editUploadFileName = (uploadId: Upload['id'], fileName: Upload['file']['name']) => { try { this.set( diff --git a/apps/meteor/client/lib/constants.ts b/apps/meteor/client/lib/constants.ts index d144e03fec2ef..70cae5deff224 100644 --- a/apps/meteor/client/lib/constants.ts +++ b/apps/meteor/client/lib/constants.ts @@ -2,3 +2,4 @@ export const USER_STATUS_TEXT_MAX_LENGTH = 120; export const BIO_TEXT_MAX_LENGTH = 260; export const VIDEOCONF_STACK_MAX_USERS = 6; export const NAVIGATION_REGION_ID = 'navigation-region'; +export const MAX_FILE_SIZE_PREVIEW = 10485760; // 10MB diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index fa88249550726..c5418ef9967cc 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -7,7 +7,7 @@ import { Emitter } from '@rocket.chat/emitter'; import { isTruthy } from '@rocket.chat/tools'; import { imperativeModal } from '@rocket.chat/ui-client'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; -import _ from 'lodash'; +import sampleSize from 'lodash/sampleSize'; import { Accounts } from 'meteor/accounts-base'; import type { E2EEState } from './E2EEState'; @@ -793,7 +793,7 @@ class E2E extends Emitter { return []; } - const randomRoomIds = _.sampleSize(roomIds, ROOM_KEY_EXCHANGE_SIZE); + const randomRoomIds = sampleSize(roomIds, ROOM_KEY_EXCHANGE_SIZE); const sampleIds: string[] = []; for await (const roomId of randomRoomIds) { diff --git a/apps/meteor/client/lib/normalizeThreadMessage.tsx b/apps/meteor/client/lib/normalizeThreadMessage.tsx index 1efa853f3679e..7f13803d2f80a 100644 --- a/apps/meteor/client/lib/normalizeThreadMessage.tsx +++ b/apps/meteor/client/lib/normalizeThreadMessage.tsx @@ -25,11 +25,7 @@ export function normalizeThreadMessage({ ...message }: Readonly attachment.title || attachment.description); - - if (attachment?.description) { - return <>{attachment.description}; - } + const attachment = message.attachments.find((attachment) => attachment.title); if (attachment?.title) { return <>{attachment.title}; diff --git a/apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts index e48eb15f885cf..0105608949b7f 100644 --- a/apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts +++ b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts @@ -178,47 +178,6 @@ describe('parseMessageTextToAstMarkdown', () => { }); it('should return correct attachment translated parsed md when translate is active', () => { - const attachmentTranslatedMessage = { - ...translatedMessage, - attachments: [ - { - description: 'description', - translations: { - en: 'description translated', - }, - }, - ], - }; - const attachmentTranslatedMessageParsed = { - ...translatedMessage, - md: translatedMessageParsed, - attachments: [ - { - description: 'description', - translations: { - en: 'description translated', - }, - md: [ - { - type: 'PARAGRAPH', - value: [ - { - type: 'PLAIN_TEXT', - value: 'description translated', - }, - ], - }, - ], - }, - ], - }; - - expect(parseMessageTextToAstMarkdown(attachmentTranslatedMessage, parseOptions, enabledAutoTranslatedOptions)).toStrictEqual( - attachmentTranslatedMessageParsed, - ); - }); - - it('should return correct attachment quote translated parsed md when translate is active', () => { const attachmentTranslatedMessage = { ...translatedMessage, attachments: [ @@ -378,7 +337,7 @@ describe('parseMessageAttachments', () => { const attachmentMessage = [ { - description: 'message **bold** _italic_ and ~strike~', + text: 'message **bold** _italic_ and ~strike~', md: messageParserTokenMessage, }, ]; @@ -400,46 +359,18 @@ describe('parseMessageAttachments', () => { autoTranslateLanguage: 'en', }; - it('should return correct attachment description translated parsed md when translate is active', () => { - const descriptionAttachment = [ - { - ...attachmentMessage[0], - description: 'attachment not translated', - translationProvider: 'provider', - translations: { - en: 'attachment translated', - }, - }, - ]; - const descriptionAttachmentParsed: Root = [ - { - type: 'PARAGRAPH', - value: [ - { - type: 'PLAIN_TEXT', - value: 'attachment translated', - }, - ], - }, - ]; - - expect(parseMessageAttachments(descriptionAttachment, parseOptions, enabledAutoTranslatedOptions)[0].md).toStrictEqual( - descriptionAttachmentParsed, - ); - }); - - it('should return correct attachment description parsed md when translate is active and auto translate language is undefined', () => { - const descriptionAttachment = [ + it('should return correct attachment text parsed md when translate is active and auto translate language is undefined', () => { + const textAttachment = [ { ...attachmentMessage[0], - description: 'attachment not translated', + text: 'attachment not translated', translationProvider: 'provider', translations: { en: 'attachment translated', }, }, ]; - const descriptionAttachmentParsed: Root = [ + const textAttachmentParsed: Root = [ { type: 'PARAGRAPH', value: [ @@ -452,39 +383,11 @@ describe('parseMessageAttachments', () => { ]; expect( - parseMessageAttachments(descriptionAttachment, parseOptions, { + parseMessageAttachments(textAttachment, parseOptions, { ...enabledAutoTranslatedOptions, autoTranslateLanguage: undefined, })[0].md, - ).toStrictEqual(descriptionAttachmentParsed); - }); - - it('should return correct attachment text translated parsed md when translate is active', () => { - const textAttachment = [ - { - ...attachmentMessage[0], - text: 'attachment not translated', - translationProvider: 'provider', - translations: { - en: 'attachment translated', - }, - }, - ]; - const textAttachmentParsed: Root = [ - { - type: 'PARAGRAPH', - value: [ - { - type: 'PLAIN_TEXT', - value: 'attachment translated', - }, - ], - }, - ]; - - expect(parseMessageAttachments(textAttachment, parseOptions, enabledAutoTranslatedOptions)[0].md).toStrictEqual( - textAttachmentParsed, - ); + ).toStrictEqual(textAttachmentParsed); }); it('should return correct attachment text translated parsed md when translate is active and has multiple texts', () => { diff --git a/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts index df84785e26351..55d393cee38ed 100644 --- a/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts +++ b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts @@ -1,12 +1,5 @@ import type { IMessage, ITranslatedMessage, MessageAttachment } from '@rocket.chat/core-typings'; -import { - isFileAttachment, - isE2EEMessage, - isQuoteAttachment, - isTranslatedAttachment, - isTranslatedMessage, - isEncryptedMessageAttachment, -} from '@rocket.chat/core-typings'; +import { isE2EEMessage, isQuoteAttachment, isTranslatedAttachment, isTranslatedMessage } from '@rocket.chat/core-typings'; import type { Options, Root } from '@rocket.chat/message-parser'; import { parse } from '@rocket.chat/message-parser'; @@ -58,7 +51,7 @@ export const parseMessageAttachment = ( autoTranslateOptions: { autoTranslateLanguage?: string; translated: boolean }, ): T => { const { translated, autoTranslateLanguage } = autoTranslateOptions; - if (!attachment.text && !attachment.description) { + if (!attachment.text) { return attachment; } @@ -69,16 +62,8 @@ export const parseMessageAttachment = ( const text = (isTranslatedAttachment(attachment) && autoTranslateLanguage && attachment?.translations?.[autoTranslateLanguage]) || attachment.text || - attachment.description || ''; - if (isFileAttachment(attachment) && attachment.description) { - attachment.descriptionMd = - translated || isEncryptedMessageAttachment(attachment) - ? textToMessageToken(text, parseOptions) - : (attachment.descriptionMd ?? textToMessageToken(text, parseOptions)); - } - return { ...attachment, md: translated ? textToMessageToken(text, parseOptions) : (attachment.md ?? textToMessageToken(text, parseOptions)), diff --git a/apps/meteor/client/lib/presence.spec.ts b/apps/meteor/client/lib/presence.spec.ts new file mode 100644 index 0000000000000..5e630bd4f03da --- /dev/null +++ b/apps/meteor/client/lib/presence.spec.ts @@ -0,0 +1,51 @@ +import { UserStatus } from '@rocket.chat/core-typings'; + +import { Presence } from './presence'; + +jest.mock('meteor/meteor', () => ({ + Meteor: { + subscribe: jest.fn(), + }, +})); + +const mockGet = jest.fn(); + +jest.mock('../../app/utils/client/lib/SDKClient', () => ({ + sdk: { + rest: { + get: (...args: unknown[]) => mockGet(...args), + }, + }, +})); + +describe('Presence fallback status', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + Presence.store.clear(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should use DISABLED as fallback when status is set to disabled', async () => { + mockGet.mockResolvedValue({ users: [] }); + Presence.setStatus('disabled'); + + Presence.listen('user1', jest.fn()); + await jest.advanceTimersByTimeAsync(500); + + expect(Presence.store.get('user1')?.status).toBe(UserStatus.DISABLED); + }); + + it('should use OFFLINE as fallback when status is set to enabled', async () => { + mockGet.mockResolvedValue({ users: [] }); + Presence.setStatus('enabled'); + + Presence.listen('user1', jest.fn()); + await jest.advanceTimersByTimeAsync(500); + + expect(Presence.store.get('user1')?.status).toBe(UserStatus.OFFLINE); + }); +}); diff --git a/apps/meteor/client/lib/presence.ts b/apps/meteor/client/lib/presence.ts index ebd42bc41abd9..16a5282c56921 100644 --- a/apps/meteor/client/lib/presence.ts +++ b/apps/meteor/client/lib/presence.ts @@ -73,6 +73,8 @@ const getPresence = ((): ((uid: UserPresence['_id']) => void) => { const { users } = await sdk.rest.get('/v1/users.presence', params); + const fallbackStatus = status === 'disabled' ? UserStatus.DISABLED : UserStatus.OFFLINE; + users.forEach((user) => { if (!store.has(user._id)) { notify(user); @@ -81,7 +83,7 @@ const getPresence = ((): ((uid: UserPresence['_id']) => void) => { }); currentUids.forEach((uid) => { - notify({ _id: uid, status: UserStatus.OFFLINE }); + notify({ _id: uid, status: fallbackStatus }); }); currentUids.clear(); diff --git a/apps/meteor/client/lib/userData.ts b/apps/meteor/client/lib/userData.ts index 2b768bad7a597..9ae32b29dfca5 100644 --- a/apps/meteor/client/lib/userData.ts +++ b/apps/meteor/client/lib/userData.ts @@ -64,7 +64,6 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise { switch (data.type) { case 'inserted': { - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { type, id, ...user } = data; Users.state.store(user.data); break; @@ -85,13 +84,40 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise; + + const mergedEmail2fa = + email2fa && + (() => { + const { changedAt: apiChangedAt, ...email2faRest } = email2fa; + let changedAt: Date | undefined; + if (apiChangedAt != null) { + const parsed = new Date(apiChangedAt as string | number | Date); + if (!Number.isNaN(parsed.getTime())) { + changedAt = parsed; + } + } + if (changedAt == null && existingUser?.services?.email2fa?.changedAt != null) { + const prev = existingUser.services.email2fa.changedAt; + changedAt = prev instanceof Date ? prev : new Date(prev); + } + return { + ...email2faRest, + ...(changedAt != null ? { changedAt } : {}), + }; + })(); updateUser({ - ...userData, + type: existingUser?.type ?? 'user', + active: existingUser?.active ?? true, + roles: existingUser?.roles ?? [], + ...existingUser, + ...meFields, ...(rawServices && { services: { ...(services ? { ...services } : {}), @@ -101,8 +127,8 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise ({ ...token, - when: new Date('when' in token ? token.when : ''), - createdAt: ('createdAt' in token ? new Date(token.createdAt) : undefined) as Date, + when: new Date('when' in token && token.when ? token.when : 0), + createdAt: 'createdAt' in token && token.createdAt ? new Date(token.createdAt) : new Date(0), twoFactorAuthorizedUntil: token.twoFactorAuthorizedUntil ? new Date(token.twoFactorAuthorizedUntil) : undefined, })), }), @@ -118,7 +144,7 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise ({ @@ -133,13 +159,13 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise { diff --git a/apps/meteor/client/lib/utils/dateFormat.spec.ts b/apps/meteor/client/lib/utils/dateFormat.spec.ts new file mode 100644 index 0000000000000..bc5a4b1824168 --- /dev/null +++ b/apps/meteor/client/lib/utils/dateFormat.spec.ts @@ -0,0 +1,143 @@ +import { formatDate, momentFormatToDateFns } from './dateFormat'; + +describe('momentFormatToDateFns', () => { + it('maps locale tokens', () => { + expect(momentFormatToDateFns('L')).toBe('P'); + expect(momentFormatToDateFns('LT')).toBe('p'); + expect(momentFormatToDateFns('LTS')).toBe('pp'); + expect(momentFormatToDateFns('LL')).toBe('PPP'); + expect(momentFormatToDateFns('LLL')).toBe('PPP p'); + expect(momentFormatToDateFns('LLLL')).toBe('EEEE, PPP p'); + }); + + it('maps common tokens', () => { + expect(momentFormatToDateFns('YYYY-MM-DD HH:mm:ss')).toBe('yyyy-MM-dd HH:mm:ss'); + expect(momentFormatToDateFns('MMMM Do YYYY, h:mm:ss a')).toBe('MMMM do yyyy, h:mm:ss aaa'); + }); + + it('preserves AM/PM casing (Moment A = uppercase, a = lowercase)', () => { + // date-fns `a` is always uppercase; `aaa` is lowercase. So Moment `a` → `aaa`. + expect(momentFormatToDateFns('A')).toBe('a'); + expect(momentFormatToDateFns('a')).toBe('aaa'); + }); + + it('translates moment [literal] escape to date-fns single-quoted literal', () => { + expect(momentFormatToDateFns('[Today at] LT')).toBe("'Today at' p"); + expect(momentFormatToDateFns('[Session started at] HH:mm [on] LL')).toBe("'Session started at' HH:mm 'on' PPP"); + }); + + it("escapes embedded single quotes inside literals as ''", () => { + expect(momentFormatToDateFns("[it's] LT")).toBe("'it''s' p"); + }); + + it('drops empty literal blocks since date-fns has no empty-string syntax', () => { + // In date-fns, '' represents a literal apostrophe, not an empty string. + expect(momentFormatToDateFns('[] LT')).toBe(' p'); + }); + + it('quotes letters that are not Moment tokens (T in ISO 8601 separator)', () => { + // In Moment, T is a literal; in date-fns T = ms timestamp. Must quote. + expect(momentFormatToDateFns('YYYY-MM-DDTHH:mm:ss')).toBe("yyyy-MM-dd'T'HH:mm:ss"); + }); + + it('maps day-of-year tokens (DDD, DDDo, DDDD)', () => { + expect(momentFormatToDateFns('DDD')).toBe('D'); + expect(momentFormatToDateFns('DDDo')).toBe('Do'); + expect(momentFormatToDateFns('DDDD')).toBe('DDD'); + }); + + it('maps day-of-week ordinal (do)', () => { + expect(momentFormatToDateFns('do')).toBe('io'); + }); + + it('maps the 6-digit padded year (YYYYYY)', () => { + expect(momentFormatToDateFns('YYYYYY')).toBe('yyyyyy'); + }); + + it('maps timezone abbreviation tokens (z, zz)', () => { + expect(momentFormatToDateFns('z')).toBe('zzz'); + expect(momentFormatToDateFns('zz')).toBe('zzz'); + }); + + it('maps extended fractional-second tokens (SSSS…SSSSSSSSS)', () => { + expect(momentFormatToDateFns('SSSS')).toBe('SSSS'); + expect(momentFormatToDateFns('SSSSSSSSS')).toBe('SSSSSSSSS'); + }); + + it('maps era year (y) and era name (N…NNNNN)', () => { + expect(momentFormatToDateFns('y')).toBe('y'); + expect(momentFormatToDateFns('N')).toBe('G'); + expect(momentFormatToDateFns('NN')).toBe('GG'); + expect(momentFormatToDateFns('NNN')).toBe('GGG'); + expect(momentFormatToDateFns('NNNN')).toBe('GGGG'); + expect(momentFormatToDateFns('NNNNN')).toBe('GGGGG'); + }); + + it('maps lowercase locale variants (l, ll, lll, llll)', () => { + expect(momentFormatToDateFns('l')).toBe('P'); + expect(momentFormatToDateFns('ll')).toBe('PP'); + expect(momentFormatToDateFns('lll')).toBe('PP p'); + expect(momentFormatToDateFns('llll')).toBe('EEE, PP p'); + }); + + it('preserves tokens that are identical between Moment and date-fns (kk, Q, ww)', () => { + // Regression: these used to pass through unchanged; tokenizer now must list them + // explicitly so they aren't quoted as literals. + expect(momentFormatToDateFns('kk:mm')).toBe('kk:mm'); + expect(momentFormatToDateFns('k:mm')).toBe('k:mm'); + expect(momentFormatToDateFns('Q')).toBe('Q'); + expect(momentFormatToDateFns('ww')).toBe('ww'); + }); + + it('maps ISO week-of-year tokens (W, WW, Wo)', () => { + expect(momentFormatToDateFns('W')).toBe('I'); + expect(momentFormatToDateFns('WW')).toBe('II'); + expect(momentFormatToDateFns('Wo')).toBe('Io'); + }); + + it('maps week-year tokens (gg/gggg locale, GG/GGGG ISO)', () => { + expect(momentFormatToDateFns('gg')).toBe('YY'); + expect(momentFormatToDateFns('gggg')).toBe('YYYY'); + expect(momentFormatToDateFns('GG')).toBe('RR'); + expect(momentFormatToDateFns('GGGG')).toBe('RRRR'); + }); + + it('maps Moment timezone offset tokens to date-fns equivalents', () => { + expect(momentFormatToDateFns('Z')).toBe('xxx'); + expect(momentFormatToDateFns('ZZ')).toBe('xx'); + expect(momentFormatToDateFns('Z ZZ')).toBe('xxx xx'); + expect(momentFormatToDateFns('LT Z')).toBe('p xxx'); + expect(momentFormatToDateFns('YYYY-MM-DDTHH:mm:ssZ')).toBe("yyyy-MM-dd'T'HH:mm:ssxxx"); + }); +}); + +describe('formatDate', () => { + const sample = new Date('2026-04-24T20:30:45'); + + it('formats literal blocks with locale tokens without throwing', () => { + expect(() => formatDate(sample, '[Today at] LT')).not.toThrow(); + expect(formatDate(sample, '[Today at] LT')).toMatch(/^Today at /); + }); + + it('keeps the ISO 8601 T as a literal instead of inserting a ms timestamp', () => { + expect(formatDate(sample, 'YYYY-MM-DDTHH:mm:ss')).toBe('2026-04-24T20:30:45'); + }); + + it('does not throw on Moment timezone tokens', () => { + expect(() => formatDate(sample, 'LT Z')).not.toThrow(); + expect(() => formatDate(sample, 'Z ZZ')).not.toThrow(); + expect(() => formatDate(sample, 'YYYY-MM-DDTHH:mm:ssZ')).not.toThrow(); + }); + + it('falls back instead of crashing on a malformed format', () => { + // Unterminated bracket — translator buffers but date-fns may still refuse. + expect(() => formatDate(sample, '[unterminated')).not.toThrow(); + }); + + it('formats week-year tokens without throwing on date-fns Y warning', () => { + // date-fns refuses Y/YY/YYYY without useAdditionalWeekYearTokens — verify the + // option is wired through. + expect(() => formatDate(sample, 'gggg')).not.toThrow(); + expect(formatDate(sample, 'gggg')).toMatch(/^\d{4}$/); + }); +}); diff --git a/apps/meteor/client/lib/utils/dateFormat.ts b/apps/meteor/client/lib/utils/dateFormat.ts new file mode 100644 index 0000000000000..fdcb91dde3344 --- /dev/null +++ b/apps/meteor/client/lib/utils/dateFormat.ts @@ -0,0 +1,243 @@ +import { format, formatDistanceToNow, formatDuration, intervalToDuration, differenceInCalendarDays } from 'date-fns'; +import type { Locale } from 'date-fns'; + +export type DateInput = string | Date | number; + +const FALLBACK_FORMAT = 'PPP p'; // date-fns equivalent of moment's LLL + +const MOMENT_TO_DATE_FNS_TOKENS: ReadonlyArray = ( + [ + // Locale formats + ['LLLL', 'EEEE, PPP p'], + ['LTS', 'pp'], + ['LLL', 'PPP p'], + ['LL', 'PPP'], + ['LT', 'p'], + ['L', 'P'], + // Locale formats — short variants (Moment lowercase l = no zero-padding; + // date-fns has no equivalent without zero-padding, so PP/P is the closest match) + ['llll', 'EEE, PP p'], + ['lll', 'PP p'], + ['ll', 'PP'], + ['l', 'P'], + // Year + ['YYYYYY', 'yyyyyy'], // 6-digit padded; Moment includes a +/- sign that date-fns omits + ['YYYY', 'yyyy'], + ['YY', 'yy'], + ['Y', 'yyyy'], + ['y', 'y'], // Moment lowercase y = era year (always positive); equivalent to calendar year for AD dates + // Era — Moment N/NN/NNN are abbreviated, NNNN wide, NNNNN narrow + ['NNNNN', 'GGGGG'], + ['NNNN', 'GGGG'], + ['NNN', 'GGG'], + ['NN', 'GG'], + ['N', 'G'], + // Month + ['MMMM', 'MMMM'], + ['MMM', 'MMM'], + ['MM', 'MM'], + ['Mo', 'Mo'], + ['M', 'M'], + // Day of month + ['Do', 'do'], + ['DD', 'dd'], + ['D', 'd'], + // Day of year — needs `useAdditionalDayOfYearTokens` on date-fns + ['DDDD', 'DDD'], + ['DDDo', 'Do'], + ['DDD', 'D'], + // Day of week + ['dddd', 'EEEE'], + ['ddd', 'EEE'], + ['dd', 'EEEEEE'], + // Numeric day of week — semantics shift: Moment d/e produce 0-6 (Sun=0), + // date-fns i/c produce 1-7. No exact equivalent exists. + ['do', 'io'], + ['d', 'i'], + ['e', 'c'], + // ISO week of year + ['WW', 'II'], + ['Wo', 'Io'], + ['W', 'I'], + // Week-numbering year — Moment gg/gggg = locale, GG/GGGG = ISO. + // date-fns Y/YY/YYYY (locale) requires `useAdditionalWeekYearTokens`. + ['gggg', 'YYYY'], + ['gg', 'YY'], + ['GGGG', 'RRRR'], + ['GG', 'RR'], + // Hour (H = 0-23, h = 1-12, k = 1-24) + ['HH', 'HH'], + ['H', 'H'], + ['hh', 'hh'], + ['h', 'h'], + ['kk', 'kk'], + ['k', 'k'], + // Minute + ['mm', 'mm'], + ['m', 'm'], + // Second + ['ss', 'ss'], + ['s', 's'], + // Fractional second — JS Date only has ms precision; SSSS+ pad with zeros in both + ['SSSSSSSSS', 'SSSSSSSSS'], + ['SSSSSSSS', 'SSSSSSSS'], + ['SSSSSSS', 'SSSSSSS'], + ['SSSSSS', 'SSSSSS'], + ['SSSSS', 'SSSSS'], + ['SSSS', 'SSSS'], + ['SSS', 'SSS'], + ['SS', 'SS'], + ['S', 'S'], + // AM/PM — date-fns `a`/`aa` are always uppercase (AM/PM); `aaa` is lowercase (am/pm) + ['A', 'a'], + ['a', 'aaa'], + // Quarter + ['QQQQ', 'QQQQ'], + ['QQQ', 'QQQ'], + ['QQ', 'QQ'], + ['Qo', 'Qo'], + ['Q', 'Q'], + // Week of year (locale) + ['ww', 'ww'], + ['wo', 'wo'], + ['w', 'w'], + // ISO day of week (Moment E = 1-7 → date-fns i) + ['E', 'i'], + // Timezone offset (Moment Z = +05:00, ZZ = +0500) + ['ZZ', 'xx'], + ['Z', 'xxx'], + // Timezone abbreviated name (Moment z/zz = "EST"). date-fns has no + // real abbreviation; zzz produces "GMT-3" in browsers. Closest available. + ['zz', 'zzz'], + ['z', 'zzz'], + // Unix timestamp (Moment X = seconds, x = milliseconds) + ['X', 't'], + ['x', 'T'], + ] as Array<[moment: string, dateFns: string]> +).sort((a, b) => b[0].length - a[0].length); + +const LITERAL_LETTER = /[a-zA-Z]/; + +/** + * Translate a Moment.js format string to a date-fns format string. + * + * The two libraries diverge in two important ways that this function bridges: + * 1. Moment treats unrecognized letters as literals (so `T` in `YYYY-MM-DDTHH:mm:ss` + * prints as a literal `T`); date-fns reserves every letter as a token, so an + * unmapped letter either produces wrong output (`T` = ms timestamp) or throws. + * 2. Moment uses `Z`/`ZZ` for timezone offsets; date-fns has no `Z` token at all. + * + * The translator tokenizes left-to-right: it recognizes Moment's `[literal]` escape + * syntax, longest-matches a known Moment token, and quotes any other letter as a + * date-fns literal so admin-configured formats keep working after the moment→date-fns + * migration. Used by Message_DateFormat / Message_TimeFormat / Message_TimeAndDateFormat. + */ +export const momentFormatToDateFns = (momentFormat: string): string => { + let out = ''; + let literal = ''; + let i = 0; + + const flushLiteral = () => { + if (literal) { + out += `'${literal.replace(/'/g, "''")}'`; + literal = ''; + } + }; + + while (i < momentFormat.length) { + const ch = momentFormat[i]; + + if (ch === '[') { + const end = momentFormat.indexOf(']', i + 1); + if (end !== -1) { + literal += momentFormat.slice(i + 1, end); + i = end + 1; + continue; + } + } + + let matched = false; + for (const [mom, df] of MOMENT_TO_DATE_FNS_TOKENS) { + if (momentFormat.startsWith(mom, i)) { + flushLiteral(); + out += df; + i += mom.length; + matched = true; + break; + } + } + if (matched) continue; + + if (LITERAL_LETTER.test(ch)) { + literal += ch; + } else { + flushLiteral(); + out += ch; + } + i++; + } + + flushLiteral(); + return out; +}; + +const safeFormat = (d: Date, momentFormat: string, locale?: Locale): string => { + const options = { + ...(locale && { locale }), + useAdditionalWeekYearTokens: true, + useAdditionalDayOfYearTokens: true, + }; + try { + return format(d, momentFormatToDateFns(momentFormat), options); + } catch { + return format(d, FALLBACK_FORMAT, options); + } +}; + +export const formatDate = (date: DateInput, formatStr: string, locale?: Locale): string => { + const d = typeof date === 'object' && date instanceof Date ? date : new Date(date); + return safeFormat(d, formatStr, locale); +}; + +export const formatTimeAgo = ( + date: DateInput, + options: { + yesterdayLabel: string; + sameDayFormat: string; + lastDayFormat?: string; // if set, show "Yesterday" + format(time, lastDayFormat) + lastWeekFormat: string; + otherFormat: string; + otherYearFormat: string; + }, + locale?: Locale, +): string => { + const d = typeof date === 'object' && date instanceof Date ? date : new Date(date); + const now = new Date(); + const diffDays = differenceInCalendarDays(now, d); + + if (diffDays === 0) { + return safeFormat(d, options.sameDayFormat, locale); + } + if (diffDays === 1) { + if (options.lastDayFormat) { + return `${options.yesterdayLabel} ${safeFormat(d, options.lastDayFormat, locale)}`; + } + return options.yesterdayLabel; + } + if (diffDays > 1 && diffDays < 7) { + return safeFormat(d, options.lastWeekFormat, locale); + } + const diffYears = now.getFullYear() - d.getFullYear(); + const fmt = diffYears !== 0 ? options.otherYearFormat : options.otherFormat; + return safeFormat(d, fmt, locale); +}; + +export const formatFromNow = (date: DateInput, addSuffix: boolean, locale?: Locale): string => { + const d = typeof date === 'object' && date instanceof Date ? date : new Date(date); + return formatDistanceToNow(d, { addSuffix, locale }); +}; + +export const formatDurationMs = (timeMs: number, locale?: Locale): string => { + const duration = intervalToDuration({ start: 0, end: timeMs }); + return formatDuration(duration, { locale }); +}; diff --git a/apps/meteor/client/lib/utils/getDateRange.ts b/apps/meteor/client/lib/utils/getDateRange.ts index 6a7cf3b296ceb..2cf3e9148c181 100644 --- a/apps/meteor/client/lib/utils/getDateRange.ts +++ b/apps/meteor/client/lib/utils/getDateRange.ts @@ -1,15 +1,12 @@ -import moment from 'moment'; +import { startOfDay, endOfDay } from 'date-fns'; export const getDateRange = (): { start: string; end: string; } => { - const today = moment(new Date()); - const start = moment(new Date(today.year(), today.month(), today.date(), 0, 0, 0)); - const end = moment(new Date(today.year(), today.month(), today.date(), 23, 59, 59)); - + const now = new Date(); return { - start: start.toISOString(), - end: end.toISOString(), + start: startOfDay(now).toISOString(), + end: endOfDay(now).toISOString(), }; }; diff --git a/apps/meteor/client/lib/utils/isPreviewableImage.ts b/apps/meteor/client/lib/utils/isPreviewableImage.ts new file mode 100644 index 0000000000000..e17a3ae51378e --- /dev/null +++ b/apps/meteor/client/lib/utils/isPreviewableImage.ts @@ -0,0 +1,12 @@ +const PREVIEWABLE_IMAGE_MIMES = new Set([ + 'image/apng', + 'image/avif', + 'image/bmp', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/svg+xml', + 'image/webp', +]); + +export const isPreviewableImage = (mimeType: string): boolean => PREVIEWABLE_IMAGE_MIMES.has(mimeType.toLowerCase()); diff --git a/apps/meteor/client/lib/utils/isSafeAvatarUrl.spec.ts b/apps/meteor/client/lib/utils/isSafeAvatarUrl.spec.ts new file mode 100644 index 0000000000000..cacffaa460c2c --- /dev/null +++ b/apps/meteor/client/lib/utils/isSafeAvatarUrl.spec.ts @@ -0,0 +1,84 @@ +import { isSafeAvatarUrl } from './isSafeAvatarUrl'; + +describe('valid URLs', () => { + it('should accept HTTPS URLs', () => { + expect(isSafeAvatarUrl('https://example.com/avatar.png')).toBe(true); + expect(isSafeAvatarUrl('https://cdn.example.com/user/123/avatar.jpg')).toBe(true); + }); + + it('should accept HTTP URLs', () => { + expect(isSafeAvatarUrl('http://example.com/avatar.png')).toBe(true); + expect(isSafeAvatarUrl('http://localhost:3000/avatar.png')).toBe(true); + }); + + it('should accept data URLs with image MIME types', () => { + expect(isSafeAvatarUrl('data:image/png;base64,iVBORw0KGgoAAAANSUhEUg')).toBe(true); + expect(isSafeAvatarUrl('data:image/jpeg;base64,/9j/4AAQSkZJRg')).toBe(true); + expect(isSafeAvatarUrl('data:image/gif;base64,R0lGODlh')).toBe(true); + expect(isSafeAvatarUrl('data:image/webp;base64,UklGRiQA')).toBe(true); + expect(isSafeAvatarUrl('data:image/svg+xml;base64,PHN2Zw')).toBe(true); + }); + + it('should be case-insensitive for protocols', () => { + expect(isSafeAvatarUrl('HTTPS://example.com/avatar.png')).toBe(true); + expect(isSafeAvatarUrl('HTTP://example.com/avatar.png')).toBe(true); + expect(isSafeAvatarUrl('DATA:IMAGE/PNG;base64,iVBORw0KGgoAAAANSUhEUg')).toBe(true); + }); +}); + +describe('invalid URLs', () => { + it('should reject javascript: protocol', () => { + expect(isSafeAvatarUrl('javascript:alert(1)')).toBe(false); + expect(isSafeAvatarUrl('javascript:void(0)')).toBe(false); + }); + + it('should reject file: protocol', () => { + expect(isSafeAvatarUrl('file:///etc/passwd')).toBe(false); + expect(isSafeAvatarUrl('file:///C:/Windows/System32')).toBe(false); + }); + + it('should reject data: URLs with non-image MIME types', () => { + expect(isSafeAvatarUrl('data:text/html;base64,PHNjcmlwdD4')).toBe(false); + expect(isSafeAvatarUrl('data:text/javascript;base64,YWxlcnQoMSk')).toBe(false); + expect(isSafeAvatarUrl('data:application/javascript;base64,YWxlcnQoMSk')).toBe(false); + expect(isSafeAvatarUrl('data:text/plain;base64,SGVsbG8=')).toBe(false); + }); + + it('should reject ftp: protocol', () => { + expect(isSafeAvatarUrl('ftp://example.com/avatar.png')).toBe(false); + }); + + it('should reject malformed URLs', () => { + expect(isSafeAvatarUrl('not a url')).toBe(false); + expect(isSafeAvatarUrl('htp://missing-t.com')).toBe(false); + expect(isSafeAvatarUrl('://example.com')).toBe(false); + }); + + it('should reject empty strings', () => { + expect(isSafeAvatarUrl('')).toBe(false); + }); + + it('should reject other dangerous protocols', () => { + expect(isSafeAvatarUrl('vbscript:msgbox(1)')).toBe(false); + expect(isSafeAvatarUrl('data:text/html,')).toBe(false); + }); +}); + +describe('URL edge cases', () => { + it('should handle URLs with query parameters', () => { + expect(isSafeAvatarUrl('https://example.com/avatar.png?size=large&format=webp')).toBe(true); + }); + + it('should handle URLs with fragments', () => { + expect(isSafeAvatarUrl('https://example.com/avatar.png#profile')).toBe(true); + }); + + it('should handle URLs with authentication', () => { + expect(isSafeAvatarUrl('https://user:pass@example.com/avatar.png')).toBe(true); + }); + + it('should handle URLs with non-standard ports', () => { + expect(isSafeAvatarUrl('http://example.com:8080/avatar.png')).toBe(true); + expect(isSafeAvatarUrl('https://example.com:443/avatar.png')).toBe(true); + }); +}); diff --git a/apps/meteor/client/lib/utils/isSafeAvatarUrl.ts b/apps/meteor/client/lib/utils/isSafeAvatarUrl.ts new file mode 100644 index 0000000000000..8c57473a67207 --- /dev/null +++ b/apps/meteor/client/lib/utils/isSafeAvatarUrl.ts @@ -0,0 +1,17 @@ +export function isSafeAvatarUrl(urlString: string): boolean { + try { + const url = new URL(urlString); + const protocol = url.protocol.toLowerCase(); + + if (protocol === 'http:' || protocol === 'https:') { + return true; + } + if (protocol === 'data:') { + return urlString.toLowerCase().startsWith('data:image/'); + } + + return false; + } catch { + return false; + } +} diff --git a/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts b/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts index 358efaef0e9c6..fb8fbe581626b 100644 --- a/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts +++ b/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts @@ -12,8 +12,8 @@ export const mapSubscriptionFromApi = ({ }: Serialized): ISubscription => ({ ...subscription, ts: new Date(ts), - ls: new Date(ls), - lr: new Date(lr), + ...(ls && { ls: new Date(ls) }), + ...(lr && { lr: new Date(lr) }), _updatedAt: new Date(_updatedAt), ...(abacLastTimeChecked && { abacLastTimeChecked: new Date(abacLastTimeChecked) }), ...(oldRoomKeys && { oldRoomKeys: oldRoomKeys.map(({ ts, ...key }) => ({ ...key, ts: new Date(ts) })) }), diff --git a/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.spec.ts b/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.spec.ts index 184145a6c4506..9584c13531439 100644 --- a/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.spec.ts +++ b/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.spec.ts @@ -48,7 +48,7 @@ describe('normalizeMessagePreview', () => { }); describe('when message has attachments', () => { - it('should return attachment description when available', () => { + it('should return attachment title when description is available', () => { const message = createFakeMessageWithAttachment({ msg: '', attachments: [ @@ -60,10 +60,10 @@ describe('normalizeMessagePreview', () => { }); const result = normalizeMessagePreview(message, mockT); - expect(result).toBe('Attachment description'); + expect(result).toBe('Attachment title'); }); - it('should return attachment title when description is not available', () => { + it('should return attachment title when message is not provided', () => { const message = createFakeMessageWithAttachment({ msg: '', attachments: [ @@ -112,7 +112,7 @@ describe('normalizeMessagePreview', () => { expect(result).toBe('Second attachment title'); }); - it('should find first attachment description', () => { + it('should find first attachment title', () => { const message = createFakeMessageWithAttachment({ msg: '', attachments: [ @@ -129,21 +129,7 @@ describe('normalizeMessagePreview', () => { }); const result = normalizeMessagePreview(message, mockT); - expect(result).toBe('Second attachment description'); - }); - - it('should escape HTML in attachment description', () => { - const message = createFakeMessageWithAttachment({ - msg: '', - attachments: [ - { - description: '', - }, - ], - }); - const result = normalizeMessagePreview(message, mockT); - - expect(result).toBe('<script>alert("xss")</script>'); + expect(result).toBe('Third attachment title'); }); it('should escape HTML in attachment title', () => { diff --git a/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.ts b/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.ts index 53bf5bf2d4056..0fbefea5fa48d 100644 --- a/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.ts +++ b/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.ts @@ -11,11 +11,7 @@ export const normalizeMessagePreview = (message: IMessage, t: TFunction): string } if (message.attachments) { - const attachment = message.attachments.find((attachment) => attachment.title || attachment.description); - - if (attachment?.description) { - return escapeHTML(attachment.description); - } + const attachment = message.attachments.find((attachment) => attachment.title); if (attachment?.title) { return escapeHTML(attachment.title); diff --git a/apps/meteor/client/lib/utils/setDateFnsLocale.ts b/apps/meteor/client/lib/utils/setDateFnsLocale.ts new file mode 100644 index 0000000000000..2b7b8d9ceb15b --- /dev/null +++ b/apps/meteor/client/lib/utils/setDateFnsLocale.ts @@ -0,0 +1,8 @@ +import { setDefaultOptions } from 'date-fns'; + +import { getDateFnsLocale } from '../../../lib/getDateFnsLocale'; + +export const setDateFnsLocale = async (language: string): Promise => { + const locale = await getDateFnsLocale(language); + setDefaultOptions({ locale }); +}; diff --git a/apps/meteor/client/lib/utils/timeAgo.ts b/apps/meteor/client/lib/utils/timeAgo.ts index 98e5d936c2d6b..2826a218d98f8 100644 --- a/apps/meteor/client/lib/utils/timeAgo.ts +++ b/apps/meteor/client/lib/utils/timeAgo.ts @@ -1,26 +1,23 @@ import { Tracker } from 'meteor/tracker'; -import type { Moment, MomentInput } from 'moment'; -import moment from 'moment'; import { getUserPreference } from '../../../app/utils/client'; import { t } from '../../../app/utils/lib/i18n'; import { settings } from '../settings'; import { getUserId } from '../user'; +import { formatTimeAgo } from './dateFormat'; const dayFormat = ['h:mm A', 'H:mm']; -export const timeAgo = (date: MomentInput) => { +export const timeAgo = (date: string | Date | number) => { const clockMode = Tracker.nonreactive(() => getUserPreference(getUserId(), 'clockMode', false) as number | boolean); const messageTimeFormat = settings.peek('Message_TimeFormat'); const sameDay = (typeof clockMode === 'number' ? dayFormat[clockMode - 1] : undefined) || messageTimeFormat; - return moment(date).calendar(null, { - lastDay: `[${t('yesterday')}]`, - sameDay, - lastWeek: 'dddd', - sameElse(this: Moment, now) { - const diff = Math.ceil(this.diff(now, 'years', true)); - return diff < 0 ? 'MMM D YYYY' : 'MMM D'; - }, + return formatTimeAgo(date, { + yesterdayLabel: t('yesterday'), + sameDayFormat: sameDay, + lastWeekFormat: 'dddd', + otherFormat: 'MMM d', + otherYearFormat: 'MMM d yyyy', }); }; diff --git a/apps/meteor/client/navbar/NavBarNavigation.tsx b/apps/meteor/client/navbar/NavBarNavigation.tsx index 836fe7c453877..f9582e5a59043 100644 --- a/apps/meteor/client/navbar/NavBarNavigation.tsx +++ b/apps/meteor/client/navbar/NavBarNavigation.tsx @@ -1,6 +1,6 @@ +import { FocusScope } from '@react-aria/focus'; import { NavBarGroup, NavBarItem, Box } from '@rocket.chat/fuselage'; import { useLayout, useRouter } from '@rocket.chat/ui-contexts'; -import { FocusScope } from 'react-aria'; import { useTranslation } from 'react-i18next'; import NavBarSearch from './NavBarSearch'; diff --git a/apps/meteor/client/navbar/NavBarOmnichannelGroup/NavBarItemOmnichannelContact.tsx b/apps/meteor/client/navbar/NavBarOmnichannelGroup/NavBarItemOmnichannelContact.tsx index f0f25efb78d56..7bf1f125dd52b 100644 --- a/apps/meteor/client/navbar/NavBarOmnichannelGroup/NavBarItemOmnichannelContact.tsx +++ b/apps/meteor/client/navbar/NavBarOmnichannelGroup/NavBarItemOmnichannelContact.tsx @@ -8,7 +8,16 @@ type NavBarItemOmnichannelContactProps = Omit, 'is'> const NavBarItemOmnichannelContact = (props: NavBarItemOmnichannelContactProps) => { const { icon, isPressed, title, handleGoToContactCenter } = useOmnichannelContactAction(); - return ; + return ( + + ); }; export default NavBarItemOmnichannelContact; diff --git a/apps/meteor/client/navbar/NavBarOmnichannelGroup/NavBarItemOmnichannelQueue.tsx b/apps/meteor/client/navbar/NavBarOmnichannelGroup/NavBarItemOmnichannelQueue.tsx index c31ce4a296cd0..56fc6a59c4e16 100644 --- a/apps/meteor/client/navbar/NavBarOmnichannelGroup/NavBarItemOmnichannelQueue.tsx +++ b/apps/meteor/client/navbar/NavBarOmnichannelGroup/NavBarItemOmnichannelQueue.tsx @@ -12,7 +12,16 @@ const NavBarItemOmnichannelQueue = (props: NavBarItemOmnichannelQueueProps) => { return null; } - return ; + return ( + + ); }; export default NavBarItemOmnichannelQueue; diff --git a/apps/meteor/client/navbar/NavBarPagesGroup/NavBarItemDirectoryPage.tsx b/apps/meteor/client/navbar/NavBarPagesGroup/NavBarItemDirectoryPage.tsx index fa03a595a945c..3b28f53d4282b 100644 --- a/apps/meteor/client/navbar/NavBarPagesGroup/NavBarItemDirectoryPage.tsx +++ b/apps/meteor/client/navbar/NavBarPagesGroup/NavBarItemDirectoryPage.tsx @@ -12,7 +12,17 @@ const NavBarItemDirectoryPage = (props: NavBarItemDirectoryPageProps) => { }); const currentRoute = useCurrentRoutePath(); - return ; + const directoryRoute = currentRoute?.includes('/directory'); + + return ( + + ); }; export default NavBarItemDirectoryPage; diff --git a/apps/meteor/client/navbar/NavBarPagesGroup/NavBarItemHomePage.tsx b/apps/meteor/client/navbar/NavBarPagesGroup/NavBarItemHomePage.tsx index c6972ff42e702..061dbf10c3c8a 100644 --- a/apps/meteor/client/navbar/NavBarPagesGroup/NavBarItemHomePage.tsx +++ b/apps/meteor/client/navbar/NavBarPagesGroup/NavBarItemHomePage.tsx @@ -15,7 +15,11 @@ const NavBarItemHomePage = (props: NavBarItemHomePageProps) => { }); const currentRoute = useCurrentRoutePath(); - return showHome ? : null; + const homeRoute = currentRoute?.includes('/home'); + + return showHome ? ( + + ) : null; }; export default NavBarItemHomePage; diff --git a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.tsx b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.tsx index ad0f0627247ee..9b28bc9bb8e41 100644 --- a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.tsx +++ b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.tsx @@ -111,7 +111,6 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh setValue, watch, } = useForm({ - mode: 'onBlur', defaultValues: { members: [], name: '', diff --git a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateDirectMessage.tsx b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateDirectMessage.tsx index 3a5802a29036d..d508db3fda098 100644 --- a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateDirectMessage.tsx +++ b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateDirectMessage.tsx @@ -38,7 +38,7 @@ const CreateDirectMessage = ({ onClose }: CreateDirectMessageProps) => { control, handleSubmit, formState: { isSubmitting, isValidating, errors }, - } = useForm({ mode: 'onBlur', defaultValues: { users: [] } }); + } = useForm({ defaultValues: { users: [] } }); const goToRoom = useGoToRoom(); @@ -94,6 +94,7 @@ const CreateDirectMessage = ({ onClose }: CreateDirectMessageProps) => { aria-describedby={`${membersFieldId}-hint ${membersFieldId}-error`} aria-required='true' aria-invalid={Boolean(errors.users)} + aria-label={t('Direct_message_creation_description')} /> )} /> diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx index 23b7493789b76..9fc12fafa368f 100644 --- a/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx @@ -1,10 +1,11 @@ -import { Box, Icon, TextInput } from '@rocket.chat/fuselage'; +import { useFocusManager } from '@react-aria/focus'; +import { useOverlayTrigger } from '@react-aria/overlays'; +import { useOverlayTriggerState } from '@react-stately/overlays'; +import { Box, Icon, IconButton, TextInput } from '@rocket.chat/fuselage'; import { useEffectEvent, useMergedRefs } from '@rocket.chat/fuselage-hooks'; import { useCallback, useEffect, useRef } from 'react'; -import { useFocusManager, useOverlayTrigger } from 'react-aria'; import { FormProvider, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { useOverlayTriggerState } from 'react-stately'; import tinykeys from 'tinykeys'; import NavBarSearchListBox from './NavBarSearchListbox'; @@ -74,7 +75,7 @@ const NavBarSearch = () => { return ( - + { placeholder={placeholder} ref={mergedRefs} role='combobox' + aria-autocomplete='list' + aria-keyshortcuts='Control+K Meta+K Control+P Meta+P' small - addon={} + addon={ + isDirty ? ( + + ) : ( + + ) + } /> {state.isOpen && } diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx index 511b8b1640a0e..481081a82e14a 100644 --- a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx @@ -1,16 +1,17 @@ +import type { OverlayTriggerAria } from '@react-aria/overlays'; +import type { OverlayTriggerState } from '@react-stately/overlays'; import { Box, Tile } from '@rocket.chat/fuselage'; import { useDebouncedValue, useEffectEvent, useOutsideClick } from '@rocket.chat/fuselage-hooks'; import { CustomScrollbars } from '@rocket.chat/ui-client'; import { useRef } from 'react'; -import type { OverlayTriggerAria } from 'react-aria'; import { useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import type { OverlayTriggerState } from 'react-stately'; import NavBarSearchNoResults from './NavBarSearchNoResults'; import NavBarSearchRow from './NavBarSearchRow'; import { useSearchItems } from './hooks/useSearchItems'; import { useListboxNavigation } from './hooks/useSearchNavigation'; +import ResultsLiveRegion from '../../components/ResultsLiveRegion'; type NavBarSearchListBoxProps = { state: OverlayTriggerState; @@ -49,22 +50,18 @@ const NavBarSearchListBox = ({ state, overlayProps }: NavBarSearchListBoxProps) display='flex' width='100%' flexDirection='column' - aria-live='polite' - aria-atomic='true' - aria-busy={isLoading} > + -
+
{items.length === 0 && !isLoading && } {items.length > 0 && ( - + {filterText ? t('Results') : t('Recent')} )} {items.map((item) => ( -
- -
+ ))}
diff --git a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchClick.ts b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchClick.ts index 887d41bf27294..a80aff09f263c 100644 --- a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchClick.ts +++ b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchClick.ts @@ -1,5 +1,5 @@ +import type { OverlayTriggerState } from '@react-stately/overlays'; import { useCallback } from 'react'; -import type { OverlayTriggerState } from 'react-stately'; export const useSearchClick = (state: OverlayTriggerState) => { const handleClick = useCallback(() => { diff --git a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchFocus.ts b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchFocus.ts index c4d348e252a0c..a1688d6115cb3 100644 --- a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchFocus.ts +++ b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchFocus.ts @@ -1,6 +1,6 @@ +import type { OverlayTriggerState } from '@react-stately/overlays'; import { useLayout } from '@rocket.chat/ui-contexts'; import { useCallback, useEffect } from 'react'; -import type { OverlayTriggerState } from 'react-stately'; export const useSearchFocus = (state: OverlayTriggerState) => { const { navbar } = useLayout(); diff --git a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchNavigation.ts b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchNavigation.ts index b1ee47279e68a..66cd078b25e74 100644 --- a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchNavigation.ts +++ b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchNavigation.ts @@ -1,7 +1,7 @@ +import { useFocusManager } from '@react-aria/focus'; +import type { OverlayTriggerState } from '@react-stately/overlays'; import type { KeyboardEvent } from 'react'; import { useCallback } from 'react'; -import { useFocusManager } from 'react-aria'; -import type { OverlayTriggerState } from 'react-stately'; export const isOption = (node: Element) => node.getAttribute('role') === 'option'; diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/KeyboardShortcutsModal.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/KeyboardShortcutsModal.tsx new file mode 100644 index 0000000000000..07ac5caac8e37 --- /dev/null +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/KeyboardShortcutsModal.tsx @@ -0,0 +1,136 @@ +import { Box, Divider } from '@rocket.chat/fuselage'; +import { GenericModal } from '@rocket.chat/ui-client'; +import type { ReactElement } from 'react'; +import { Fragment, memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +type KeyCombo = { + mac: readonly string[]; + other: readonly string[]; +}; + +type ShortcutDefinition = { + id: string; + descriptionKey: string; + combos: readonly KeyCombo[]; +}; + +const SHORTCUTS: readonly ShortcutDefinition[] = [ + { + id: 'openKeyboardShortcuts', + descriptionKey: 'Keyboard_Shortcuts_Show_Keyboard_Shortcuts', + combos: [{ mac: ['Shift', '?'], other: ['Shift', '?'] }], + }, + { + id: 'openSearch', + descriptionKey: 'Keyboard_Shortcuts_Open_Channel_Slash_User_Search', + combos: [ + { mac: ['Command', 'P'], other: ['Control', 'P'] }, + { mac: ['Command', 'K'], other: ['Control', 'K'] }, + ], + }, + { + id: 'markAllAsRead', + descriptionKey: 'Keyboard_Shortcuts_Mark_all_as_read', + combos: [{ mac: ['Shift', 'Escape'], other: ['Control', 'Escape'] }], + }, + { + id: 'editPreviousMessage', + descriptionKey: 'Keyboard_Shortcuts_Edit_Previous_Message', + combos: [{ mac: ['ArrowUp'], other: ['ArrowUp'] }], + }, + { + id: 'moveToBeginningHorizontal', + descriptionKey: 'Keyboard_Shortcuts_Move_To_Beginning_Of_Message', + combos: [{ mac: ['Command', 'ArrowLeft'], other: ['Alt', 'ArrowLeft'] }], + }, + { + id: 'moveToBeginningVertical', + descriptionKey: 'Keyboard_Shortcuts_Move_To_Beginning_Of_Message', + combos: [{ mac: ['Command', 'ArrowUp'], other: ['Alt', 'ArrowUp'] }], + }, + { + id: 'moveToEndHorizontal', + descriptionKey: 'Keyboard_Shortcuts_Move_To_End_Of_Message', + combos: [{ mac: ['Command', 'ArrowRight'], other: ['Alt', 'ArrowRight'] }], + }, + { + id: 'moveToEndVertical', + descriptionKey: 'Keyboard_Shortcuts_Move_To_End_Of_Message', + combos: [{ mac: ['Command', 'ArrowDown'], other: ['Alt', 'ArrowDown'] }], + }, + { + id: 'newLine', + descriptionKey: 'Keyboard_Shortcuts_New_Line_In_Message', + combos: [{ mac: ['Shift', 'Enter'], other: ['Shift', 'Enter'] }], + }, +]; + +const KEY_LABEL_TRANSLATIONS: Record = { + Command: 'Keyboard_Shortcut_Key_Command', + Control: 'Keyboard_Shortcut_Key_Control', + Option: 'Keyboard_Shortcut_Key_Option', + Alt: 'Keyboard_Shortcut_Key_Alt', + Shift: 'Keyboard_Shortcut_Key_Shift', + Enter: 'Keyboard_Shortcut_Key_Enter', + Escape: 'Keyboard_Shortcut_Key_Escape', + ArrowUp: 'Keyboard_Shortcut_Key_ArrowUp', + ArrowDown: 'Keyboard_Shortcut_Key_ArrowDown', + ArrowLeft: 'Keyboard_Shortcut_Key_ArrowLeft', + ArrowRight: 'Keyboard_Shortcut_Key_ArrowRight', +}; + +const isMacPlatform = (): boolean => + typeof navigator !== 'undefined' && typeof navigator.platform === 'string' && navigator.platform.toLowerCase().includes('mac'); + +type KeyboardShortcutsModalProps = { + onClose: () => void; +}; + +const KeyboardShortcutsModal = ({ onClose }: KeyboardShortcutsModalProps): ReactElement => { + const { t } = useTranslation(); + const isMac = isMacPlatform(); + + return ( + + + {SHORTCUTS.map(({ id, descriptionKey, combos }) => ( + + + {t(descriptionKey)} + + + {combos.map((combo, comboIndex) => { + const keys = isMac ? combo.mac : combo.other; + return ( + + {comboIndex > 0 && ( + + {t('or')} + + )} + + {keys.map((token, tokenIndex) => ( + + {tokenIndex > 0 && ( + + )} + {KEY_LABEL_TRANSLATIONS[token] ? t(KEY_LABEL_TRANSLATIONS[token]) : token} + + ))} + + + ); + })} + + + ))} + + + ); +}; + +export default memo(KeyboardShortcutsModal); diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useKeyboardShortcutsModalHandler.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useKeyboardShortcutsModalHandler.tsx new file mode 100644 index 0000000000000..076d813c3f52d --- /dev/null +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useKeyboardShortcutsModalHandler.tsx @@ -0,0 +1,12 @@ +import { useSetModal } from '@rocket.chat/ui-contexts'; + +import KeyboardShortcutsModal from '../KeyboardShortcutsModal'; + +export const useKeyboardShortcutsModalHandler = () => { + const setModal = useSetModal(); + + return () => { + const handleModalClose = () => setModal(null); + setModal(); + }; +}; diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx index 126f1665c5e7f..407e81d0fb725 100644 --- a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import UserMenuHeader from '../UserMenuHeader'; import { useAccountItems } from './useAccountItems'; +import { useKeyboardShortcutsModalHandler } from './useKeyboardShortcutsModalHandler'; import { useStatusItems } from './useStatusItems'; export const useUserMenu = (user: IUser) => { @@ -13,12 +14,20 @@ export const useUserMenu = (user: IUser) => { const statusItems = useStatusItems(); const accountItems = useAccountItems(); + const handleKeyboardShortcuts = useKeyboardShortcutsModalHandler(); const logout = useLogout(); const handleLogout = useEffectEvent(() => { logout(); }); + const keyboardShortcutsItem: GenericMenuItemProps = { + id: 'keyboardShortcuts', + icon: 'keyboard', + content: t('Keyboard_Shortcuts_Title'), + onClick: handleKeyboardShortcuts, + }; + const logoutItem: GenericMenuItemProps = { id: 'logout', icon: 'sign-out', @@ -39,6 +48,9 @@ export const useUserMenu = (user: IUser) => { title: t('Account'), items: accountItems, }, + { + items: [keyboardShortcutsItem], + }, { items: [logoutItem], }, diff --git a/apps/meteor/client/providers/RouterProvider.tsx b/apps/meteor/client/providers/RouterProvider.tsx index 77fc0e28c50b8..865dd3462c4c1 100644 --- a/apps/meteor/client/providers/RouterProvider.tsx +++ b/apps/meteor/client/providers/RouterProvider.tsx @@ -2,6 +2,7 @@ import { RouterContext } from '@rocket.chat/ui-contexts'; import type { RouterContextValue } from '@rocket.chat/ui-contexts'; import type { ReactNode } from 'react'; +import { useRouterScrollToHash } from '../hooks/useRouterScrollToHash'; import { Router } from '../router'; /** @deprecated consume it from the `RouterContext` instead */ @@ -11,6 +12,10 @@ type RouterProviderProps = { children?: ReactNode; }; -const RouterProvider = ({ children }: RouterProviderProps) => {children}; +const RouterProvider = ({ children }: RouterProviderProps) => { + useRouterScrollToHash(router); + + return {children}; +}; export default RouterProvider; diff --git a/apps/meteor/client/providers/TranslationProvider.tsx b/apps/meteor/client/providers/TranslationProvider.tsx index c8aaabbf8470c..d9373c79630f0 100644 --- a/apps/meteor/client/providers/TranslationProvider.tsx +++ b/apps/meteor/client/providers/TranslationProvider.tsx @@ -12,10 +12,9 @@ import en from '@rocket.chat/i18n/dist/resources/en.i18n.json'; import { capitalize } from '@rocket.chat/string-helpers'; import { normalizeLanguage } from '@rocket.chat/tools'; import type { TranslationContextValue } from '@rocket.chat/ui-contexts'; -import { useMethod, useSetting, TranslationContext } from '@rocket.chat/ui-contexts'; +import { useSetting, TranslationContext } from '@rocket.chat/ui-contexts'; import type i18next from 'i18next'; import I18NextHttpBackend from 'i18next-http-backend'; -import moment from 'moment'; import type { ReactElement, ReactNode } from 'react'; import { useEffect, useMemo } from 'react'; import { I18nextProvider, initReactI18next, useTranslation } from 'react-i18next'; @@ -25,6 +24,7 @@ import { i18n } from '../../app/utils/lib/i18n'; import { AppClientOrchestratorInstance } from '../apps/orchestrator'; import { onLoggedIn } from '../lib/loggedIn'; import { isRTLScriptLanguage } from '../lib/utils/isRTLScriptLanguage'; +import { setDateFnsLocale } from '../lib/utils/setDateFnsLocale'; i18n.use(I18NextHttpBackend).use(initReactI18next); @@ -189,8 +189,6 @@ type TranslationProviderProps = { }; const TranslationProvider = ({ children }: TranslationProviderProps): ReactElement => { - const loadLocale = useMethod('loadLocale'); - const language = useAutoLanguage(); const i18nextInstance = useI18next(language); useCustomTranslations(i18nextInstance); @@ -216,23 +214,8 @@ const TranslationProvider = ({ children }: TranslationProviderProps): ReactEleme ); useEffect(() => { - if (moment.locales().includes(language.toLowerCase())) { - moment.locale(language); - return; - } - - const locale = !availableLanguages.find((lng) => lng.key === language) ? language.split('-').shift() : language; - - loadLocale(locale ?? language) - .then((localeSrc) => { - localeSrc && Function(localeSrc).call({ moment }); - moment.locale(language); - }) - .catch((error) => { - moment.locale('en'); - console.error('Error loading moment locale:', error); - }); - }, [language, loadLocale, availableLanguages]); + setDateFnsLocale(language); + }, [language]); useEffect( () => diff --git a/apps/meteor/client/router/index.tsx b/apps/meteor/client/router/index.tsx index 04083e9f89834..3fb7c08c4ec16 100644 --- a/apps/meteor/client/router/index.tsx +++ b/apps/meteor/client/router/index.tsx @@ -1,6 +1,7 @@ import type { RoomType, RoomRouteData } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import type { + LocationHash, LocationPathname, LocationSearch, RouteName, @@ -222,6 +223,8 @@ export class Router implements RouterContextValue { readonly getLocationSearch = () => location.search as LocationSearch; + readonly getLocationHash = () => location.hash as LocationHash; + readonly getRouteParameters = () => (this.current?.params ? (Object.fromEntries(this.current.params.entries()) as RouteParameters) : {}); readonly getSearchParameters = () => diff --git a/apps/meteor/client/sidebar/RoomList/SidebarItemTemplateWithData.tsx b/apps/meteor/client/sidebar/RoomList/SidebarItemTemplateWithData.tsx index 445c5607c672e..d297e8c6a9416 100644 --- a/apps/meteor/client/sidebar/RoomList/SidebarItemTemplateWithData.tsx +++ b/apps/meteor/client/sidebar/RoomList/SidebarItemTemplateWithData.tsx @@ -108,6 +108,7 @@ const SidebarItemTemplateWithData = ({ data-unread={highlighted} unread={highlighted} selected={selected} + aria-current={selected ? 'page' : undefined} href={href} onClick={(): void => { !selected && sidebar.toggle(); diff --git a/apps/meteor/client/sidebar/RoomList/useSidebarListNavigation.ts b/apps/meteor/client/sidebar/RoomList/useSidebarListNavigation.ts index 3f6ee5062a26d..156d006c0a15f 100644 --- a/apps/meteor/client/sidebar/RoomList/useSidebarListNavigation.ts +++ b/apps/meteor/client/sidebar/RoomList/useSidebarListNavigation.ts @@ -1,5 +1,5 @@ +import { useFocusManager } from '@react-aria/focus'; import { useCallback } from 'react'; -import { useFocusManager } from 'react-aria'; const isListItem = (node: EventTarget) => (node as HTMLElement).classList.contains('rcx-sidebar-v2-item'); const isCollapseGroup = (node: EventTarget) => (node as HTMLElement).classList.contains('rcx-sidebar-v2-collapse-group__bar'); diff --git a/apps/meteor/client/sidebar/SidebarRegion.tsx b/apps/meteor/client/sidebar/SidebarRegion.tsx index 1541c900d7a5d..98bd455f03cdc 100644 --- a/apps/meteor/client/sidebar/SidebarRegion.tsx +++ b/apps/meteor/client/sidebar/SidebarRegion.tsx @@ -1,8 +1,8 @@ +import { FocusScope } from '@react-aria/focus'; import { css } from '@rocket.chat/css-in-js'; import { Box } from '@rocket.chat/fuselage'; import { useLayout } from '@rocket.chat/ui-contexts'; import { memo } from 'react'; -import { FocusScope } from 'react-aria'; import Sidebar from './Sidebar'; diff --git a/apps/meteor/client/sidebar/badges/SidebarItemBadges.spec.tsx b/apps/meteor/client/sidebar/badges/SidebarItemBadges.spec.tsx index 9866febc2dcaa..d742456b67f2a 100644 --- a/apps/meteor/client/sidebar/badges/SidebarItemBadges.spec.tsx +++ b/apps/meteor/client/sidebar/badges/SidebarItemBadges.spec.tsx @@ -65,7 +65,7 @@ describe('SidebarItemBadges', () => { }, ); - expect(screen.getByRole('status', { name: 'Invited January 1, 2025' })).toBeInTheDocument(); + expect(screen.getByRole('status', { name: 'Invited January 1st, 2025' })).toBeInTheDocument(); }); it('should not render InvitationBadge when subscription does not have status INVITED', () => { diff --git a/apps/meteor/client/startup/startup.ts b/apps/meteor/client/startup/startup.ts index 45fa6c12b1bbb..e9568ae7e2dea 100644 --- a/apps/meteor/client/startup/startup.ts +++ b/apps/meteor/client/startup/startup.ts @@ -1,7 +1,6 @@ import type { UserStatus } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import moment from 'moment'; import 'highlight.js/styles/github.css'; import { sdk } from '../../app/utils/client/lib/SDKClient'; @@ -31,7 +30,7 @@ Meteor.startup(() => { return; } - const utcOffset = moment().utcOffset() / 60; + const utcOffset = -new Date().getTimezoneOffset() / 60; if (user.utcOffset !== utcOffset) { sdk.call('userSetUtcOffset', utcOffset); } diff --git a/apps/meteor/client/ui.ts b/apps/meteor/client/ui.ts index 5716ab1c83299..7a0e3b508d35e 100644 --- a/apps/meteor/client/ui.ts +++ b/apps/meteor/client/ui.ts @@ -15,7 +15,6 @@ import { useDiscussionsRoomAction } from './hooks/roomActions/useDiscussionsRoom import { useE2EERoomAction } from './hooks/roomActions/useE2EERoomAction'; import { useExportMessagesRoomAction } from './hooks/roomActions/useExportMessagesRoomAction'; import { useGameCenterRoomAction } from './hooks/roomActions/useGameCenterRoomAction'; -import { useKeyboardShortcutListRoomAction } from './hooks/roomActions/useKeyboardShortcutListRoomAction'; import { useMediaCallRoomAction } from './hooks/roomActions/useMediaCallRoomAction'; import { useMembersListRoomAction } from './hooks/roomActions/useMembersListRoomAction'; import { useMentionsRoomAction } from './hooks/roomActions/useMentionsRoomAction'; @@ -52,7 +51,6 @@ export const roomActionHooks = [ useE2EERoomAction, useExportMessagesRoomAction, useGameCenterRoomAction, - useKeyboardShortcutListRoomAction, useBannedUsersRoomAction, useMembersListRoomAction, useMentionsRoomAction, diff --git a/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx b/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx index 6b5b6a1bccc9c..ac876da527ed2 100644 --- a/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx +++ b/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx @@ -1,12 +1,9 @@ +import { VisuallyHidden } from '@react-aria/visually-hidden'; import { css } from '@rocket.chat/css-in-js'; import type { SelectOption } from '@rocket.chat/fuselage'; +import { Accordion, AccordionItem, Box, Button, ButtonGroup } from '@rocket.chat/fuselage'; import { FieldDescription, - Accordion, - AccordionItem, - Box, - Button, - ButtonGroup, Field, FieldGroup, FieldHint, @@ -15,12 +12,11 @@ import { RadioButton, Select, ToggleSwitch, -} from '@rocket.chat/fuselage'; +} from '@rocket.chat/fuselage-forms'; import { ExternalLink, Page, PageHeader, PageScrollableContentWithShadow, PageFooter } from '@rocket.chat/ui-client'; -import { useTranslation, useToastMessageDispatch, useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; +import { useTranslation, useToastMessageDispatch, useEndpoint, useSetting, useLocationHash } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; import { useId, useMemo } from 'react'; -import { VisuallyHidden } from 'react-aria'; import { Controller, useForm } from 'react-hook-form'; import { fontSizes } from './fontSizes'; @@ -38,6 +34,7 @@ const AccessibilityPage = () => { const createFontStyleElement = useCreateFontStyleElement(); const displayRolesEnabled = useSetting('UI_DisplayRoles'); + const shouldExpand = useLocationHash().length > 1; const timeFormatOptions = useMemo( (): SelectOption[] => [ @@ -49,11 +46,6 @@ const AccessibilityPage = () => { ); const pageFormId = useId(); - const fontSizeId = useId(); - const mentionsWithSymbolId = useId(); - const clockModeId = useId(); - const hideUsernamesId = useId(); - const hideRolesId = useId(); const linkListId = useId(); const { @@ -113,14 +105,14 @@ const AccessibilityPage = () => { return ( - + {t(title)} ( - onChange(id)} checked={value === id} /> + render={({ field: { value, onChange, ...field } }) => ( + onChange(id)} checked={value === id} /> )} /> @@ -131,35 +123,25 @@ const AccessibilityPage = () => { ); })} - + {t('Adjustable_layout')} - - {t('Font_size')} - + {t('Font_size')} - ( - } /> {t('Adjustable_font_size_description')} - {t('Mentions_with_@_symbol')} + {t('Mentions_with_@_symbol')} ( - - )} + render={({ field: { value, ...field } }) => } /> { {t('Mentions_with_@_symbol_description')} - - {t('Message_TimeFormat')} + + {t('Message_TimeFormat')} ( - } /> - + - {t('Show_usernames')} + {t('Show_usernames')} ( - onChange(!(e.target as HTMLInputElement).checked)} - /> + render={({ field: { value, onChange, ...field } }) => ( + onChange(!(e.target as HTMLInputElement).checked)} /> )} /> {t('Show_or_hide_the_username_of_message_authors')} {displayRolesEnabled && ( - + - {t('Show_roles')} + {t('Show_roles')} ( - onChange(!(e.target as HTMLInputElement).checked)} - /> + render={({ field: { value, onChange, ...field } }) => ( + onChange(!(e.target as HTMLInputElement).checked)} /> )} /> diff --git a/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx b/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx index eb62c25d8077f..f2a6a4264635a 100644 --- a/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx +++ b/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx @@ -1,9 +1,9 @@ import type { SelectOption } from '@rocket.chat/fuselage'; -import { SelectLegacy, Box, Button, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; -import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { Box, Button } from '@rocket.chat/fuselage'; +import { Select, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage-forms'; import { Page, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client'; import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; -import { useId, useMemo } from 'react'; +import { useMemo } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -37,11 +37,9 @@ const AccountIntegrationsPage = () => { }, }); - const handleSubmitForm = useEffectEvent(({ accountSelected }: { accountSelected: string }) => { + const handleSubmitForm = ({ accountSelected }: { accountSelected: string }) => { removeMutation.mutate({ accountSelected }); - }); - - const accountSelectedId = useId(); + }; return ( @@ -55,17 +53,13 @@ const AccountIntegrationsPage = () => { control={control} name='accountSelected' rules={{ required: t('Required_field', { field: t('WebDAV_Accounts') }) }} - render={({ field }) => } + render={({ field }) => - )} - /> + - )} + render={({ field }) => - )} - /> + - )} + render={({ field }) => - )} + render={({ field }) => - )} + render={({ field }) => { onChange(value); @@ -126,17 +93,14 @@ const PreferencesSoundSection = () => { - - {t('New_Message_Notification')} - + {t('New_Message_Notification')} ( + render={({ field: { onChange, ...field } }) => ( handleChange(String(value))} - options={moment.tz.names().map((key) => [key, key])} + options={timezoneNames.map((key) => [key, key])} /> {hint && {hint}} diff --git a/apps/meteor/client/views/admin/settings/groups/LDAPGroupPage.tsx b/apps/meteor/client/views/admin/settings/groups/LDAPGroupPage.tsx index 2ae03dbb563ff..e862afaf03ecb 100644 --- a/apps/meteor/client/views/admin/settings/groups/LDAPGroupPage.tsx +++ b/apps/meteor/client/views/admin/settings/groups/LDAPGroupPage.tsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'; import BaseGroupPage from './BaseGroupPage'; import { useExternalLink } from '../../../../hooks/useExternalLink'; +import { useLdapSync } from '../../../../hooks/useLdapSync'; import { links } from '../../../../lib/links'; import { useEditableSettings } from '../../EditableSettingsContext'; @@ -20,11 +21,11 @@ function LDAPGroupPage({ _id, i18nLabel, onClickBack, ...group }: LDAPGroupPageP const { t } = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); const testConnection = useEndpoint('POST', '/v1/ldap.testConnection'); - const syncNow = useEndpoint('POST', '/v1/ldap.syncNow'); const testSearch = useEndpoint('POST', '/v1/ldap.testSearch'); const ldapEnabled = useSetting('LDAP_Enable'); const setModal = useSetModal(); const closeModal = useEffectEvent(() => setModal()); + const handleSyncNow = useLdapSync(); const handleLinkClick = useExternalLink(); @@ -48,37 +49,6 @@ function LDAPGroupPage({ _id, i18nLabel, onClickBack, ...group }: LDAPGroupPageP } }; - const handleSyncNowButtonClick = async (): Promise => { - try { - await testConnection(); - const confirmSync = async (): Promise => { - closeModal(); - - try { - const { message } = await syncNow(); - dispatchToastMessage({ type: 'success', message: t(message as Parameters[0]) }); - } catch (error) { - error instanceof Error && dispatchToastMessage({ type: 'error', message: error }); - } - }; - - setModal( - - {t('LDAP_Sync_Now_Description')} - , - ); - } catch (error) { - error instanceof Error && dispatchToastMessage({ type: 'error', message: error }); - } - }; - const handleSearchTestButtonClick = async (): Promise => { try { await testConnection(); @@ -144,7 +114,7 @@ function LDAPGroupPage({ _id, i18nLabel, onClickBack, ...group }: LDAPGroupPageP -