diff --git a/.changeset/blue-points-dream.md b/.changeset/blue-points-dream.md new file mode 100644 index 0000000000000..eacb88108a0f7 --- /dev/null +++ b/.changeset/blue-points-dream.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates) diff --git a/.changeset/blue-seals-leave.md b/.changeset/blue-seals-leave.md new file mode 100644 index 0000000000000..62f6e7e9003fd --- /dev/null +++ b/.changeset/blue-seals-leave.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/model-typings": patch +"@rocket.chat/models": patch +--- + +Fixes an authorization issue that allowed users to confirm uploads from other users diff --git a/.changeset/bright-dots-march.md b/.changeset/bright-dots-march.md new file mode 100644 index 0000000000000..771343eea43f4 --- /dev/null +++ b/.changeset/bright-dots-march.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/meteor': patch +'@rocket.chat/model-typings': patch +'@rocket.chat/models': patch +--- + +Fixes main channel scroll position changing when jumping to a thread message from search diff --git a/.changeset/bump-patch-1774533301673.md b/.changeset/bump-patch-1774533301673.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1774533301673.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1774666021169.md b/.changeset/bump-patch-1774666021169.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1774666021169.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1774909627968.md b/.changeset/bump-patch-1774909627968.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1774909627968.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1775173623076.md b/.changeset/bump-patch-1775173623076.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1775173623076.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/clean-ears-fly.md b/.changeset/clean-ears-fly.md new file mode 100644 index 0000000000000..781a8a4da4466 --- /dev/null +++ b/.changeset/clean-ears-fly.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes a cross-resource access issue that allowed users to retrieve emojis from the Custom Sounds endpoint and sounds from the Custom Emojis endpoint when using the FileSystem storage mode. diff --git a/.changeset/eight-colts-kiss.md b/.changeset/eight-colts-kiss.md new file mode 100644 index 0000000000000..a163dd3637426 --- /dev/null +++ b/.changeset/eight-colts-kiss.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/federation-matrix": patch +"@rocket.chat/meteor": patch +--- + +Fixes an issue on Federation where all domains ending with the pattern where being allowed to communicate, the feature is meant to work with a list, url by url diff --git a/.changeset/five-chicken-invite.md b/.changeset/five-chicken-invite.md new file mode 100644 index 0000000000000..0c13329c3f84d --- /dev/null +++ b/.changeset/five-chicken-invite.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/federation-matrix': minor +'@rocket.chat/meteor': minor +--- + +Adds support to name changes on federated rooms diff --git a/.changeset/fix-blockquote-empty-lines.md b/.changeset/fix-blockquote-empty-lines.md new file mode 100644 index 0000000000000..b3b463f3913a6 --- /dev/null +++ b/.changeset/fix-blockquote-empty-lines.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/message-parser': patch +--- + +Fixed blockquotes with empty lines between paragraphs not rendering as a single blockquote. Lines like `> ` or `>` (empty quote lines) are now treated as part of the surrounding blockquote rather than breaking it into separate quotes. diff --git a/.changeset/fix-message-parser-reduce-perf.md b/.changeset/fix-message-parser-reduce-perf.md new file mode 100644 index 0000000000000..6601f8f205c43 --- /dev/null +++ b/.changeset/fix-message-parser-reduce-perf.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/message-parser': patch +--- + +Replaces wasteful `filter().shift()` with `find(Boolean)` in `extractFirstResult` to avoid allocating an intermediate filtered array just to get the first truthy element. diff --git a/.changeset/fix-register-workspace-i18n.md b/.changeset/fix-register-workspace-i18n.md new file mode 100644 index 0000000000000..62eed988444d9 --- /dev/null +++ b/.changeset/fix-register-workspace-i18n.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes wrong i18n key in RegisterWorkspace confirmation step so the text is translated instead of showing a missing key. diff --git a/.changeset/fix-trailing-punctuation-url.md b/.changeset/fix-trailing-punctuation-url.md new file mode 100644 index 0000000000000..f55255a46e574 --- /dev/null +++ b/.changeset/fix-trailing-punctuation-url.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/message-parser": patch +--- + +Fixes trailing punctuation (e.g. periods, exclamation marks) being incorrectly included in parsed URLs when they appear at the end of a message. For example, `go to https://www.google.com.` now correctly parses the URL as `https://www.google.com` without the trailing period. diff --git a/.changeset/fix-webhook-newline.md b/.changeset/fix-webhook-newline.md new file mode 100644 index 0000000000000..c622c57eb235b --- /dev/null +++ b/.changeset/fix-webhook-newline.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes incoming webhook messages ignoring literal `\n` escape sequences, and fixes the `MarkdownText` `document` variant not rendering newlines as line breaks. diff --git a/.changeset/fluffy-turtles-admire.md b/.changeset/fluffy-turtles-admire.md new file mode 100644 index 0000000000000..3d21fa3ed7910 --- /dev/null +++ b/.changeset/fluffy-turtles-admire.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Fixes race condition causing duplicate open livechat rooms per visitor token. diff --git a/.changeset/healthy-dragons-crash.md b/.changeset/healthy-dragons-crash.md new file mode 100644 index 0000000000000..f08337723f544 --- /dev/null +++ b/.changeset/healthy-dragons-crash.md @@ -0,0 +1,9 @@ +--- +'@rocket.chat/fuselage-ui-kit': minor +'@rocket.chat/ui-kit': major +'@rocket.chat/apps-engine': minor +'@rocket.chat/livechat': minor +'@rocket.chat/meteor': minor +--- + +refactor(ui-kit): Remove UiKit deprecations diff --git a/.changeset/honest-shrimps-cough.md b/.changeset/honest-shrimps-cough.md new file mode 100644 index 0000000000000..c7711d4a8cc73 --- /dev/null +++ b/.changeset/honest-shrimps-cough.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes inconsistent username formatting causing '@@username' for federated users diff --git a/.changeset/hungry-monkeys-hang.md b/.changeset/hungry-monkeys-hang.md new file mode 100644 index 0000000000000..f128167d7cee7 --- /dev/null +++ b/.changeset/hungry-monkeys-hang.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/rest-typings": minor +--- + +Add OpenAPI support for the Rocket.Chat autotranslate translateMessage API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation diff --git a/.changeset/late-pots-teach.md b/.changeset/late-pots-teach.md new file mode 100644 index 0000000000000..77d715c51fb80 --- /dev/null +++ b/.changeset/late-pots-teach.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/i18n": patch +"@rocket.chat/ui-voip": patch +--- + +Fixes mismatched translations for Voice calling UI diff --git a/.changeset/little-eyes-kneel.md b/.changeset/little-eyes-kneel.md new file mode 100644 index 0000000000000..2582804405acc --- /dev/null +++ b/.changeset/little-eyes-kneel.md @@ -0,0 +1,75 @@ +--- +'@rocket.chat/eslint-config': minor +'@rocket.chat/server-cloud-communication': patch +'@rocket.chat/omnichannel-services': patch +'@rocket.chat/omnichannel-transcript': patch +'@rocket.chat/authorization-service': patch +'@rocket.chat/federation-matrix': patch +'@rocket.chat/web-ui-registration': patch +'@rocket.chat/network-broker': patch +'@rocket.chat/password-policies': patch +'@rocket.chat/release-changelog': patch +'@rocket.chat/storybook-config': patch +'@rocket.chat/presence-service': patch +'@rocket.chat/omni-core-ee': patch +'@rocket.chat/fuselage-ui-kit': patch +'@rocket.chat/instance-status': patch +'@rocket.chat/media-signaling': patch +'@rocket.chat/patch-injection': patch +'@rocket.chat/account-service': patch +'@rocket.chat/media-calls': patch +'@rocket.chat/message-parser': patch +'@rocket.chat/mock-providers': patch +'@rocket.chat/release-action': patch +'@rocket.chat/pdf-worker': patch +'@rocket.chat/account-utils': patch +'@rocket.chat/core-services': patch +'@rocket.chat/message-types': patch +'@rocket.chat/model-typings': patch +'@rocket.chat/mongo-adapter': patch +'@rocket.chat/ui-video-conf': patch +'@rocket.chat/uikit-playground': patch +'@rocket.chat/cas-validate': patch +'@rocket.chat/core-typings': patch +'@rocket.chat/jest-presets': patch +'@rocket.chat/peggy-loader': patch +'@rocket.chat/rest-typings': patch +'@rocket.chat/server-fetch': patch +'@rocket.chat/ddp-streamer': patch +'@rocket.chat/queue-worker': patch +'@rocket.chat/presence': patch +'@rocket.chat/apps-engine': patch +'@rocket.chat/desktop-api': patch +'@rocket.chat/http-router': patch +'@rocket.chat/poplib': patch +'@rocket.chat/ui-composer': patch +'@rocket.chat/ui-contexts': patch +'@rocket.chat/license': patch +'@rocket.chat/api-client': patch +'@rocket.chat/ddp-client': patch +'@rocket.chat/log-format': patch +'@rocket.chat/gazzodown': patch +'@rocket.chat/omni-core': patch +'@rocket.chat/ui-avatar': patch +'@rocket.chat/ui-client': patch +'@rocket.chat/livechat': patch +'@rocket.chat/abac': patch +'@rocket.chat/favicon': patch +'@rocket.chat/tracing': patch +'@rocket.chat/ui-voip': patch +'@rocket.chat/agenda': patch +'@rocket.chat/base64': patch +'@rocket.chat/logger': patch +'@rocket.chat/models': patch +'@rocket.chat/random': patch +'@rocket.chat/sha256': patch +'@rocket.chat/ui-kit': patch +'@rocket.chat/tools': patch +'@rocket.chat/apps': patch +'@rocket.chat/cron': patch +'@rocket.chat/i18n': patch +'@rocket.chat/jwt': patch +'@rocket.chat/meteor': patch +--- + +chore(eslint): Upgrades ESLint and its configuration diff --git a/.changeset/loud-weeks-protect.md b/.changeset/loud-weeks-protect.md new file mode 100644 index 0000000000000..3317177f72765 --- /dev/null +++ b/.changeset/loud-weeks-protect.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/message-parser': patch +--- + +Fixes ordered list AST generation to preserve `number: 0` for list items that start at index `0`. diff --git a/.changeset/many-glasses-care.md b/.changeset/many-glasses-care.md new file mode 100644 index 0000000000000..e57d57f5cbe61 --- /dev/null +++ b/.changeset/many-glasses-care.md @@ -0,0 +1,9 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/i18n": minor +"@rocket.chat/media-signaling": minor +"@rocket.chat/ui-voip": minor +"@rocket.chat/media-calls": minor +--- + +Introduces native screen sharing for internal voice calls. This feature is currently in beta and can be disabled through admin settings. diff --git a/.changeset/migrate-chat-follow-unfollow-message.md b/.changeset/migrate-chat-follow-unfollow-message.md new file mode 100644 index 0000000000000..875c2ed9d1443 --- /dev/null +++ b/.changeset/migrate-chat-follow-unfollow-message.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/rest-typings": minor +--- + +Add OpenAPI support for the chat.followMessage and chat.unfollowMessage API endpoints by migrating to a modern chained route definition syntax and utilizing AJV schemas for body and response validation. diff --git a/.changeset/migrate-chat-star-unstar-message.md b/.changeset/migrate-chat-star-unstar-message.md new file mode 100644 index 0000000000000..395b6422747a5 --- /dev/null +++ b/.changeset/migrate-chat-star-unstar-message.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/rest-typings": minor +--- + +Add OpenAPI support for the chat.starMessage and chat.unStarMessage API endpoints by migrating to a modern chained route definition syntax and utilizing AJV schemas for body and response validation. diff --git a/.changeset/migrate-rooms-leave-endpoint.md b/.changeset/migrate-rooms-leave-endpoint.md new file mode 100644 index 0000000000000..4f9a6263a9a19 --- /dev/null +++ b/.changeset/migrate-rooms-leave-endpoint.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/meteor': minor +'@rocket.chat/rest-typings': minor +--- + +Migrated rooms.leave endpoint to new OpenAPI pattern with AJV validation diff --git a/.changeset/nasty-candles-invent.md b/.changeset/nasty-candles-invent.md new file mode 100644 index 0000000000000..2af4dcf9cebf1 --- /dev/null +++ b/.changeset/nasty-candles-invent.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/web-ui-registration': patch +'@rocket.chat/i18n': patch +--- + +Fixes invalid email domain error not being displayed on the registration form. diff --git a/.changeset/new-students-attack.md b/.changeset/new-students-attack.md new file mode 100644 index 0000000000000..b1d4b56d505e1 --- /dev/null +++ b/.changeset/new-students-attack.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes an issue when forwarding messages to a password-protected room. diff --git a/.changeset/nice-penguins-rhyme.md b/.changeset/nice-penguins-rhyme.md new file mode 100644 index 0000000000000..5e89a31ef9739 --- /dev/null +++ b/.changeset/nice-penguins-rhyme.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fix marking a message as sent before the request finishes diff --git a/.changeset/nice-squids-smoke.md b/.changeset/nice-squids-smoke.md new file mode 100644 index 0000000000000..a71f10d915f97 --- /dev/null +++ b/.changeset/nice-squids-smoke.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat e2e.getUsersOfRoomWithoutKey endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/nine-otters-hug.md b/.changeset/nine-otters-hug.md new file mode 100644 index 0000000000000..78868e3057732 --- /dev/null +++ b/.changeset/nine-otters-hug.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +migrated rooms.delete endpoint to new OpenAPI pattern with AJV validation diff --git a/.changeset/olive-hairs-report.md b/.changeset/olive-hairs-report.md new file mode 100644 index 0000000000000..fff0535e67cbc --- /dev/null +++ b/.changeset/olive-hairs-report.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes version update banner showing outdated versions after server upgrade. diff --git a/.changeset/orange-paws-poke.md b/.changeset/orange-paws-poke.md new file mode 100644 index 0000000000000..2b49cea8f1442 --- /dev/null +++ b/.changeset/orange-paws-poke.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/i18n': patch +'@rocket.chat/meteor': patch +--- + +Deprecates `Anonymous write`. Feature will be removed in version 9.0.0. diff --git a/.changeset/polite-plums-boil.md b/.changeset/polite-plums-boil.md new file mode 100644 index 0000000000000..b9263d979581b --- /dev/null +++ b/.changeset/polite-plums-boil.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes the intermittent behavior where the "New messages" indicator appears incorrectly after the user sends a message diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000000000..0da5f495d163a --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,146 @@ +{ + "mode": "pre", + "tag": "rc", + "initialVersions": { + "@rocket.chat/meteor": "8.3.0-develop", + "rocketchat-services": "2.0.43", + "@rocket.chat/uikit-playground": "0.7.8", + "@rocket.chat/account-service": "0.4.52", + "@rocket.chat/authorization-service": "0.5.5", + "@rocket.chat/ddp-streamer": "0.3.52", + "@rocket.chat/omnichannel-transcript": "0.4.52", + "@rocket.chat/presence-service": "0.4.52", + "@rocket.chat/queue-worker": "0.4.52", + "@rocket.chat/abac": "0.1.5", + "@rocket.chat/federation-matrix": "0.0.14", + "@rocket.chat/license": "1.1.12", + "@rocket.chat/media-calls": "0.2.5", + "@rocket.chat/network-broker": "0.2.31", + "@rocket.chat/omni-core-ee": "0.0.17", + "@rocket.chat/omnichannel-services": "0.3.49", + "@rocket.chat/pdf-worker": "0.3.31", + "@rocket.chat/presence": "0.2.52", + "@rocket.chat/account-utils": "0.0.2", + "@rocket.chat/agenda": "0.1.0", + "@rocket.chat/api-client": "0.2.52", + "@rocket.chat/apps": "0.6.5", + "@rocket.chat/apps-engine": "1.60.0", + "@rocket.chat/base64": "1.0.13", + "@rocket.chat/cas-validate": "0.0.3", + "@rocket.chat/core-services": "0.13.1", + "@rocket.chat/core-typings": "8.3.0-develop", + "@rocket.chat/cron": "0.1.52", + "@rocket.chat/ddp-client": "1.0.5", + "@rocket.chat/desktop-api": "1.1.0", + "@rocket.chat/eslint-config": "0.7.0", + "@rocket.chat/favicon": "0.0.4", + "@rocket.chat/fuselage-ui-kit": "28.0.1", + "@rocket.chat/gazzodown": "28.0.1", + "@rocket.chat/http-router": "7.9.19", + "@rocket.chat/i18n": "2.1.0", + "@rocket.chat/instance-status": "0.1.52", + "@rocket.chat/jest-presets": "0.0.1", + "@rocket.chat/jwt": "0.2.0", + "@rocket.chat/livechat": "2.0.5", + "@rocket.chat/log-format": "0.0.2", + "@rocket.chat/logger": "1.0.0", + "@rocket.chat/media-signaling": "0.1.1", + "@rocket.chat/message-parser": "0.31.34", + "@rocket.chat/message-types": "0.1.0", + "@rocket.chat/mock-providers": "0.4.12", + "@rocket.chat/model-typings": "2.1.1", + "@rocket.chat/models": "2.1.1", + "@rocket.chat/mongo-adapter": "0.0.2", + "@rocket.chat/poplib": "0.0.2", + "@rocket.chat/omni-core": "0.0.17", + "@rocket.chat/password-policies": "0.1.0", + "@rocket.chat/patch-injection": "0.0.1", + "@rocket.chat/peggy-loader": "0.31.27", + "@rocket.chat/random": "1.2.2", + "@rocket.chat/release-action": "2.2.3", + "@rocket.chat/release-changelog": "0.1.0", + "@rocket.chat/rest-typings": "8.3.0-develop", + "@rocket.chat/server-cloud-communication": "0.0.2", + "@rocket.chat/server-fetch": "0.1.1", + "@rocket.chat/sha256": "1.0.12", + "@rocket.chat/storybook-config": "0.0.2", + "@rocket.chat/tools": "0.2.4", + "@rocket.chat/tracing": "0.0.1", + "@rocket.chat/tsconfig": "0.0.0", + "@rocket.chat/ui-avatar": "24.0.1", + "@rocket.chat/ui-client": "28.0.1", + "@rocket.chat/ui-composer": "0.5.3", + "@rocket.chat/ui-contexts": "28.0.1", + "@rocket.chat/ui-kit": "0.39.0", + "@rocket.chat/ui-video-conf": "28.0.1", + "@rocket.chat/ui-voip": "18.0.1", + "@rocket.chat/web-ui-registration": "28.0.1" + }, + "changesets": [ + "blue-points-dream", + "blue-seals-leave", + "bright-dots-march", + "bump-patch-1774533301673", + "bump-patch-1774666021169", + "bump-patch-1774909627968", + "bump-patch-1775173623076", + "clean-ears-fly", + "eight-colts-kiss", + "five-chicken-invite", + "fix-blockquote-empty-lines", + "fix-message-parser-reduce-perf", + "fix-register-workspace-i18n", + "fix-trailing-punctuation-url", + "fix-webhook-newline", + "fluffy-turtles-admire", + "healthy-dragons-crash", + "honest-shrimps-cough", + "hungry-monkeys-hang", + "late-pots-teach", + "little-eyes-kneel", + "loud-weeks-protect", + "many-glasses-care", + "migrate-chat-follow-unfollow-message", + "migrate-chat-star-unstar-message", + "migrate-rooms-leave-endpoint", + "nasty-candles-invent", + "new-students-attack", + "nice-penguins-rhyme", + "nice-squids-smoke", + "nine-otters-hug", + "olive-hairs-report", + "orange-paws-poke", + "polite-plums-boil", + "pretty-jobs-juggle", + "rare-planes-tan", + "rare-waves-help", + "red-windows-breathe", + "refactor-instances-api-chained-pattern", + "refactor-ldap-api-chained-pattern", + "refactor-presence-api-chained-pattern", + "rude-plums-think", + "shaggy-cars-watch", + "shiny-pears-admire", + "short-starfishes-provide", + "small-pants-reflect", + "spicy-drinks-carry", + "spotty-news-burn", + "spotty-poems-smash", + "stale-elephants-type", + "strict-ajv-coercion", + "sweet-terms-relax", + "swift-badgers-try", + "tame-dolphins-draw", + "tame-humans-greet", + "tame-tables-complain", + "tender-papayas-jam", + "thick-nails-exist", + "tough-steaks-beam", + "tricky-boxes-type", + "twenty-colts-flash", + "unlucky-impalas-matter", + "weak-terms-shave", + "wet-roses-call", + "wicked-buckets-thank" + ] +} diff --git a/.changeset/pretty-jobs-juggle.md b/.changeset/pretty-jobs-juggle.md new file mode 100644 index 0000000000000..028fc592dd034 --- /dev/null +++ b/.changeset/pretty-jobs-juggle.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Adds OpenAPI support for the Rocket.Chat e2e.updateGroupKey endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/rare-planes-tan.md b/.changeset/rare-planes-tan.md new file mode 100644 index 0000000000000..f1874e99ac6fc --- /dev/null +++ b/.changeset/rare-planes-tan.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/core-typings': patch +'@rocket.chat/meteor': patch +--- + +Fixes an issue where emails were not saved for users logging in via the GitHub OAuth provider. diff --git a/.changeset/rare-waves-help.md b/.changeset/rare-waves-help.md new file mode 100644 index 0000000000000..476f7e0839153 --- /dev/null +++ b/.changeset/rare-waves-help.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/rest-typings": minor +--- + +Add OpenAPI support for the Rocket.Chat users.getAvatarSuggestion API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/red-windows-breathe.md b/.changeset/red-windows-breathe.md new file mode 100644 index 0000000000000..a177574edea6b --- /dev/null +++ b/.changeset/red-windows-breathe.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes calendar events modifying the wrong status property when attempting to sync `busy` status. diff --git a/.changeset/refactor-instances-api-chained-pattern.md b/.changeset/refactor-instances-api-chained-pattern.md new file mode 100644 index 0000000000000..e38ef1235e7ef --- /dev/null +++ b/.changeset/refactor-instances-api-chained-pattern.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + + adds `instances.get` API endpoint to new chained pattern with response schemas diff --git a/.changeset/refactor-ldap-api-chained-pattern.md b/.changeset/refactor-ldap-api-chained-pattern.md new file mode 100644 index 0000000000000..e402e8609cb46 --- /dev/null +++ b/.changeset/refactor-ldap-api-chained-pattern.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Migrates `ldap.testConnection` and `ldap.testSearch` REST API endpoints from legacy `addRoute` pattern to the new chained `.post()` API pattern with typed response schemas and AJV body validation (replacing Meteor `check()`). diff --git a/.changeset/refactor-presence-api-chained-pattern.md b/.changeset/refactor-presence-api-chained-pattern.md new file mode 100644 index 0000000000000..cec1816fce98b --- /dev/null +++ b/.changeset/refactor-presence-api-chained-pattern.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Migrates `presence.getConnections` and `presence.enableBroadcast` REST API endpoints from legacy `addRoute` pattern to the new chained `.get()`/`.post()` API pattern with typed response schemas. diff --git a/.changeset/rude-plums-think.md b/.changeset/rude-plums-think.md new file mode 100644 index 0000000000000..6b5804f013757 --- /dev/null +++ b/.changeset/rude-plums-think.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Fixes Custom Sounds Contextualbar state and refresh behavior diff --git a/.changeset/shaggy-cars-watch.md b/.changeset/shaggy-cars-watch.md new file mode 100644 index 0000000000000..56db9fd9b4e74 --- /dev/null +++ b/.changeset/shaggy-cars-watch.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/ui-contexts": patch +--- + +Fixes a mismatch in the room icons on the sidebar items, ABAC Managed rooms were not displaying the correct icon diff --git a/.changeset/shiny-pears-admire.md b/.changeset/shiny-pears-admire.md new file mode 100644 index 0000000000000..0e8287d708f4e --- /dev/null +++ b/.changeset/shiny-pears-admire.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Limits `Outgoing webhook` maximum response size to 10mb. diff --git a/.changeset/short-starfishes-provide.md b/.changeset/short-starfishes-provide.md new file mode 100644 index 0000000000000..2d70c789a69cd --- /dev/null +++ b/.changeset/short-starfishes-provide.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/rest-typings": minor +--- + +Add OpenAPI support for the Rocket.Chat e2e.fetchMyKeys endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/small-pants-reflect.md b/.changeset/small-pants-reflect.md new file mode 100644 index 0000000000000..a086551f4c8a9 --- /dev/null +++ b/.changeset/small-pants-reflect.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes the download of attachments with non-unicode names on E2EE rooms diff --git a/.changeset/spicy-drinks-carry.md b/.changeset/spicy-drinks-carry.md new file mode 100644 index 0000000000000..1b2119694d4cc --- /dev/null +++ b/.changeset/spicy-drinks-carry.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat push.test API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/spotty-news-burn.md b/.changeset/spotty-news-burn.md new file mode 100644 index 0000000000000..ffdb604dd1ced --- /dev/null +++ b/.changeset/spotty-news-burn.md @@ -0,0 +1,9 @@ +--- +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/ui-composer': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Adds support for multiple files in message composer, improving file upload experience diff --git a/.changeset/spotty-poems-smash.md b/.changeset/spotty-poems-smash.md new file mode 100644 index 0000000000000..2899b84364ef8 --- /dev/null +++ b/.changeset/spotty-poems-smash.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/models": patch +--- + +Fixes an issue where, sometimes, updatedAt was not being set during the subscription creation diff --git a/.changeset/stale-elephants-type.md b/.changeset/stale-elephants-type.md new file mode 100644 index 0000000000000..a49ea38cbccc1 --- /dev/null +++ b/.changeset/stale-elephants-type.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes main team channels being able to be converted into public or private with only the `create-team-channel` or `create-team-group` (the correct permission for main teams are `create-c` and `create-p`) diff --git a/.changeset/strict-ajv-coercion.md b/.changeset/strict-ajv-coercion.md new file mode 100644 index 0000000000000..f34405032264b --- /dev/null +++ b/.changeset/strict-ajv-coercion.md @@ -0,0 +1,16 @@ +--- +"@rocket.chat/rest-typings": minor +"@rocket.chat/meteor": patch +--- + +Splits the single AJV validator instance into two: `ajv` (coerceTypes: false) for request **body** validation and `ajvQuery` (coerceTypes: true) for **query parameter** validation. + +**Why this matters:** Previously, a single AJV instance with `coerceTypes: true` was used everywhere. This silently accepted values with wrong types — for example, sending `{ "rid": 12345 }` (number) where a string was expected would pass validation because `12345` was coerced to `"12345"`. With this change, body validation is now strict: the server will reject payloads with incorrect types instead of silently coercing them. + +**What may break for API consumers:** + +- **Numeric values sent as strings in POST/PUT/PATCH bodies** (e.g., `{ "count": "10" }` instead of `{ "count": 10 }`) will now be rejected. Ensure JSON bodies use proper types. +- **Boolean values sent as strings in bodies** (e.g., `{ "readThreads": "true" }` instead of `{ "readThreads": true }`) will now be rejected. +- **`null` values where a string is expected** (e.g., `{ "name": null }` for a `type: 'string'` field without `nullable: true`) will no longer be coerced to `""`. + +**No change for query parameters:** GET query params (e.g., `?count=10&offset=0`) continue to be coerced via `ajvQuery`, since HTTP query strings are always strings. diff --git a/.changeset/sweet-terms-relax.md b/.changeset/sweet-terms-relax.md new file mode 100644 index 0000000000000..8861e65f43250 --- /dev/null +++ b/.changeset/sweet-terms-relax.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/meteor': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +--- + +Add OpenAPI support for the Rocket.Chat custom-user-status.list API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation diff --git a/.changeset/swift-badgers-try.md b/.changeset/swift-badgers-try.md new file mode 100644 index 0000000000000..368d41a127c83 --- /dev/null +++ b/.changeset/swift-badgers-try.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Add OpenAPI support for the Rocket.Chat e2e endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/tame-dolphins-draw.md b/.changeset/tame-dolphins-draw.md new file mode 100644 index 0000000000000..f42810fac68ce --- /dev/null +++ b/.changeset/tame-dolphins-draw.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes `inquiries.take` not failing when attempting to take a chat while over chat limits diff --git a/.changeset/tame-humans-greet.md b/.changeset/tame-humans-greet.md new file mode 100644 index 0000000000000..e5b0aa45eece6 --- /dev/null +++ b/.changeset/tame-humans-greet.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes an issue where `Production` flag was not being respected when initializing Push Notifications configuration diff --git a/.changeset/tame-tables-complain.md b/.changeset/tame-tables-complain.md new file mode 100644 index 0000000000000..2d8c05a3f1432 --- /dev/null +++ b/.changeset/tame-tables-complain.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes "Join" button on Outlook Calendar bubbling click event, also opening the calendar event details. diff --git a/.changeset/tender-papayas-jam.md b/.changeset/tender-papayas-jam.md new file mode 100644 index 0000000000000..d9e85e6d29425 --- /dev/null +++ b/.changeset/tender-papayas-jam.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Limits Omnichannel webhook maximum response size to 10mb. diff --git a/.changeset/thick-nails-exist.md b/.changeset/thick-nails-exist.md new file mode 100644 index 0000000000000..985c251a2044e --- /dev/null +++ b/.changeset/thick-nails-exist.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Adds support for ban management in rooms, enabling authorized users to ban and unban members via UI and slash commands. diff --git a/.changeset/tough-steaks-beam.md b/.changeset/tough-steaks-beam.md new file mode 100644 index 0000000000000..cd0263fb496ed --- /dev/null +++ b/.changeset/tough-steaks-beam.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes reactivity of Custom Sounds and Custom Emojis storage settings diff --git a/.changeset/tricky-boxes-type.md b/.changeset/tricky-boxes-type.md new file mode 100644 index 0000000000000..084f3f79fe242 --- /dev/null +++ b/.changeset/tricky-boxes-type.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat rooms.favorite APIs endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/twenty-colts-flash.md b/.changeset/twenty-colts-flash.md new file mode 100644 index 0000000000000..93729a19533f6 --- /dev/null +++ b/.changeset/twenty-colts-flash.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Adds new `custom-sounds.getOne` REST endpoint to retrieve a single custom sound by `_id` and updates client to consume it. diff --git a/.changeset/unlucky-impalas-matter.md b/.changeset/unlucky-impalas-matter.md new file mode 100644 index 0000000000000..ed56575dcec7c --- /dev/null +++ b/.changeset/unlucky-impalas-matter.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes `ssrf` validation for oauth endpoints, which allows internal endpoints to be used during the auth flow. diff --git a/.changeset/weak-terms-shave.md b/.changeset/weak-terms-shave.md new file mode 100644 index 0000000000000..1813edcdb2b5b --- /dev/null +++ b/.changeset/weak-terms-shave.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat emoji-custom.create API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/wet-roses-call.md b/.changeset/wet-roses-call.md new file mode 100644 index 0000000000000..88cdcdb45362e --- /dev/null +++ b/.changeset/wet-roses-call.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +"@rocket.chat/core-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat commands.get API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/wicked-buckets-thank.md b/.changeset/wicked-buckets-thank.md new file mode 100644 index 0000000000000..cc6f8af59fcce --- /dev/null +++ b/.changeset/wicked-buckets-thank.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat dm.close/im.close API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.github/actions/build-docker/action.yml b/.github/actions/build-docker/action.yml index 55a249545ae2f..1bc8542e20464 100644 --- a/.github/actions/build-docker/action.yml +++ b/.github/actions/build-docker/action.yml @@ -108,6 +108,7 @@ runs: --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 *.cache-from=type=gha \ --set *.cache-to=type=gha,mode=max \ diff --git a/.github/actions/restore-packages/action.yml b/.github/actions/restore-packages/action.yml new file mode 100644 index 0000000000000..7e0726d19ef5b --- /dev/null +++ b/.github/actions/restore-packages/action.yml @@ -0,0 +1,16 @@ +name: 'Restore Packages Build' +description: 'Downloads and unpacks the packages build artifact' + +runs: + using: 'composite' + steps: + - name: Restore packages build + uses: actions/download-artifact@v8 + with: + name: packages-build + path: /tmp + + - name: Unpack packages build + shell: bash + run: | + tar -xzf /tmp/RocketChat-packages-build.tar.gz -C . diff --git a/.github/actions/update-version-durability/index.js b/.github/actions/update-version-durability/index.js index 2065ac6660827..d5fbeffb5de49 100644 --- a/.github/actions/update-version-durability/index.js +++ b/.github/actions/update-version-durability/index.js @@ -103,7 +103,7 @@ async function generateTable({ owner, repo } = {}) { minorDate.setDate(1); supportDateStart = minorDate; supportDate = new Date(minorDate); - supportDate.setMonth(supportDate.getMonth() + (lts ? 9 : 6)); + supportDate.setMonth(supportDate.getMonth() + (lts ? 12 : 6)); releaseData.push({ release: { diff --git a/.github/actions/update-version-durability/package-lock.json b/.github/actions/update-version-durability/package-lock.json index 6fb692c311a1d..5ac99a59a922a 100644 --- a/.github/actions/update-version-durability/package-lock.json +++ b/.github/actions/update-version-durability/package-lock.json @@ -205,16 +205,17 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" }, "node_modules/axios": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", - "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -253,6 +254,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" }, @@ -264,6 +266,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", "engines": { "node": ">=0.4.0" } @@ -352,15 +355,16 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -371,9 +375,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -496,6 +500,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -504,6 +509,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", "dependencies": { "mime-db": "1.52.0" }, diff --git a/.github/agents/bug-resolution-agent.md b/.github/agents/bug-resolution-agent.md new file mode 100644 index 0000000000000..45af5ac8ca190 --- /dev/null +++ b/.github/agents/bug-resolution-agent.md @@ -0,0 +1,212 @@ +--- +name: Bug Resolution Agent +description: | + A focused agent that resolves GitHub issues by applying minimal, test-driven fixes. + It prioritizes reproducible tests (when feasible), enforces lint and TypeScript compliance, + and avoids refactoring or unrelated changes. +--- + +# Bug Resolution Agent + +## Purpose + +This agent resolves bugs in a precise and minimal manner. +Its goal is to: + +- Reproduce the issue (preferably with an automated test) +- Apply the smallest possible fix +- Ensure quality gates (tests, lint, TypeScript) pass +- Create a clear changeset +- Keep the PR easy to review + +The agent must **not introduce refactors, performance optimizations, or scope expansion**. + +--- + +## Operating Principles + +1. **Minimal Surface Area** + - Only modify what is strictly necessary to resolve the issue. + - Do not refactor unrelated code. + - Do not introduce structural improvements unless required to fix the bug. + +2. **Test-First When Feasible** + - If the issue can be reproduced via automated test (especially API or black-box behavior), write a failing test first. + - The test must fail before the fix and pass after the fix. + +3. **Quality Gates Are Mandatory** + - All existing tests must pass. + - Lint must pass with no new warnings or errors. + - TypeScript type checking must pass without errors. + - Build must succeed. + +4. **Scope Discipline** + - If additional problems are discovered, do not fix them in the same PR. + - Document them as TODOs for future issues (see Section: Documenting Out-of-Scope Findings). + +--- + +## Documenting Out-of-Scope Findings + +When you discover problems outside the current scope during your work, **do not fix them**. Instead, create a detailed TODO comment or document them in the PR description so they can become separate issues. + +### TODO Format + +Add a TODO comment in the code near where the problem was found. + +**Type prefixes** (same as PR conventions): +- `bug` - A bug that needs to be fixed +- `feat` - A new feature opportunity +- `refactor` - Code that needs refactoring +- `chore` - Maintenance tasks (dependencies, configs, etc.) +- `test` - Missing or incomplete tests + +**Optional labels** (GitHub labels in brackets): +- `[security]` - Security-related issues +- `[performance]` - Performance improvements +- `[a11y]` - Accessibility issues +- `[i18n]` - Internationalization issues +- `[breaking]` - Breaking changes +- Any other GitHub label relevant to the issue + +```typescript +// TODO: type [optional-label] +// Problem: +// Location: +// Impact: +// Suggested fix: +// Discovered while: +``` + +### Example + +```typescript +// TODO: bug [high-priority] Race condition in message delivery +// Problem: When multiple messages are sent rapidly, the order is not guaranteed +// due to async handling without proper sequencing. +// Location: apps/meteor/server/services/messages/sendMessage.ts - sendToChannel() +// Impact: Medium - Users may see messages out of order in high-traffic channels +// Suggested fix: Implement a message queue with sequence numbers or use +// optimistic locking on the channel's lastMessageAt timestamp. +// Discovered while: Fixing #12345 - Message duplication bug +``` + +### In PR Description + +Also list discovered issues in the PR description under a "Discovered Issues" section: + +```markdown +## Discovered Issues (Out of Scope) + +The following issues were discovered during this work and should be addressed in separate PRs: + +- `bug` [high-priority]: **Race condition in message delivery** - See TODO in `sendMessage.ts:142` +- `test`: **Missing input validation tests** - See TODO in `userController.ts:87` +``` + +--- + +## Step-by-Step Execution Flow + +### 1. Analyze the Issue + +- Carefully read the issue description. +- Identify: + - Expected behavior + - Actual behavior + - Reproduction steps +- Do not assume undocumented requirements. + +--- + +### 2. Determine Test Feasibility + +- Can the bug be reproduced through: + - Unit tests? + - Integration tests? + - API black-box tests? + +If **yes** → proceed to Step 3. +If **no** → proceed directly to Step 4. + +--- + +### 3. Write a Failing Test (Preferred Path) + +- Implement a test that reproduces the bug. +- The test must: + - Reflect the reported behavior + - Fail under the current implementation +- Use the project's existing test framework and conventions. +- Avoid introducing new testing patterns unless strictly required. + +--- + +### 4. Apply the Minimal Fix + +- Implement the smallest change that resolves the failing behavior. +- Do not: + - Refactor unrelated modules + - Rename symbols without necessity + - Change formatting beyond what lint enforces + - Improve performance unless directly tied to the bug + +--- + +### 5. Validate the Fix + +Ensure: + +- The newly created test passes. +- All existing tests pass. +- Lint passes. +- TypeScript compilation passes. +- Build succeeds. + +If any of these fail, adjust only what is required to restore compliance. + +--- + +### 6. Create a Changeset + +Create a concise changeset entry including: + +- What was broken +- What was changed +- How it was validated (test reference) + +Keep it factual and objective. + +--- + +### 7. Open the Pull Request + +The PR must: + +- Clearly reference the original issue. +- Highlight the test added (if applicable). +- Describe the minimal fix applied. +- Avoid mentioning improvements outside the issue scope. + +--- + +## Non-Goals + +This agent must NOT: + +- Perform refactoring +- Improve unrelated code quality +- Introduce stylistic changes +- Expand scope beyond the issue +- Combine multiple bug fixes in one PR + +--- + +## Success Criteria + +A successful run results in: + +- A minimal diff +- A reproducible test (when feasible) +- Passing CI (tests, lint, TypeScript) +- A clear and review-friendly Pull Request diff --git a/.github/agents/feature-development-agent.md b/.github/agents/feature-development-agent.md new file mode 100644 index 0000000000000..f7df896de6dcc --- /dev/null +++ b/.github/agents/feature-development-agent.md @@ -0,0 +1,335 @@ +--- +name: Feature Development Agent +description: | + A comprehensive agent that implements new features following best practices, + with proper planning, testing, documentation, and incremental delivery. + It ensures features are well-designed, tested, and maintainable. +--- + +# Feature Development Agent + +## Purpose + +This agent implements new features in a structured and maintainable way. +Its goal is to: + +- Understand and clarify feature requirements +- Design a clean, extensible implementation +- Write comprehensive tests for new functionality +- Ensure quality gates pass +- Create well-documented, reviewable PRs +- Follow existing patterns and conventions + +The agent must **focus only on the specified feature scope** and avoid scope creep. + +--- + +## Operating Principles + +1. **Requirements First** + - Fully understand the feature before writing code. + - Clarify ambiguities before implementation. + - Define acceptance criteria upfront. + +2. **Design Before Code** + - Plan the architecture and approach. + - Consider edge cases and error handling. + - Identify integration points with existing code. + +3. **Test-Driven Development** + - Write tests that define expected behavior. + - Tests should cover happy paths and edge cases. + - Tests serve as living documentation. + +4. **Quality Gates Are Mandatory** + - All tests (new and existing) must pass. + - Lint must pass with no new warnings or errors. + - TypeScript type checking must pass without errors. + - Build must succeed. + +5. **Incremental Delivery** + - Break features into deliverable increments. + - Each increment should be functional and valuable. + - Prefer feature flags for large features. + +6. **Consistency** + - Follow existing code patterns and conventions. + - Maintain consistency with the codebase style. + - Reuse existing utilities and components. + +7. **Scope Discipline** + - Focus only on the specified feature requirements. + - Do not fix unrelated bugs discovered during implementation. + - Do not refactor existing code beyond what's needed for the feature. + - Document discovered problems as TODOs for future issues (see Section: Documenting Out-of-Scope Findings). + +--- + +## Documenting Out-of-Scope Findings + +When you discover bugs, technical debt, or improvement opportunities outside the current feature scope, **do not fix them**. Instead, create a detailed TODO comment or document them in the PR description so they can become separate issues. + +### TODO Format + +Add a TODO comment in the code near where the problem was found. + +**Type prefixes** (same as PR conventions): +- `bug` - A bug that needs to be fixed +- `feat` - A new feature opportunity +- `refactor` - Code that needs refactoring +- `chore` - Maintenance tasks (dependencies, configs, etc.) +- `test` - Missing or incomplete tests + +**Optional labels** (GitHub labels in brackets): +- `[security]` - Security-related issues +- `[performance]` - Performance improvements +- `[a11y]` - Accessibility issues +- `[i18n]` - Internationalization issues +- `[breaking]` - Breaking changes +- Any other GitHub label relevant to the issue + +```typescript +// TODO: type [optional-label] +// Problem: +// Location: +// Impact: +// Suggested fix: +// Discovered while: +``` + +### Example + +```typescript +// TODO: bug [security] Missing rate limiting on user search endpoint +// Problem: The /api/v1/users.search endpoint has no rate limiting, +// allowing potential abuse through rapid sequential requests. +// Location: apps/meteor/app/api/server/v1/users.ts - searchUsers() +// Impact: High - Security vulnerability, potential DoS vector +// Suggested fix: Add rate limiting middleware similar to login endpoints, +// suggest 10 requests per minute per user. +// Discovered while: Implementing user mention autocomplete feature #54321 +``` + +### In PR Description + +Also list discovered issues in the PR description under a "Discovered Issues" section: + +```markdown +## Discovered Issues (Out of Scope) + +The following issues were discovered during this feature implementation and should be addressed in separate PRs: + +- `bug` [security]: **Missing rate limiting on user search** - See TODO in `users.ts:234` +- `refactor`: **Inconsistent error handling in API** - See TODO in `channels.ts:156` +- `chore`: **Outdated TypeScript types** - See TODO in `types/user.d.ts:12` +``` + +--- + +## Step-by-Step Execution Flow + +### 1. Analyze Feature Requirements + +- Carefully read the feature request/specification. +- Identify: + - Core functionality + - User stories/use cases + - Acceptance criteria + - Non-functional requirements (performance, security) +- List any unclear or ambiguous requirements. +- Do not assume undocumented behavior. + +--- + +### 2. Research Existing Codebase + +- Identify related existing functionality. +- Find: + - Similar features to reference + - Existing patterns to follow + - Utilities and helpers to reuse + - Integration points +- Understand the architectural context. + +--- + +### 3. Design the Implementation + +Create a technical design covering: + +- **Data Model**: New types, interfaces, schemas +- **API Design**: Endpoints, methods, signatures +- **Component Structure**: Files, modules, classes +- **State Management**: How data flows +- **Error Handling**: Expected failures and responses +- **Security**: Authentication, authorization, validation +- **Performance**: Considerations for scale + +--- + +### 4. Define Test Strategy + +Plan tests at multiple levels: + +- **Unit Tests**: Individual functions and components +- **Integration Tests**: Component interactions +- **API Tests**: Endpoint behavior (if applicable) +- **E2E Tests**: User workflows (if applicable) + +Define: +- Happy path scenarios +- Edge cases +- Error scenarios +- Boundary conditions + +--- + +### 5. Implement Incrementally + +For each implementation increment: + +#### 5.1 Write Tests First +- Define expected behavior through tests. +- Tests should initially fail (TDD red phase). + +#### 5.2 Implement the Code +- Write the minimum code to pass tests. +- Follow existing patterns and conventions. +- Add proper TypeScript types. +- Include error handling. + +#### 5.3 Refactor if Needed +- Clean up the implementation. +- Ensure code quality standards are met. +- Keep tests passing. + +#### 5.4 Verify Quality Gates +- Run all tests. +- Run lint. +- Run TypeScript compilation. +- Build the project. + +--- + +### 6. Add Documentation + +Document the new feature: + +- **Code Comments**: Complex logic explanation +- **JSDoc/TSDoc**: Public APIs and functions +- **README Updates**: If feature affects setup/usage +- **API Documentation**: New endpoints (if applicable) +- **Inline Documentation**: Configuration options + +--- + +### 7. Create a Changeset + +Create a changeset entry including: + +- Feature name and description +- Key functionality added +- Any breaking changes +- Migration notes (if applicable) + +--- + +### 8. Open the Pull Request + +The PR must include: + +- Clear description of the feature +- Link to the original issue/specification +- Summary of implementation approach +- List of new tests added +- Screenshots/recordings (if UI changes) +- Testing instructions for reviewers +- Any deployment considerations + +--- + +## Implementation Guidelines + +### Code Structure +- Place code in appropriate directories following project conventions. +- Create new files for distinct functionality. +- Keep files focused and reasonably sized. + +### Type Safety +- Define explicit types for all new code. +- Avoid `any` types. +- Use generics where appropriate. +- Export types that consumers need. + +### Error Handling +- Handle all expected error cases. +- Provide meaningful error messages. +- Use appropriate error types/codes. +- Log errors appropriately. + +### Security +- Validate all inputs. +- Sanitize outputs where needed. +- Follow authentication/authorization patterns. +- Never expose sensitive data. + +### Performance +- Consider performance implications. +- Avoid unnecessary computations. +- Use appropriate data structures. +- Add caching where beneficial. + +--- + +## Feature Categories + +### API Features +- Define clear endpoint contracts. +- Follow REST/GraphQL conventions. +- Include proper validation. +- Document request/response schemas. + +### UI Features +- Follow design system patterns. +- Ensure accessibility (a11y). +- Support internationalization (i18n). +- Handle loading and error states. + +### Backend Features +- Design for scalability. +- Consider data migration needs. +- Handle edge cases gracefully. +- Add appropriate logging. + +### Integration Features +- Define clear interfaces. +- Handle external service failures. +- Add retry logic where appropriate. +- Document integration requirements. + +--- + +## Non-Goals + +This agent must NOT: + +- Implement features beyond the defined scope +- Fix unrelated bugs (create separate issues) +- Refactor existing code unrelated to the feature +- Skip test coverage +- Ignore existing patterns and conventions +- Make breaking changes without explicit approval + +--- + +## Success Criteria + +A successful feature implementation results in: + +- Fully functional feature matching requirements +- Comprehensive test coverage +- Passing CI (tests, lint, TypeScript) +- Clear documentation +- Easy-to-review Pull Request +- No regressions in existing functionality +- Ready for production deployment diff --git a/.github/agents/refactor-agent.md b/.github/agents/refactor-agent.md new file mode 100644 index 0000000000000..9e855f3302d58 --- /dev/null +++ b/.github/agents/refactor-agent.md @@ -0,0 +1,261 @@ +--- +name: Refactor Agent +description: | + A disciplined agent that performs code refactoring with a focus on improving code quality, + maintainability, and readability without changing external behavior. It ensures all tests + pass before and after changes, and creates incremental, reviewable PRs. +--- + +# Refactor Agent + +## Purpose + +This agent performs controlled refactoring operations to improve code quality. +Its goal is to: + +- Improve code readability, maintainability, and structure +- Preserve existing behavior (no functional changes) +- Ensure all quality gates pass throughout the process +- Create incremental, easy-to-review changes +- Document the rationale behind refactoring decisions + +The agent must **not introduce new features, fix bugs, or change external behavior**. + +--- + +## Operating Principles + +1. **Behavior Preservation** + - External behavior must remain identical before and after refactoring. + - All existing tests must continue to pass. + - If tests fail, the refactoring approach must be reconsidered. + +2. **Incremental Changes** + - Break large refactors into smaller, atomic commits. + - Each commit should be independently valid and reviewable. + - Prefer multiple small PRs over one large PR when appropriate. + +3. **Quality Gates Are Mandatory** + - All existing tests must pass. + - Lint must pass with no new warnings or errors. + - TypeScript type checking must pass without errors. + - Build must succeed. + +4. **Clear Rationale** + - Every refactoring decision must have a documented reason. + - Common reasons include: reducing duplication, improving type safety, enhancing readability, simplifying complexity. + +5. **Scope Discipline** + - Stay focused on the refactoring goal. + - Do not fix unrelated bugs discovered during refactoring. + - Do not add new features or optimizations. + - Document discovered problems as TODOs for future issues (see Section: Documenting Out-of-Scope Findings). + +--- + +## Documenting Out-of-Scope Findings + +When you discover bugs, technical debt, or improvement opportunities outside the current refactoring scope, **do not fix them**. Instead, create a detailed TODO comment or document them in the PR description so they can become separate issues. + +### TODO Format + +Add a TODO comment in the code near where the problem was found. + +**Type prefixes** (same as PR conventions): +- `bug` - A bug that needs to be fixed +- `feat` - A new feature opportunity +- `refactor` - Code that needs refactoring +- `chore` - Maintenance tasks (dependencies, configs, etc.) +- `test` - Missing or incomplete tests + +**Optional labels** (GitHub labels in brackets): +- `[security]` - Security-related issues +- `[performance]` - Performance improvements +- `[a11y]` - Accessibility issues +- `[i18n]` - Internationalization issues +- `[breaking]` - Breaking changes +- Any other GitHub label relevant to the issue + +```typescript +// TODO: type [optional-label] +// Problem: +// Location: +// Impact: +// Suggested fix: +// Discovered while: +``` + +### Example + +```typescript +// TODO: chore [breaking] Deprecated API usage in notification service +// Problem: The notifyUser() function uses the deprecated Meteor.defer() API +// which will be removed in the next major version. +// Location: apps/meteor/server/services/notifications/notifyUser.ts:45 +// Impact: High - Will break notifications after Meteor upgrade +// Suggested fix: Replace with Promise-based async/await pattern using +// the new queueMicrotask() or setImmediate() alternatives. +// Discovered while: Refactoring notification module structure +``` + +### In PR Description + +Also list discovered issues in the PR description under a "Discovered Issues" section: + +```markdown +## Discovered Issues (Out of Scope) + +The following issues were discovered during this refactoring and should be addressed in separate PRs: + +- `chore` [breaking]: **Deprecated API usage** - See TODO in `notifyUser.ts:45` +- `bug`: **Missing error boundary** - See TODO in `MessageList.tsx:23` +- `bug` [performance]: **Potential memory leak** - See TODO in `subscriptionManager.ts:112` +``` + +--- + +## Step-by-Step Execution Flow + +### 1. Understand the Refactoring Goal + +- Clearly define what needs to be improved: + - Code duplication? + - Complex conditionals? + - Poor naming? + - Missing type safety? + - Tight coupling? + - Large files/functions? +- Identify the scope and boundaries of the refactoring. + +--- + +### 2. Analyze Current State + +- Review the existing code structure. +- Identify: + - Existing test coverage + - Dependencies and dependents + - Potential risk areas +- Document the current state for reference. + +--- + +### 3. Ensure Test Coverage + +- Verify existing tests adequately cover the code being refactored. +- If coverage is insufficient: + - Add characterization tests that capture current behavior. + - These tests act as a safety net during refactoring. +- Tests must pass before any refactoring begins. + +--- + +### 4. Plan the Refactoring Steps + +- Break down the refactoring into discrete steps. +- Each step should: + - Be independently verifiable + - Maintain a working codebase + - Be easy to review +- Order steps to minimize risk. + +--- + +### 5. Execute Refactoring Incrementally + +For each step: + +1. Make the targeted change. +2. Run tests to verify behavior preservation. +3. Run lint and type checking. +4. Commit with a clear message explaining the change. + +Common refactoring patterns to apply: + +- **Extract Function/Method**: Break down large functions +- **Rename**: Improve clarity of names +- **Move**: Relocate code to better locations +- **Inline**: Remove unnecessary abstractions +- **Extract Interface/Type**: Improve type definitions +- **Consolidate Duplicates**: DRY principle application +- **Simplify Conditionals**: Reduce complexity + +--- + +### 6. Validate Final State + +After all refactoring steps: + +- All tests pass. +- Lint passes. +- TypeScript compilation passes. +- Build succeeds. +- Code review checklist: + - Is the code more readable? + - Is the code more maintainable? + - Is the structure improved? + - Are there any regressions? + +--- + +### 7. Open the Pull Request + +The PR must: + +- Clearly describe the refactoring goal and rationale. +- List the refactoring patterns applied. +- Confirm that no behavioral changes were introduced. +- Reference any related issues or technical debt items. +- Include before/after examples if helpful. + +--- + +## Refactoring Categories + +### Structural Refactoring +- File/folder reorganization +- Module extraction +- Dependency restructuring + +### Code Quality Refactoring +- Naming improvements +- Function decomposition +- Duplication removal +- Complexity reduction + +### Type Safety Refactoring +- Adding explicit types +- Removing `any` types +- Improving generic usage +- Adding type guards + +### Pattern Application +- Applying design patterns +- Removing anti-patterns +- Standardizing approaches + +--- + +## Non-Goals + +This agent must NOT: + +- Change external behavior +- Fix bugs (create separate issues) +- Add new features +- Optimize performance (unless part of explicit refactoring goal) +- Make changes outside the defined scope +- Skip quality gate verification + +--- + +## Success Criteria + +A successful refactoring results in: + +- Improved code quality metrics (readability, maintainability) +- All tests passing (no behavioral changes) +- Passing CI (tests, lint, TypeScript) +- Clear documentation of changes +- Easy-to-review Pull Request +- No new bugs introduced diff --git a/.github/pr-title-checker-config.json b/.github/pr-title-checker-config.json deleted file mode 100644 index de31f80d51b37..0000000000000 --- a/.github/pr-title-checker-config.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "LABEL": { - "name": "Invalid PR Title", - "color": "B60205" - }, - "CHECKS": { - "regexp": "(feat|fix|ci|chore|docs|test|refactor|i18n|regression|revert)(\\([^\\)]+\\))?\\!?: .{1,}$|(?:Bump .+)$|^Release [0-9]+\\.[0-9]+\\.[0-9]+$|^Merge master into develop", - "ignoreLabels": ["[ignore-title]"] - }, - "MESSAGES": { - "failure": "Invalid PR title. Please use one of the following formats: 'feat: add new feature', 'fix: fix a bug', 'ci: update CI configuration', 'chore: update dependencies', 'docs: update documentation', 'test: add tests', 'refactor: refactor code', 'i18n: update translations', 'regression: fix a regression'.\nFor more info please check [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/)." - } -} diff --git a/.github/workflows/auto-close-duplicates.yml b/.github/workflows/auto-close-duplicates.yml new file mode 100644 index 0000000000000..6f24ce2028b8a --- /dev/null +++ b/.github/workflows/auto-close-duplicates.yml @@ -0,0 +1,31 @@ +name: Auto-close duplicate issues +description: Auto-closes issues that are duplicates of existing issues +on: + schedule: + - cron: '0 9 * * *' + workflow_dispatch: + +jobs: + auto-close-duplicates: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + issues: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Auto-close duplicate issues + run: bun run scripts/auto-close-duplicates.ts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }} + STATSIG_API_KEY: ${{ secrets.STATSIG_API_KEY }} diff --git a/.github/workflows/ci-code-check.yml b/.github/workflows/ci-code-check.yml index 52e3b7c085d9d..a2eecd5603f3e 100644 --- a/.github/workflows/ci-code-check.yml +++ b/.github/workflows/ci-code-check.yml @@ -43,20 +43,10 @@ jobs: - uses: rharkor/caching-for-turbo@v1.8 - - name: Restore packages build - uses: actions/download-artifact@v7 - with: - name: packages-build - path: /tmp - - - name: Unpack packages build - shell: bash - run: | - tar -xzf /tmp/RocketChat-packages-build.tar.gz -C . + - uses: ./.github/actions/restore-packages - name: Cache TypeCheck uses: actions/cache@v5 - if: matrix.check == 'ts' with: path: ./apps/meteor/tsconfig.typecheck.tsbuildinfo key: typecheck-cache-${{ runner.OS }}-${{ hashFiles('yarn.lock') }}-${{ github.event.issue.number }} @@ -66,7 +56,6 @@ jobs: typecheck-cache - name: Install Meteor - if: matrix.check == 'ts' shell: bash run: | # Restore bin from cache @@ -88,7 +77,7 @@ jobs: - name: TS TypeCheck if: matrix.check == 'ts' - run: yarn turbo run typecheck + run: yarn turbo run typecheck --concurrency=5 - name: Cache eslint uses: actions/cache@v5 diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index 0e15ee6c9fb5a..99395082a9fbc 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -74,7 +74,7 @@ jobs: # if building for production on develop branch or release, add suffix for coverage images DOCKER_TAG_SUFFIX_ROCKETCHAT: ${{ inputs.coverage == matrix.mongodb-version && (github.event_name == 'release' || github.ref == 'refs/heads/develop') && '-cov' || '' }} MONGODB_VERSION: ${{ matrix.mongodb-version }} - COVERAGE_DIR: '/tmp/coverage/${{ inputs.type }}' + COVERAGE_DIR: '/tmp/coverage/${{ startsWith(inputs.type, ''api'') && ''api'' || inputs.type }}' COVERAGE_FILE_NAME: '${{ inputs.type }}-${{ matrix.shard }}.json' COVERAGE_REPORTER: ${{ inputs.coverage == matrix.mongodb-version && 'json' || '' }} @@ -125,20 +125,11 @@ jobs: - uses: rharkor/caching-for-turbo@v1.8 - - name: Restore packages build - uses: actions/download-artifact@v7 - with: - name: packages-build - path: /tmp - - - name: Unpack packages build - shell: bash - run: | - tar -xzf /tmp/RocketChat-packages-build.tar.gz -C . + - uses: ./.github/actions/restore-packages # Download Docker images from build artifacts - name: Download Docker images - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 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' }} @@ -169,7 +160,7 @@ jobs: run: echo "DEBUG_LOG_LEVEL=2" >> $GITHUB_ENV - name: Start httpbin container and wait for it to be ready - if: inputs.type == 'api' + if: inputs.type == 'api' || inputs.type == 'api-livechat' run: | docker compose -f docker-compose-ci.yml up -d httpbin @@ -227,6 +218,22 @@ jobs: ls -la $COVERAGE_DIR exit $s + - name: E2E Test API (Livechat) + if: inputs.type == 'api-livechat' + working-directory: ./apps/meteor + env: + WEBHOOK_TEST_URL: 'http://httpbin' + IS_EE: ${{ inputs.release == 'ee' && 'true' || '' }} + run: | + set -o xtrace + + npm run testapi:livechat + + docker compose -f ../../docker-compose-ci.yml stop + + ls -la $COVERAGE_DIR + exit $s + - name: E2E Test UI (${{ matrix.shard }}/${{ inputs.total-shard }}) if: inputs.type == 'ui' env: @@ -247,12 +254,14 @@ jobs: QASE_REPORT: ${{ github.ref == 'refs/heads/develop' && 'true' || '' }} CI: true PLAYWRIGHT_RETRIES: ${{ inputs.retries }} + E2E_SHARD: ${{ matrix.shard }} + E2E_TOTAL_SHARD: ${{ inputs.total-shard }} working-directory: ./apps/meteor run: | set -o xtrace yarn prepare - yarn test:e2e --shard=${{ matrix.shard }}/${{ inputs.total-shard }} + yarn test:e2e --shard="$E2E_SHARD/$E2E_TOTAL_SHARD" - name: Merge ui coverage files if: inputs.type == 'ui' && inputs.coverage == matrix.mongodb-version @@ -263,9 +272,9 @@ jobs: - name: Store playwright test trace if: inputs.type == 'ui' && always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: - name: playwright-test-trace-${{ inputs.release }}-${{ matrix.mongodb-version }}-${{ matrix.shard }}${{ inputs.db-watcher-disabled == 'true' && '-no-watcher' || '' }} + name: playwright-test-trace-${{ inputs.release }}-${{ matrix.mongodb-version }}-${{ matrix.shard }} path: ./apps/meteor/tests/e2e/.playwright* include-hidden-files: true @@ -279,7 +288,7 @@ jobs: - name: Store coverage if: inputs.coverage == matrix.mongodb-version - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 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 0606607121db3..c1adfc760e170 100644 --- a/.github/workflows/ci-test-storybook.yml +++ b/.github/workflows/ci-test-storybook.yml @@ -37,16 +37,7 @@ jobs: - uses: rharkor/caching-for-turbo@v1.8 - - name: Restore packages build - uses: actions/download-artifact@v7 - with: - name: packages-build - path: /tmp - - - name: Unpack packages build - shell: bash - run: | - tar -xzf /tmp/RocketChat-packages-build.tar.gz -C . + - uses: ./.github/actions/restore-packages - uses: ./.github/actions/setup-playwright diff --git a/.github/workflows/ci-test-unit.yml b/.github/workflows/ci-test-unit.yml index 7d249f60ff48e..56184ff08aff2 100644 --- a/.github/workflows/ci-test-unit.yml +++ b/.github/workflows/ci-test-unit.yml @@ -41,19 +41,10 @@ jobs: - uses: rharkor/caching-for-turbo@v1.8 - - name: Restore packages build - uses: actions/download-artifact@v7 - with: - name: packages-build - path: /tmp - - - name: Unpack packages build - shell: bash - run: | - tar -xzf /tmp/RocketChat-packages-build.tar.gz -C . + - uses: ./.github/actions/restore-packages - name: Unit Test - run: yarn testunit + run: yarn testunit --concurrency=1 - uses: codecov/codecov-action@v5 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 389be8c766bdf..d590223204a86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: branches: '**' paths-ignore: - '**.md' + merge_group: push: branches: - develop @@ -116,6 +117,8 @@ jobs: else DOCKER_TAG=$GITHUB_REF_NAME fi + # 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 @@ -220,7 +223,7 @@ jobs: $(git ls-files -oi --exclude-standard -- ':(exclude)node_modules/*' ':(exclude)**/node_modules/*' ':(exclude)**/.meteor/*' ':(exclude)**/.turbo/*' ':(exclude).turbo/*' ':(exclude)**/.yarn/*' ':(exclude).yarn/*' ':(exclude).git/*') - name: Upload packages build artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: packages-build path: /tmp/RocketChat-packages-build.tar.gz @@ -228,7 +231,7 @@ jobs: - name: Store turbo build if: steps.packages-cache-build.outputs.cache-hit != 'true' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: turbo-build path: .turbo/cache @@ -250,7 +253,6 @@ jobs: - type: ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'production' || '' }} steps: - - uses: actions/checkout@v6 - uses: ./.github/actions/meteor-build @@ -294,16 +296,7 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Restore packages build - uses: actions/download-artifact@v7 - with: - name: packages-build - path: /tmp - - - name: Unpack packages build - shell: bash - run: | - tar -xzf /tmp/RocketChat-packages-build.tar.gz -C . + - uses: ./.github/actions/restore-packages # we only build and publish the actual docker images if not a PR from a fork - name: Image ${{ matrix.service[0] }} @@ -394,7 +387,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@v7 + uses: actions/download-artifact@v8 with: pattern: manifests-* path: /tmp/manifests @@ -508,6 +501,22 @@ jobs: CR_USER: ${{ secrets.CR_USER }} CR_PAT: ${{ secrets.CR_PAT }} + test-api-livechat: + name: 🔨 Test API Livechat (CE) + needs: [checks, build-gh-docker-publish, release-versions] + + uses: ./.github/workflows/ci-test-e2e.yml + with: + type: api-livechat + release: ce + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} + gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + secrets: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + test-ui: name: 🔨 Test UI (CE) needs: [checks, build-gh-docker-publish, release-versions] @@ -553,6 +562,26 @@ jobs: CR_USER: ${{ secrets.CR_USER }} CR_PAT: ${{ secrets.CR_PAT }} + test-api-livechat-ee: + name: 🔨 Test API Livechat (EE) + needs: [checks, build-gh-docker-publish, release-versions] + + uses: ./.github/workflows/ci-test-e2e.yml + with: + type: api-livechat + release: ee + transporter: 'nats://nats:4222' + enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} + mongodb-version: "['8.0']" + coverage: '8.0' + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} + gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + secrets: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + test-ui-ee: name: 🔨 Test UI (EE) needs: [checks, build-gh-docker-publish, release-versions] @@ -600,7 +629,7 @@ jobs: - uses: rharkor/caching-for-turbo@v1.8 - name: Restore turbo build - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 continue-on-error: true with: name: turbo-build @@ -624,7 +653,7 @@ jobs: # Download Docker images from build artifacts - name: Download Docker images - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 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' @@ -679,18 +708,18 @@ jobs: report-coverage: name: 📊 Report Coverage runs-on: ubuntu-24.04 - needs: [release-versions, test-api-ee, test-ui-ee] + needs: [release-versions, test-api-ee, test-api-livechat-ee, test-ui-ee] steps: - uses: actions/checkout@v6 - name: Use Node.js - uses: actions/setup-node@v6.1.0 + uses: actions/setup-node@v6.2.0 with: node-version: ${{ needs.release-versions.outputs.node-version }} - name: Restore coverage folder - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: pattern: coverage-* path: /tmp/coverage @@ -704,7 +733,7 @@ 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@v6 + uses: actions/upload-artifact@v7 with: name: reports-coverage path: /tmp/coverage_report @@ -731,7 +760,7 @@ jobs: tests-done: name: ✅ Tests Done runs-on: ubuntu-24.04-arm - needs: [checks, test-unit, test-api, test-ui, test-api-ee, test-ui-ee, test-federation-matrix] + needs: [checks, test-unit, test-api, test-ui, test-api-ee, test-ui-ee, test-api-livechat, test-api-livechat-ee, test-federation-matrix] if: always() steps: - name: Test finish aggregation @@ -760,6 +789,14 @@ jobs: exit 1 fi + if [[ '${{ needs.test-api-livechat.result }}' != 'success' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-api-livechat-ee.result }}' != 'success' ]]; then + exit 1 + fi + if [[ '${{ needs.test-federation-matrix.result }}' != 'success' ]]; then exit 1 fi @@ -781,7 +818,7 @@ jobs: ref: ${{ github.ref }} - name: Restore build - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: build-production path: /tmp/build @@ -852,7 +889,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@v7 + uses: actions/download-artifact@v8 with: pattern: manifests-* path: /tmp/manifests @@ -869,6 +906,7 @@ jobs: # 'develop' or 'tag' DOCKER_TAG=$GITHUB_REF_NAME + DOCKER_TAG="${DOCKER_TAG//\//-}" declare -a TAGS=() diff --git a/.github/workflows/dedupe-issues.yml b/.github/workflows/dedupe-issues.yml new file mode 100644 index 0000000000000..3c44d475ef671 --- /dev/null +++ b/.github/workflows/dedupe-issues.yml @@ -0,0 +1,83 @@ +name: Rocket.Chat Issue Dedupe +description: Automatically dedupe GitHub issues using AI +on: + issues: + types: [opened] + workflow_dispatch: + inputs: + issue_number: + description: 'Issue number to process for duplicate detection' + required: true + type: string + +jobs: + dedupe-issues: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + issues: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Run Claude Code slash command + uses: anthropics/claude-code-base-action@beta + 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' + claude_env: | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Log duplicate comment event to Statsig + if: always() + env: + STATSIG_API_KEY: ${{ secrets.STATSIG_API_KEY }} + run: | + ISSUE_NUMBER=${{ github.event.issue.number || inputs.issue_number }} + REPO=${{ github.repository }} + + if [ -z "$STATSIG_API_KEY" ]; then + echo "STATSIG_API_KEY not found, skipping Statsig logging" + exit 0 + fi + + # Prepare the event payload + EVENT_PAYLOAD=$(jq -n \ + --arg issue_number "$ISSUE_NUMBER" \ + --arg repo "$REPO" \ + --arg triggered_by "${{ github.event_name }}" \ + --arg actor "${{ github.actor }}" \ + '{ + events: [{ + eventName: "github_duplicate_comment_added", + value: 1, + metadata: { + repository: $repo, + issue_number: ($issue_number | tonumber), + triggered_by: $triggered_by, + workflow_run_id: "${{ github.run_id }}", + actor: $actor + }, + time: (now | floor | tostring) + }] + }') + + # Send to Statsig API + echo "Logging duplicate comment event to Statsig for issue #${ISSUE_NUMBER}" + + RESPONSE=$(curl -s -w "\n%{http_code}" -X POST https://events.statsigapi.net/v1/log_event \ + -H "Content-Type: application/json" \ + -H "STATSIG-API-KEY: ${STATSIG_API_KEY}" \ + -d "$EVENT_PAYLOAD") + + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | head -n-1) + + if [ "$HTTP_CODE" -eq 200 ] || [ "$HTTP_CODE" -eq 202 ]; then + echo "Successfully logged duplicate comment event for issue #${ISSUE_NUMBER}" + else + echo "Failed to log duplicate comment event for issue #${ISSUE_NUMBER}. HTTP ${HTTP_CODE}: ${BODY}" + fi diff --git a/.github/workflows/pr-title-checker.yml b/.github/workflows/pr-title-checker.yml deleted file mode 100644 index 39e40ba3fd4cf..0000000000000 --- a/.github/workflows/pr-title-checker.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: 'PR Title Checker' -on: - pull_request_target: - types: - - opened - - edited - - synchronize - - labeled - - unlabeled - -jobs: - check: - runs-on: ubuntu-24.04 - steps: - - uses: thehanimo/pr-title-checker@v1.4.3 - with: - GITHUB_TOKEN: ${{ secrets.RC_TITLE_CHECKER }} diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 504c7dfaca42c..b8add61628c5b 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -1,7 +1,7 @@ name: Release candidate cut on: schedule: - - cron: '28 17 20 * *' # run at minute 28 to avoid the chance of delay due to high load on GH + - cron: '28 21 20 * *' # run at minute 28 to avoid the chance of delay due to high load on GH jobs: new-release: runs-on: ubuntu-24.04 diff --git a/.github/workflows/todo.yml b/.github/workflows/todo.yml new file mode 100644 index 0000000000000..cee1e97a864e0 --- /dev/null +++ b/.github/workflows/todo.yml @@ -0,0 +1,58 @@ +name: Create issues from TODOs + +on: + workflow_dispatch: + inputs: + importAll: + default: false + required: false + type: boolean + description: Enable, if you want to import all TODOs. Runs on checked out branch! Only use if you're sure what you are doing. + sha: + default: '' + required: false + type: string + description: 'A commit SHA or range (e.g. "abc123" or "abc123...def456"). Single SHA compares against its parent.' + path: + default: '' + required: false + type: string + description: 'Import TODOs from a specific path (e.g. "apps/meteor/client" or "packages/core-typings/src/IMessage.ts").' + push: + branches: # do not set multiple branches, todos might be added and then get referenced by themselves in case of a merge + - develop + +permissions: + issues: write + repository-projects: read + contents: read + +jobs: + todos: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + working-directory: scripts/todo-issue + + - name: Create issues from TODOs + run: bun run src/index.ts + working-directory: scripts/todo-issue + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }} + GITHUB_SHA: ${{ github.sha }} + BEFORE_SHA: ${{ github.event.before }} + IMPORT_ALL: ${{ inputs.importAll || 'false' }} + SHA_INPUT: ${{ inputs.sha || '' }} + PATH_FILTER: ${{ inputs.path || '' }} diff --git a/.github/workflows/update-version-durability.yml b/.github/workflows/update-version-durability.yml index 00e1ee8125ad6..46f9fbb0c3b0e 100644 --- a/.github/workflows/update-version-durability.yml +++ b/.github/workflows/update-version-durability.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@v6 - name: Use Node.js - uses: actions/setup-node@v6.1.0 + uses: actions/setup-node@v6.2.0 with: node-version: 22.16.0 diff --git a/.vscode/settings.json b/.vscode/settings.json index 2dcd055310d14..9bc41d08b8468 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,22 +1,4 @@ { - "eslint.workingDirectories": [ - { - "pattern": "packages/*", - "changeProcessCWD": true - }, - { - "pattern": "apps/*", - "changeProcessCWD": true - }, - { - "pattern": "ee/apps/*", - "changeProcessCWD": true - }, - { - "pattern": "ee/packages/*", - "changeProcessCWD": true - } - ], "typescript.tsdk": "./node_modules/typescript/lib", "cSpell.words": [ "autotranslate", diff --git a/.worktrees/replies-refactor b/.worktrees/replies-refactor deleted file mode 160000 index e5b749fabc585..0000000000000 --- a/.worktrees/replies-refactor +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e5b749fabc58569dd3d3d059ca1745d9606b661d diff --git a/.yarn/patches/@react-pdf-layout-npm-4.4.2-6c2e3312fa.patch b/.yarn/patches/@react-pdf-layout-npm-4.4.2-6c2e3312fa.patch new file mode 100644 index 0000000000000..a8271b2de103b --- /dev/null +++ b/.yarn/patches/@react-pdf-layout-npm-4.4.2-6c2e3312fa.patch @@ -0,0 +1,15 @@ +diff --git a/lib/index.js b/lib/index.js +index c64bdf2d5f7e704a65be4e9a7116c5ee6a582701..ce6641d0b63daf7c5d3f8a1de773f290c0e9d51c 100644 +--- a/lib/index.js ++++ b/lib/index.js +@@ -2,8 +2,8 @@ import { upperFirst, capitalize, parseFloat as parseFloat$1, without, pick, comp + import * as P from '@react-pdf/primitives'; + import resolveStyle, { transformColor, flatten } from '@react-pdf/stylesheet'; + import layoutEngine, { fontSubstitution, wordHyphenation, scriptItemizer, textDecoration, justification, linebreaker, bidi, fromFragments } from '@react-pdf/textkit'; +-import * as Yoga from 'yoga-layout/load'; +-import { loadYoga as loadYoga$1 } from 'yoga-layout/load'; ++import * as Yoga from 'yoga-layout/dist/src/load.js'; ++import { loadYoga as loadYoga$1 } from 'yoga-layout/dist/src/load.js'; + import emojiRegex from 'emoji-regex-xs'; + import resolveImage from '@react-pdf/image'; + diff --git a/.yarn/patches/yoga-layout-npm-3.2.1-51ec934670.patch b/.yarn/patches/yoga-layout-npm-3.2.1-51ec934670.patch new file mode 100644 index 0000000000000..c67d2ce23858f --- /dev/null +++ b/.yarn/patches/yoga-layout-npm-3.2.1-51ec934670.patch @@ -0,0 +1,26 @@ +diff --git a/dist/binaries/yoga-wasm-base64-esm.js b/dist/binaries/yoga-wasm-base64-esm.js +index 350866aeaf90bdcc7bea18adfaae1cfcf2e40af6..a973419e8569791396b7a123599fd464b8c0cff4 100644 +--- a/dist/binaries/yoga-wasm-base64-esm.js ++++ b/dist/binaries/yoga-wasm-base64-esm.js +@@ -1,6 +1,6 @@ + + var loadYoga = (() => { +- var _scriptDir = import.meta.url; ++ var _scriptDir = undefined; + + return ( + function(loadYoga) { +diff --git a/package.json b/package.json +index 1fb0482c9451d745ca010f9c1ad58f5d0f74a559..8f7705e1325c046fd671ddc163bc34b74d4389cf 100644 +--- a/package.json ++++ b/package.json +@@ -14,7 +14,8 @@ + "types": "./dist/src/index.d.ts", + "exports": { + ".": "./dist/src/index.js", +- "./load": "./dist/src/load.js" ++ "./load": "./dist/src/load.js", ++ "./dist/src/load.js": "./dist/src/load.js" + }, + "files": [ + "dist/binaries/**", diff --git a/README.md b/README.md index f663b0113e1a0..09b4595440480 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ Develop your own apps that can be integrated with Rocket.Chat. We provide an [op Join thousands of members worldwide in our [community server](https://open.rocket.chat). Join [#support](https://open.rocket.chat/channel/support) and [#general](https://open.rocket.chat/channel/general) for help from the community. +![Alt](https://repobeats.axiom.co/api/embed/1efe0f0a7c366bd58068a1ab1f555ab912ec3895.svg "Repobeats analytics image") # 👥 Contributions @@ -115,7 +116,7 @@ We're hiring developers, technical support, and product managers all the time. C - [Twitter](https://twitter.com/RocketChat) - [Facebook](https://www.facebook.com/RocketChatApp) - [LinkedIn](https://www.linkedin.com/company/rocket-chat) -- [Youtube](https://www.youtube.com/channel/UCin9nv7mUjoqrRiwrzS5UVQ) +- [YouTube](https://www.youtube.com/channel/UCin9nv7mUjoqrRiwrzS5UVQ) # 🗒️ Credits diff --git a/apps/meteor/.eslintrc.json b/apps/meteor/.eslintrc.json deleted file mode 100644 index 47376a4e7fddf..0000000000000 --- a/apps/meteor/.eslintrc.json +++ /dev/null @@ -1,156 +0,0 @@ -{ - "extends": [ - "@rocket.chat/eslint-config", - "@rocket.chat/eslint-config/react", - "plugin:you-dont-need-lodash-underscore/compatible", - "plugin:storybook/recommended" - ], - "globals": { - "__meteor_bootstrap__": false, - "__meteor_runtime_config__": false, - "Assets": false, - "chrome": false, - "jscolor": false - }, - "rules": { - "import/named": "error", - "react-hooks/exhaustive-deps": [ - "warn", - { - "additionalHooks": "(useComponentDidUpdate)" - } - ], - "prefer-arrow-callback": [ - "error", - { - "allowNamedFunctions": true - } - ] - }, - "ignorePatterns": [ - "app/emoji-emojione/generateEmojiIndex.js", - "public", - "private/moment-locales", - "imports", - "ee/server/services/dist", - "!.mocharc.js", - "!.mocharc.*.js", - "!.scripts", - "!.storybook", - "!client/.eslintrc.js", - "!ee/client/.eslintrc.js", - "storybook-static", - "packages" - ], - "overrides": [ - { - "files": ["**/*.ts", "**/*.tsx"], - "rules": { - "@typescript-eslint/naming-convention": [ - "error", - { - "selector": ["function", "parameter", "variable"], - "modifiers": ["destructured"], - "format": null - }, - { - "selector": ["variable"], - "format": ["camelCase", "UPPER_CASE", "PascalCase"], - "leadingUnderscore": "allowSingleOrDouble" - }, - { - "selector": ["function"], - "format": ["camelCase", "PascalCase"], - "leadingUnderscore": "allowSingleOrDouble" - }, - { - "selector": ["parameter"], - "format": ["PascalCase"], - "filter": { - "regex": "Component$", - "match": true - } - }, - { - "selector": ["parameter"], - "format": ["camelCase"], - "leadingUnderscore": "allow" - }, - { - "selector": ["parameter"], - "format": ["camelCase"], - "modifiers": ["unused"], - "leadingUnderscore": "require" - }, - { - "selector": "parameter", - "format": null, - "filter": { - "regex": "^Story$", - "match": true - } - }, - { - "selector": ["interface"], - "format": ["PascalCase"], - "custom": { - "regex": "^I[A-Z]", - "match": true - } - } - ], - "no-unreachable-loop": "error" - }, - "parserOptions": { - "project": ["./tsconfig.json"] - }, - "excludedFiles": [".scripts/*.ts"] - }, - { - "files": ["**/*.tests.js", "**/*.tests.ts", "**/*.spec.ts"], - "env": { - "mocha": true - } - }, - { - "files": ["**/*.spec.ts", "**/*.spec.tsx"], - "extends": ["plugin:testing-library/react"], - "rules": { - "testing-library/no-await-sync-events": "warn", - "testing-library/no-manual-cleanup": "warn", - "testing-library/prefer-explicit-assert": "warn", - "testing-library/prefer-user-event": "warn" - }, - "env": { - "mocha": true - } - }, - { - "files": ["**/*.stories.js", "**/*.stories.jsx", "**/*.stories.ts", "**/*.stories.tsx", "**/*.spec.tsx"], - "rules": { - "react/display-name": "off", - "react/no-multi-comp": "off" - } - }, - { - "files": ["**/*.stories.ts", "**/*.stories.tsx"], - "rules": { - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/explicit-module-boundary-types": "off" - } - }, - { - "files": ["client/**/*.ts", "client/**/*.tsx", "ee/client/**/*.ts", "ee/client/**/*.tsx"], - "rules": { - "@typescript-eslint/no-misused-promises": "off", - "@typescript-eslint/no-floating-promises": "off" - } - }, - { - "files": ["**/*.d.ts"], - "rules": { - "@typescript-eslint/naming-convention": "off" - } - } - ] -} diff --git a/apps/meteor/.mocharc.api.js b/apps/meteor/.mocharc.api.js index b73a24a275e43..ca2657fa8a9a2 100644 --- a/apps/meteor/.mocharc.api.js +++ b/apps/meteor/.mocharc.api.js @@ -7,8 +7,8 @@ module.exports = /** @satisfies {import('mocha').MochaOptions} */ ({ ...require('./.mocharc.base.json'), // see https://github.com/mochajs/mocha/issues/3916 timeout: 10000, - bail: true, + bail: false, retries: 0, file: 'tests/end-to-end/teardown.ts', - spec: ['tests/end-to-end/api/**/*', 'tests/end-to-end/apps/*'], + spec: ['tests/end-to-end/api/*.ts', 'tests/end-to-end/api/helpers/**/*', 'tests/end-to-end/api/methods/**/*', 'tests/end-to-end/apps/*'], }); diff --git a/apps/meteor/.mocharc.api.livechat.js b/apps/meteor/.mocharc.api.livechat.js new file mode 100644 index 0000000000000..48a3a13e2751f --- /dev/null +++ b/apps/meteor/.mocharc.api.livechat.js @@ -0,0 +1,14 @@ +'use strict'; + +/* + * Mocha configuration for Livechat REST API integration tests. + */ + +module.exports = /** @satisfies {import('mocha').MochaOptions} */ ({ + ...require('./.mocharc.base.json'), // see https://github.com/mochajs/mocha/issues/3916 + timeout: 10000, + bail: true, + retries: 0, + file: 'tests/end-to-end/teardown.ts', + spec: ['tests/end-to-end/api/livechat/**/*'], +}); diff --git a/apps/meteor/.mocharc.js b/apps/meteor/.mocharc.js index 2ec06b5257b65..11d408b22a601 100644 --- a/apps/meteor/.mocharc.js +++ b/apps/meteor/.mocharc.js @@ -30,6 +30,7 @@ module.exports = { 'app/file-upload/server/**/*.spec.ts', 'app/statistics/server/**/*.spec.ts', 'app/livechat/server/lib/**/*.spec.ts', + 'app/push/server/**/*.spec.ts', 'app/utils/server/**/*.spec.ts', ], }; diff --git a/apps/meteor/CHANGELOG.md b/apps/meteor/CHANGELOG.md index 74dd26f29d705..ef99b7db28c6f 100644 --- a/apps/meteor/CHANGELOG.md +++ b/apps/meteor/CHANGELOG.md @@ -1,5 +1,352 @@ # @rocket.chat/meteor +## 8.3.0-rc.4 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.3.0-rc.4 + - @rocket.chat/rest-typings@8.3.0-rc.4 + - @rocket.chat/abac@0.1.6-rc.4 + - @rocket.chat/federation-matrix@0.1.0-rc.4 + - @rocket.chat/license@1.1.13-rc.4 + - @rocket.chat/media-calls@0.3.0-rc.4 + - @rocket.chat/omnichannel-services@0.3.50-rc.4 + - @rocket.chat/pdf-worker@0.3.32-rc.4 + - @rocket.chat/presence@0.2.53-rc.4 + - @rocket.chat/api-client@0.2.53-rc.4 + - @rocket.chat/apps@0.6.6-rc.4 + - @rocket.chat/core-services@0.13.2-rc.4 + - @rocket.chat/cron@0.1.53-rc.4 + - @rocket.chat/fuselage-ui-kit@29.0.0-rc.4 + - @rocket.chat/gazzodown@29.0.0-rc.4 + - @rocket.chat/http-router@7.9.20-rc.4 + - @rocket.chat/message-types@0.1.1-rc.0 + - @rocket.chat/model-typings@2.1.2-rc.4 + - @rocket.chat/ui-avatar@25.0.0-rc.4 + - @rocket.chat/ui-client@29.0.0-rc.4 + - @rocket.chat/ui-contexts@29.0.0-rc.4 + - @rocket.chat/ui-voip@19.0.0-rc.4 + - @rocket.chat/web-ui-registration@29.0.0-rc.4 + - @rocket.chat/models@2.1.2-rc.4 + - @rocket.chat/server-cloud-communication@0.0.3-rc.0 + - @rocket.chat/network-broker@0.2.32-rc.4 + - @rocket.chat/omni-core-ee@0.0.18-rc.4 + - @rocket.chat/ui-video-conf@29.0.0-rc.4 + - @rocket.chat/instance-status@0.1.53-rc.4 + - @rocket.chat/omni-core@0.0.18-rc.4 + - @rocket.chat/server-fetch@0.1.2-rc.4 +
+ +## 8.3.0-rc.3 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.3.0-rc.3 + - @rocket.chat/rest-typings@8.3.0-rc.3 + - @rocket.chat/abac@0.1.6-rc.3 + - @rocket.chat/federation-matrix@0.1.0-rc.3 + - @rocket.chat/license@1.1.13-rc.3 + - @rocket.chat/media-calls@0.3.0-rc.3 + - @rocket.chat/omnichannel-services@0.3.50-rc.3 + - @rocket.chat/pdf-worker@0.3.32-rc.3 + - @rocket.chat/presence@0.2.53-rc.3 + - @rocket.chat/api-client@0.2.53-rc.3 + - @rocket.chat/apps@0.6.6-rc.3 + - @rocket.chat/core-services@0.13.2-rc.3 + - @rocket.chat/cron@0.1.53-rc.3 + - @rocket.chat/fuselage-ui-kit@29.0.0-rc.3 + - @rocket.chat/gazzodown@29.0.0-rc.3 + - @rocket.chat/http-router@7.9.20-rc.3 + - @rocket.chat/message-types@0.1.1-rc.0 + - @rocket.chat/model-typings@2.1.2-rc.3 + - @rocket.chat/ui-avatar@25.0.0-rc.3 + - @rocket.chat/ui-client@29.0.0-rc.3 + - @rocket.chat/ui-contexts@29.0.0-rc.3 + - @rocket.chat/ui-voip@19.0.0-rc.3 + - @rocket.chat/web-ui-registration@29.0.0-rc.3 + - @rocket.chat/models@2.1.2-rc.3 + - @rocket.chat/server-cloud-communication@0.0.3-rc.0 + - @rocket.chat/network-broker@0.2.32-rc.3 + - @rocket.chat/omni-core-ee@0.0.18-rc.3 + - @rocket.chat/ui-video-conf@29.0.0-rc.3 + - @rocket.chat/instance-status@0.1.53-rc.3 + - @rocket.chat/omni-core@0.0.18-rc.3 + - @rocket.chat/server-fetch@0.1.2-rc.3 +
+ +## 8.3.0-rc.2 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.3.0-rc.2 + - @rocket.chat/rest-typings@8.3.0-rc.2 + - @rocket.chat/abac@0.1.6-rc.2 + - @rocket.chat/federation-matrix@0.1.0-rc.2 + - @rocket.chat/license@1.1.13-rc.2 + - @rocket.chat/media-calls@0.3.0-rc.2 + - @rocket.chat/omnichannel-services@0.3.50-rc.2 + - @rocket.chat/pdf-worker@0.3.32-rc.2 + - @rocket.chat/presence@0.2.53-rc.2 + - @rocket.chat/api-client@0.2.53-rc.2 + - @rocket.chat/apps@0.6.6-rc.2 + - @rocket.chat/core-services@0.13.2-rc.2 + - @rocket.chat/cron@0.1.53-rc.2 + - @rocket.chat/fuselage-ui-kit@29.0.0-rc.2 + - @rocket.chat/gazzodown@29.0.0-rc.2 + - @rocket.chat/http-router@7.9.20-rc.2 + - @rocket.chat/message-types@0.1.1-rc.0 + - @rocket.chat/model-typings@2.1.2-rc.2 + - @rocket.chat/ui-avatar@25.0.0-rc.2 + - @rocket.chat/ui-client@29.0.0-rc.2 + - @rocket.chat/ui-contexts@29.0.0-rc.2 + - @rocket.chat/ui-voip@19.0.0-rc.2 + - @rocket.chat/web-ui-registration@29.0.0-rc.2 + - @rocket.chat/models@2.1.2-rc.2 + - @rocket.chat/server-cloud-communication@0.0.3-rc.0 + - @rocket.chat/network-broker@0.2.32-rc.2 + - @rocket.chat/omni-core-ee@0.0.18-rc.2 + - @rocket.chat/ui-video-conf@29.0.0-rc.2 + - @rocket.chat/instance-status@0.1.53-rc.2 + - @rocket.chat/omni-core@0.0.18-rc.2 + - @rocket.chat/server-fetch@0.1.2-rc.2 +
+ +## 8.3.0-rc.1 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.3.0-rc.1 + - @rocket.chat/rest-typings@8.3.0-rc.1 + - @rocket.chat/abac@0.1.6-rc.1 + - @rocket.chat/federation-matrix@0.1.0-rc.1 + - @rocket.chat/license@1.1.13-rc.1 + - @rocket.chat/media-calls@0.3.0-rc.1 + - @rocket.chat/omnichannel-services@0.3.50-rc.1 + - @rocket.chat/pdf-worker@0.3.32-rc.1 + - @rocket.chat/presence@0.2.53-rc.1 + - @rocket.chat/api-client@0.2.53-rc.1 + - @rocket.chat/apps@0.6.6-rc.1 + - @rocket.chat/core-services@0.13.2-rc.1 + - @rocket.chat/cron@0.1.53-rc.1 + - @rocket.chat/fuselage-ui-kit@29.0.0-rc.1 + - @rocket.chat/gazzodown@29.0.0-rc.1 + - @rocket.chat/http-router@7.9.20-rc.1 + - @rocket.chat/message-types@0.1.1-rc.0 + - @rocket.chat/model-typings@2.1.2-rc.1 + - @rocket.chat/ui-avatar@25.0.0-rc.1 + - @rocket.chat/ui-client@29.0.0-rc.1 + - @rocket.chat/ui-contexts@29.0.0-rc.1 + - @rocket.chat/ui-voip@19.0.0-rc.1 + - @rocket.chat/web-ui-registration@29.0.0-rc.1 + - @rocket.chat/models@2.1.2-rc.1 + - @rocket.chat/server-cloud-communication@0.0.3-rc.0 + - @rocket.chat/network-broker@0.2.32-rc.1 + - @rocket.chat/omni-core-ee@0.0.18-rc.1 + - @rocket.chat/ui-video-conf@29.0.0-rc.1 + - @rocket.chat/instance-status@0.1.53-rc.1 + - @rocket.chat/omni-core@0.0.18-rc.1 + - @rocket.chat/server-fetch@0.1.2-rc.1 +
+ +## 8.3.0-rc.0 + +### Minor Changes + +- ([#39750](https://github.com/RocketChat/Rocket.Chat/pull/39750)) Adds support to name changes on federated rooms + +- ([#39268](https://github.com/RocketChat/Rocket.Chat/pull/39268)) refactor(ui-kit): Remove UiKit deprecations + +- ([#38978](https://github.com/RocketChat/Rocket.Chat/pull/38978) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat autotranslate translateMessage API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation + +- ([#37922](https://github.com/RocketChat/Rocket.Chat/pull/37922)) Introduces native screen sharing for internal voice calls. This feature is currently in beta and can be disabled through admin settings. + +- ([#39225](https://github.com/RocketChat/Rocket.Chat/pull/39225) by [@sezallagwal](https://github.com/sezallagwal)) Add OpenAPI support for the chat.followMessage and chat.unfollowMessage API endpoints by migrating to a modern chained route definition syntax and utilizing AJV schemas for body and response validation. + +- ([#39227](https://github.com/RocketChat/Rocket.Chat/pull/39227) by [@sezallagwal](https://github.com/sezallagwal)) Add OpenAPI support for the chat.starMessage and chat.unStarMessage API endpoints by migrating to a modern chained route definition syntax and utilizing AJV schemas for body and response validation. + +- ([#38957](https://github.com/RocketChat/Rocket.Chat/pull/38957) by [@Verifieddanny](https://github.com/Verifieddanny)) Migrated rooms.leave endpoint to new OpenAPI pattern with AJV validation + +- ([#38549](https://github.com/RocketChat/Rocket.Chat/pull/38549) by [@Rohitgiri02](https://github.com/Rohitgiri02)) migrated rooms.delete endpoint to new OpenAPI pattern with AJV validation + +- ([#39094](https://github.com/RocketChat/Rocket.Chat/pull/39094) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Adds OpenAPI support for the Rocket.Chat e2e.updateGroupKey endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#36402](https://github.com/RocketChat/Rocket.Chat/pull/36402) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat users.getAvatarSuggestion API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#38881](https://github.com/RocketChat/Rocket.Chat/pull/38881) by [@smirk-dev](https://github.com/smirk-dev)) adds `instances.get` API endpoint to new chained pattern with response schemas + +- ([#38883](https://github.com/RocketChat/Rocket.Chat/pull/38883) by [@smirk-dev](https://github.com/smirk-dev)) Migrates `ldap.testConnection` and `ldap.testSearch` REST API endpoints from legacy `addRoute` pattern to the new chained `.post()` API pattern with typed response schemas and AJV body validation (replacing Meteor `check()`). + +- ([#38882](https://github.com/RocketChat/Rocket.Chat/pull/38882) by [@smirk-dev](https://github.com/smirk-dev)) Migrates `presence.getConnections` and `presence.enableBroadcast` REST API endpoints from legacy `addRoute` pattern to the new chained `.get()`/`.post()` API pattern with typed response schemas. + +- ([#38610](https://github.com/RocketChat/Rocket.Chat/pull/38610)) Fixes Custom Sounds Contextualbar state and refresh behavior + +- ([#36779](https://github.com/RocketChat/Rocket.Chat/pull/36779) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat e2e.fetchMyKeys endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#39425](https://github.com/RocketChat/Rocket.Chat/pull/39425)) Adds support for multiple files in message composer, improving file upload experience + +- ([#36916](https://github.com/RocketChat/Rocket.Chat/pull/36916) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat custom-user-status.list API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation + +- ([#39219](https://github.com/RocketChat/Rocket.Chat/pull/39219) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat e2e endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#38610](https://github.com/RocketChat/Rocket.Chat/pull/38610)) Adds new `custom-sounds.getOne` REST endpoint to retrieve a single custom sound by `_id` and updates client to consume it. + +### Patch Changes + +- ([#39492](https://github.com/RocketChat/Rocket.Chat/pull/39492)) Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates) + +- ([#39010](https://github.com/RocketChat/Rocket.Chat/pull/39010)) Fixes an authorization issue that allowed users to confirm uploads from other users + +- ([#39092](https://github.com/RocketChat/Rocket.Chat/pull/39092)) Fixes main channel scroll position changing when jumping to a thread message from search + +- ([#38531](https://github.com/RocketChat/Rocket.Chat/pull/38531)) Fixes a cross-resource access issue that allowed users to retrieve emojis from the Custom Sounds endpoint and sounds from the Custom Emojis endpoint when using the FileSystem storage mode. + +- ([#39752](https://github.com/RocketChat/Rocket.Chat/pull/39752)) Fixes an issue on Federation where all domains ending with the pattern where being allowed to communicate, the feature is meant to work with a list, url by url + +- ([#38662](https://github.com/RocketChat/Rocket.Chat/pull/38662) by [@TheRazorbill](https://github.com/TheRazorbill)) Fixes wrong i18n key in RegisterWorkspace confirmation step so the text is translated instead of showing a missing key. + +- ([#38983](https://github.com/RocketChat/Rocket.Chat/pull/38983) by [@copilot-swe-agent](https://github.com/copilot-swe-agent)) Fixes incoming webhook messages ignoring literal `\n` escape sequences, and fixes the `MarkdownText` `document` variant not rendering newlines as line breaks. + +- ([#39087](https://github.com/RocketChat/Rocket.Chat/pull/39087)) Fixes race condition causing duplicate open livechat rooms per visitor token. + +- ([#39460](https://github.com/RocketChat/Rocket.Chat/pull/39460)) Fixes inconsistent username formatting causing '@@username' for federated users + +- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration + +- ([#39541](https://github.com/RocketChat/Rocket.Chat/pull/39541)) Fixes an issue when forwarding messages to a password-protected room. + +- ([#39003](https://github.com/RocketChat/Rocket.Chat/pull/39003)) Fix marking a message as sent before the request finishes + +- ([#36786](https://github.com/RocketChat/Rocket.Chat/pull/36786) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat e2e.getUsersOfRoomWithoutKey endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#38932](https://github.com/RocketChat/Rocket.Chat/pull/38932)) Fixes version update banner showing outdated versions after server upgrade. + +- ([#39461](https://github.com/RocketChat/Rocket.Chat/pull/39461)) Deprecates `Anonymous write`. Feature will be removed in version 9.0.0. + +- ([#39545](https://github.com/RocketChat/Rocket.Chat/pull/39545)) Fixes the intermittent behavior where the "New messages" indicator appears incorrectly after the user sends a message + +- ([#39753](https://github.com/RocketChat/Rocket.Chat/pull/39753)) Fixes an issue where emails were not saved for users logging in via the GitHub OAuth provider. + +- ([#39491](https://github.com/RocketChat/Rocket.Chat/pull/39491)) Fixes calendar events modifying the wrong status property when attempting to sync `busy` status. + +- ([#39054](https://github.com/RocketChat/Rocket.Chat/pull/39054)) Fixes a mismatch in the room icons on the sidebar items, ABAC Managed rooms were not displaying the correct icon + +- ([#38760](https://github.com/RocketChat/Rocket.Chat/pull/38760) by [@Khizarshah01](https://github.com/Khizarshah01)) Limits `Outgoing webhook` maximum response size to 10mb. + +- ([#39612](https://github.com/RocketChat/Rocket.Chat/pull/39612)) Fixes the download of attachments with non-unicode names on E2EE rooms + +- ([#36882](https://github.com/RocketChat/Rocket.Chat/pull/36882) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat push.test API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#39718](https://github.com/RocketChat/Rocket.Chat/pull/39718)) Fixes an issue where, sometimes, updatedAt was not being set during the subscription creation + +- ([#39557](https://github.com/RocketChat/Rocket.Chat/pull/39557)) Fixes main team channels being able to be converted into public or private with only the `create-team-channel` or `create-team-group` (the correct permission for main teams are `create-c` and `create-p`) + +- ([#39559](https://github.com/RocketChat/Rocket.Chat/pull/39559) by [@copilot-swe-agent](https://github.com/copilot-swe-agent)) Splits the single AJV validator instance into two: `ajv` (coerceTypes: false) for request **body** validation and `ajvQuery` (coerceTypes: true) for **query parameter** validation. + + **Why this matters:** Previously, a single AJV instance with `coerceTypes: true` was used everywhere. This silently accepted values with wrong types — for example, sending `{ "rid": 12345 }` (number) where a string was expected would pass validation because `12345` was coerced to `"12345"`. With this change, body validation is now strict: the server will reject payloads with incorrect types instead of silently coercing them. + + **What may break for API consumers:** + + - **Numeric values sent as strings in POST/PUT/PATCH bodies** (e.g., `{ "count": "10" }` instead of `{ "count": 10 }`) will now be rejected. Ensure JSON bodies use proper types. + - **Boolean values sent as strings in bodies** (e.g., `{ "readThreads": "true" }` instead of `{ "readThreads": true }`) will now be rejected. + - **`null` values where a string is expected** (e.g., `{ "name": null }` for a `type: 'string'` field without `nullable: true`) will no longer be coerced to `""`. + + **No change for query parameters:** GET query params (e.g., `?count=10&offset=0`) continue to be coerced via `ajvQuery`, since HTTP query strings are always strings. + +- ([#39250](https://github.com/RocketChat/Rocket.Chat/pull/39250)) Fixes `inquiries.take` not failing when attempting to take a chat while over chat limits + +- ([#38852](https://github.com/RocketChat/Rocket.Chat/pull/38852)) Fixes an issue where `Production` flag was not being respected when initializing Push Notifications configuration + +- ([#39363](https://github.com/RocketChat/Rocket.Chat/pull/39363) by [@gauravsingh001-cyber](https://github.com/gauravsingh001-cyber)) Fixes "Join" button on Outlook Calendar bubbling click event, also opening the calendar event details. + +- ([#38944](https://github.com/RocketChat/Rocket.Chat/pull/38944) by [@Khizarshah01](https://github.com/Khizarshah01)) Limits Omnichannel webhook maximum response size to 10mb. + +- ([#39678](https://github.com/RocketChat/Rocket.Chat/pull/39678)) Adds support for ban management in federated rooms, enabling authorized users to ban and unban members via UI and slash commands. + +- ([#38954](https://github.com/RocketChat/Rocket.Chat/pull/38954)) Fixes reactivity of Custom Sounds and Custom Emojis storage settings + +- ([#35995](https://github.com/RocketChat/Rocket.Chat/pull/35995) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat rooms.favorite APIs endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#39505](https://github.com/RocketChat/Rocket.Chat/pull/39505)) Fixes `ssrf` validation for oauth endpoints, which allows internal endpoints to be used during the auth flow. + +- ([#36523](https://github.com/RocketChat/Rocket.Chat/pull/36523) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat emoji-custom.create API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#36953](https://github.com/RocketChat/Rocket.Chat/pull/36953) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat commands.get API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#38974](https://github.com/RocketChat/Rocket.Chat/pull/38974) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat dm.close/im.close API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +-
Updated dependencies [602b20a8c570b895eb296ecfe39c9b7fcb12fabd, e2068892bf1ffc88b15ab71ad743cf84e5d31ed5, e65b1764aad1ece3d770599e2ba0e216f41457cf, 6b80941a610085bac643d6958f6efa7c018f4bdf, d1bf2cc675e80403659d388a1fbbdc6f73889dad, 02b1e6e6a184850d21e335077ca30382a1c7a66b, 9a70095296dbf516b0113a9a65e09f25137b2eaf, cd2fc208d351032c0b729755af4886665dca08b6, 87f9262af4a543d52642a54e1ef546d509a79e23, a4e3c1635d55ec4ce04cbde741426770e43581fb, 652ff8cfe26b9068a776c39132c0eb5440702894, 539659af22bc19880eda047dfc0b152472ccb65c, b1b1d6ccd81c90d231a7e594f834965c6e5f4fae, 1741a20dd86c353755becfc706cd9ad63df09cfa, 5518503736b72674753e711ba4089d177ab988a5, a4341ec67d1f0413f30bbabfd292d1b0a41728b2, 40253146de8d8f83737e71b0ade7c67e0c295a28, 85c0ac7d8c7a5b7b89ef58f4a42b18467a8e2dd4, 803b8075514de54c9ff34ba0c9aa3ee5fc3bbe61, 1361a1f4f1e3c0cc3f2a191cef8eccc12a714cde, c217b0bde182e5f76dbe1892d9b37d61ffab71db, 2a2701098536b32143003be8d267891978c708c9, 78e37dc3deae4ff05f5e33f9134c7094fd6c1330, 37acece030bc9f39bdaa86ab0130eb818332033e, 43d0cfc6a70e8a31d5f3d24162216dae6b07efdd, d8baf395181b70fef9ce448eb509f65b66049615, ddc0ed34b03072362d166f1160104a9332b362e8, d83a1a9753464ee916845b3c88757bbcf76884a5, eae3fb3136bd0b48294c050a71b0a36d05ca02b0, 4c2e444216efd514ab406fe8e9cd127ef971d566, 722df6f60bc86c51b204e28a39acb3dc8710bdeb, 78b3fe3ef20e3a545b84551ba3f85cb40e862ba7, 98a6c58a38c053c60db2b4d53a9df0e94fecf0ba, 29b453e1def8092a8d78c28736e2bfb24229717b, 39f2e87e1caa6842e69155f033205cfdc4767b9e, c117492ad90d291a361eedc929506f557495caf7, 7c7324184589a15bf3e67b4f0c1cc222f8d48db3]: + + - @rocket.chat/model-typings@2.1.2-rc.0 + - @rocket.chat/models@2.1.2-rc.0 + - @rocket.chat/federation-matrix@0.1.0-rc.0 + - @rocket.chat/message-parser@0.31.35-rc.0 + - @rocket.chat/fuselage-ui-kit@29.0.0-rc.0 + - @rocket.chat/ui-kit@1.0.0-rc.0 + - @rocket.chat/apps-engine@1.61.0-rc.0 + - @rocket.chat/rest-typings@8.3.0-rc.0 + - @rocket.chat/i18n@2.2.0-rc.0 + - @rocket.chat/ui-voip@19.0.0-rc.0 + - @rocket.chat/server-cloud-communication@0.0.3-rc.0 + - @rocket.chat/omnichannel-services@0.3.50-rc.0 + - @rocket.chat/web-ui-registration@29.0.0-rc.0 + - @rocket.chat/network-broker@0.2.32-rc.0 + - @rocket.chat/password-policies@0.1.1-rc.0 + - @rocket.chat/omni-core-ee@0.0.18-rc.0 + - @rocket.chat/instance-status@0.1.53-rc.0 + - @rocket.chat/media-signaling@0.2.0-rc.0 + - @rocket.chat/patch-injection@0.0.2-rc.0 + - @rocket.chat/media-calls@0.3.0-rc.0 + - @rocket.chat/pdf-worker@0.3.32-rc.0 + - @rocket.chat/account-utils@0.0.3-rc.0 + - @rocket.chat/core-services@0.13.2-rc.0 + - @rocket.chat/message-types@0.1.1-rc.0 + - @rocket.chat/mongo-adapter@0.0.3-rc.0 + - @rocket.chat/ui-video-conf@29.0.0-rc.0 + - @rocket.chat/cas-validate@0.0.4-rc.0 + - @rocket.chat/core-typings@8.3.0-rc.0 + - @rocket.chat/server-fetch@0.1.2-rc.0 + - @rocket.chat/presence@0.2.53-rc.0 + - @rocket.chat/http-router@7.9.20-rc.0 + - @rocket.chat/poplib@0.0.3-rc.0 + - @rocket.chat/ui-composer@0.6.0-rc.0 + - @rocket.chat/ui-contexts@29.0.0-rc.0 + - @rocket.chat/license@1.1.13-rc.0 + - @rocket.chat/api-client@0.2.53-rc.0 + - @rocket.chat/log-format@0.0.3-rc.0 + - @rocket.chat/gazzodown@29.0.0-rc.0 + - @rocket.chat/omni-core@0.0.18-rc.0 + - @rocket.chat/ui-avatar@25.0.0-rc.0 + - @rocket.chat/ui-client@29.0.0-rc.0 + - @rocket.chat/abac@0.1.6-rc.0 + - @rocket.chat/favicon@0.0.5-rc.0 + - @rocket.chat/tracing@0.0.2-rc.0 + - @rocket.chat/agenda@0.1.1-rc.0 + - @rocket.chat/base64@1.0.14-rc.0 + - @rocket.chat/logger@1.0.1-rc.0 + - @rocket.chat/random@1.2.3-rc.0 + - @rocket.chat/sha256@1.0.13-rc.0 + - @rocket.chat/tools@0.2.5-rc.0 + - @rocket.chat/apps@0.6.6-rc.0 + - @rocket.chat/cron@0.1.53-rc.0 + - @rocket.chat/jwt@0.2.1-rc.0 +
+ ## 8.2.1 ### Patch Changes diff --git a/apps/meteor/app/2fa/server/code/EmailCheck.ts b/apps/meteor/app/2fa/server/code/EmailCheck.ts index 8c9865c0b0042..5967aa518348b 100644 --- a/apps/meteor/app/2fa/server/code/EmailCheck.ts +++ b/apps/meteor/app/2fa/server/code/EmailCheck.ts @@ -101,13 +101,13 @@ ${t('If_you_didnt_try_to_login_in_your_account_please_ignore_this_email')} const random = Random._randomString(6, '0123456789'); const encryptedRandom = await bcrypt.hash(random, Accounts._bcryptRounds()); const expire = new Date(); - const expirationInSeconds = parseInt(settings.get('Accounts_TwoFactorAuthentication_By_Email_Code_Expiration') as string, 10); + const expirationInSeconds = parseInt(settings.get('Accounts_TwoFactorAuthentication_By_Email_Code_Expiration'), 10); expire.setSeconds(expire.getSeconds() + expirationInSeconds); await Users.addEmailCodeByUserId(user._id, encryptedRandom, expire); - for await (const address of emails) { + for (const address of emails) { await this.send2FAEmail(address, random, user); } } diff --git a/apps/meteor/app/2fa/server/code/PasswordCheckFallback.ts b/apps/meteor/app/2fa/server/code/PasswordCheckFallback.ts index ba462abc1f93c..d0b3661677249 100644 --- a/apps/meteor/app/2fa/server/code/PasswordCheckFallback.ts +++ b/apps/meteor/app/2fa/server/code/PasswordCheckFallback.ts @@ -1,5 +1,6 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Accounts } from 'meteor/accounts-base'; +import type { Meteor } from 'meteor/meteor'; import type { ICodeCheck, IProcessInvalidCodeResult } from './ICodeCheck'; import { settings } from '../../../settings/server'; diff --git a/apps/meteor/app/2fa/server/functions/resetTOTP.ts b/apps/meteor/app/2fa/server/functions/resetTOTP.ts index 84426cd4f88d8..bbffd70e6e8d8 100644 --- a/apps/meteor/app/2fa/server/functions/resetTOTP.ts +++ b/apps/meteor/app/2fa/server/functions/resetTOTP.ts @@ -36,7 +36,7 @@ const sendResetNotification = async function (uid: string): Promise { const from = settings.get('From_Email'); const subject = t('TOTP_reset_email'); - for await (const address of addresses) { + for (const address of addresses) { try { await Mailer.send({ to: address, diff --git a/apps/meteor/app/2fa/server/methods/validateTempToken.ts b/apps/meteor/app/2fa/server/methods/validateTempToken.ts index 89338acd9730f..cc5c7ad4f9544 100644 --- a/apps/meteor/app/2fa/server/methods/validateTempToken.ts +++ b/apps/meteor/app/2fa/server/methods/validateTempToken.ts @@ -1,5 +1,6 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Users } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; import { notifyOnUserChange, notifyOnUserChangeAsync } from '../../../lib/server/lib/notifyListener'; diff --git a/apps/meteor/app/api/server/ApiClass.ts b/apps/meteor/app/api/server/ApiClass.ts index cf7cf77ac8c1f..2610bc2903911 100644 --- a/apps/meteor/app/api/server/ApiClass.ts +++ b/apps/meteor/app/api/server/ApiClass.ts @@ -18,8 +18,7 @@ import type { RateLimiterOptionsToCheck } from 'meteor/rate-limit'; import { RateLimiter } from 'meteor/rate-limit'; import _ from 'underscore'; -import type { PermissionsPayload } from './api.helpers'; -import { checkPermissionsForInvocation, checkPermissions, parseDeprecation } from './api.helpers'; +import { checkPermissions, parseDeprecation } from './api.helpers'; import type { FailureResult, ForbiddenResult, @@ -41,6 +40,9 @@ import type { } from './definition'; import { getUserInfo } from './helpers/getUserInfo'; import { parseJsonQuery } from './helpers/parseJsonQuery'; +import { authenticationMiddlewareForHono } from './middlewares/authenticationHono'; +import { permissionsMiddleware } from './middlewares/permissions'; +import type { APIActionContext } from './router'; import { RocketChatAPIRouter } from './router'; import { license } from '../../../ee/app/api-enterprise/server/middlewares/license'; import { isObject } from '../../../lib/utils/isObject'; @@ -57,7 +59,7 @@ const logger = new Logger('API'); // We have some breaking changes planned to the API. // To avoid conflicts or missing something during the period we are adopting a 'feature flag approach' // TODO: MAJOR check if this is still needed -const applyBreakingChanges = shouldBreakInVersion('9.0.0'); +export const applyBreakingChanges = shouldBreakInVersion('9.0.0'); type MinimalRoute = { method: 'GET' | 'POST' | 'PUT' | 'DELETE'; path: string; @@ -166,7 +168,7 @@ export class APIClass }[] = []; - public authMethods: ((routeContext: GenericRouteExecutionContext) => Promise)[]; + public authMethods: ((routeContext: APIActionContext) => Promise)[]; protected helperMethods: Map any> = new Map(); @@ -248,7 +250,7 @@ export class APIClass Promise): void { + public addAuthMethod(func: (routeContext: APIActionContext) => Promise): void { this.authMethods.push(func); } @@ -276,7 +278,7 @@ export class APIClass; - return finalResult as SuccessResult; + return finalResult; } public redirect(code: C, result: T): RedirectResult { @@ -779,7 +781,7 @@ export class APIClass; if (typeof operations[method as keyof Operations] === 'function') { - (operations as Record)[method as string] = { + (operations as Record)[method] = { action: operations[method as keyof Operations], }; } else { @@ -825,29 +827,8 @@ export class APIClass error.message).join('\n ')); } } - if (shouldVerifyPermissions) { - if (!this.userId) { - if (applyBreakingChanges) { - throw new Meteor.Error('error-unauthorized', 'You must be logged in to do this'); - } - throw new Meteor.Error('error-unauthorized', 'User does not have the permissions required for this action'); - } - if ( - !(await checkPermissionsForInvocation( - this.userId, - _options.permissionsRequired as PermissionsPayload, - this.request.method as Method, - )) - ) { - if (applyBreakingChanges) { - throw new Meteor.Error('error-forbidden', 'User does not have the permissions required for this action', { - permissions: _options.permissionsRequired, - }); - } - throw new Meteor.Error('error-unauthorized', 'User does not have the permissions required for this action', { - permissions: _options.permissionsRequired, - }); - } - } - if ( this.userId && (await api.processTwoFactor({ @@ -954,8 +910,15 @@ export class APIClass] as Record).action as any, + (operations[method as keyof Operations] as Record).action, ); this._routes.push({ path: route, @@ -971,7 +934,7 @@ export class APIClass { + public async authenticatedRoute(routeContext: APIActionContext): Promise { const userId = routeContext.request.headers.get('x-user-id'); const userToken = routeContext.request.headers.get('x-auth-token'); @@ -988,7 +951,6 @@ export class APIClass).addRoute( 'login', - { authRequired: false }, + { authRequired: false, userWithoutUsername: true }, { async post() { const request = this.request as unknown as Request; @@ -1089,7 +1051,7 @@ export class APIClass Meteor.callAsync('login', args)); - this.user = await Users.findOne( + const user = await Users.findOne( { _id: auth.id, }, @@ -1098,18 +1060,16 @@ export class APIClass, Range<500>>, 509>; export type SuccessResult = { statusCode: TStatusCode; - body: T extends object ? { success: true } & T : T; + body: T extends Record ? { success: true } & T : T; }; export type FailureResult = { @@ -110,6 +110,7 @@ export type SharedOptions = ( '*'?: { operation: TOperation; permissions: string[] }; }); authRequired?: boolean; + userWithoutUsername?: boolean; forceTwoFactorAuthenticationForNonEnterprise?: boolean; rateLimiterOptions?: | { @@ -128,6 +129,7 @@ export type SharedOptions = ( '*'?: { operation: TOperation; permissions: string[] }; }); authRequired: true; + userWithoutUsername?: boolean; twoFactorRequired: true; twoFactorOptions?: ITwoFactorOptions; rateLimiterOptions?: @@ -215,19 +217,19 @@ export type ActionThis; userId: string; readonly token: string; } : TOptions extends { authOrAnonRequired: true } ? { - user?: IUser; + user?: TOptions extends { userWithoutUsername: true } ? IUser : RequiredField; userId?: string; readonly token?: string; } : { - user?: IUser | null; - userId?: string | undefined; + user?: TOptions extends { userWithoutUsername: true } ? IUser : RequiredField; + userId?: string; readonly token?: string; }); @@ -297,7 +299,6 @@ export type TypedOptions = { export type TypedThis = { readonly logger: Logger; userId: TOptions['authRequired'] extends true ? string : string | undefined; - user: TOptions['authRequired'] extends true ? IUser : IUser | null; token: TOptions['authRequired'] extends true ? string : string | undefined; queryParams: TOptions['query'] extends ValidateFunction ? Query : never; urlParams: UrlParams extends Record ? UrlParams : never; @@ -313,11 +314,17 @@ export type TypedThis query: Record; }>; bodyParams: TOptions['body'] extends ValidateFunction ? Body : never; - + request: Request; requestIp?: string; route: string; response: Response; -}; +} & (TOptions['authRequired'] extends true + ? { + user: TOptions extends { userWithoutUsername: true } ? IUser : RequiredField; + } + : { + user?: TOptions extends { userWithoutUsername: true } ? IUser : RequiredField; + }); type PromiseOrValue = T | Promise; diff --git a/apps/meteor/app/api/server/helpers/getLoggedInUser.ts b/apps/meteor/app/api/server/helpers/getLoggedInUser.ts deleted file mode 100644 index d3fc562eeb20f..0000000000000 --- a/apps/meteor/app/api/server/helpers/getLoggedInUser.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { IUser } from '@rocket.chat/core-typings'; -import { Users } from '@rocket.chat/models'; -import { Accounts } from 'meteor/accounts-base'; - -export async function getLoggedInUser(request: Request): Promise | null> { - const token = request.headers.get('x-auth-token'); - const userId = request.headers.get('x-user-id'); - if (!token || !userId || typeof token !== 'string' || typeof userId !== 'string') { - return null; - } - - return Users.findOneByIdAndLoginToken(userId, Accounts._hashLoginToken(token), { projection: { username: 1 } }); -} diff --git a/apps/meteor/app/api/server/helpers/getUserFromParams.ts b/apps/meteor/app/api/server/helpers/getUserFromParams.ts index ad2efce90fe50..984658371da2c 100644 --- a/apps/meteor/app/api/server/helpers/getUserFromParams.ts +++ b/apps/meteor/app/api/server/helpers/getUserFromParams.ts @@ -3,14 +3,17 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -export async function getUserFromParams(params: { - userId?: string; - username?: string; - user?: string; -}): Promise> { +export async function getUserFromParams( + params: { + userId?: string; + username?: string; + user?: string; + }, + full?: T, +): Promise> { let user; - const projection = { username: 1, name: 1, status: 1, statusText: 1, roles: 1 }; + const projection = full ? {} : { username: 1, name: 1, status: 1, statusText: 1, roles: 1 }; if (params.userId?.trim()) { user = await Users.findOneById(params.userId, { projection }); } else if (params.username?.trim()) { diff --git a/apps/meteor/app/api/server/helpers/getUserInfo.spec.ts b/apps/meteor/app/api/server/helpers/getUserInfo.spec.ts index eaf6122fb99c4..4d00e1292f62f 100644 --- a/apps/meteor/app/api/server/helpers/getUserInfo.spec.ts +++ b/apps/meteor/app/api/server/helpers/getUserInfo.spec.ts @@ -1,6 +1,16 @@ import { getUserInfo } from './getUserInfo'; import type { CachedSettings } from '../../../settings/server/CachedSettings'; +const mockInfoVersion = jest.fn(() => '7.5.0'); + +jest.mock('../../../utils/rocketchat.info', () => ({ + Info: { + get version() { + return mockInfoVersion(); + }, + }, +})); + jest.mock('@rocket.chat/models', () => ({ Users: { findOneById: jest.fn().mockResolvedValue({ @@ -198,4 +208,50 @@ describe('getUserInfo', () => { }); }); }); + + describe('version update banner filtering', () => { + beforeEach(() => { + mockInfoVersion.mockReturnValue('7.5.0'); + }); + + it('should filter out versionUpdate banners for versions <= current installed', async () => { + user.banners = { + 'versionUpdate-6_2_0': { + id: 'versionUpdate-6_2_0', + priority: 10, + title: 'Update', + text: 'New version', + modifiers: [], + link: '', + read: false, + }, + }; + const userInfo = await getUserInfo(user); + expect(userInfo.banners).toEqual({}); + }); + + it('should keep versionUpdate banners for versions > current installed', async () => { + user.banners = { + 'versionUpdate-8_0_0': { + id: 'versionUpdate-8_0_0', + priority: 10, + title: 'Update', + text: 'New version', + modifiers: [], + link: '', + read: false, + }, + }; + const userInfo = await getUserInfo(user); + expect(userInfo.banners).toHaveProperty('versionUpdate-8_0_0'); + }); + + it('should keep non-versionUpdate banners unchanged', async () => { + user.banners = { + 'other-banner': { id: 'other-banner', priority: 10, title: 'Other', text: 'Other banner', modifiers: [], link: '', read: false }, + }; + const userInfo = await getUserInfo(user); + expect(userInfo.banners).toHaveProperty('other-banner'); + }); + }); }); diff --git a/apps/meteor/app/api/server/helpers/getUserInfo.ts b/apps/meteor/app/api/server/helpers/getUserInfo.ts index beed975452c7a..e567d7b468546 100644 --- a/apps/meteor/app/api/server/helpers/getUserInfo.ts +++ b/apps/meteor/app/api/server/helpers/getUserInfo.ts @@ -1,6 +1,8 @@ import { isOAuthUser, type IUser, type IUserEmail, type IUserCalendar } from '@rocket.chat/core-typings'; +import semver from 'semver'; import { settings } from '../../../settings/server'; +import { Info } from '../../../utils/rocketchat.info'; import { getURL } from '../../../utils/server/getURL'; import { getUserPreference } from '../../../utils/server/lib/getUserPreference'; @@ -17,7 +19,7 @@ const getUserPreferences = async (me: IUser): Promise> = const allDefaultUserSettings = settings.getByRegexp(new RegExp(`^${defaultUserSettingPrefix}.*$`)); const accumulator: Record = {}; - for await (const [key] of allDefaultUserSettings) { + for (const [key] of allDefaultUserSettings) { const settingWithoutPrefix = key.replace(defaultUserSettingPrefix, ' ').trim(); accumulator[settingWithoutPrefix] = await getUserPreference(me, settingWithoutPrefix); } @@ -25,6 +27,23 @@ const getUserPreferences = async (me: IUser): Promise> = return accumulator; }; +const filterOutdatedVersionUpdateBanners = (banners: NonNullable): IUser['banners'] => { + return Object.fromEntries( + Object.entries(banners).filter(([id]) => { + if (!id.startsWith('versionUpdate-')) { + return true; + } + + const version = id.replace('versionUpdate-', '').replace(/_/g, '.'); + if (!semver.valid(version) || semver.lte(version, Info.version)) { + return false; + } + + return true; + }), + ); +}; + /** * Returns the user's calendar settings based on their email domain and the configured mapping. * If the email is not provided or the domain is not found in the mapping, @@ -80,6 +99,7 @@ export async function getUserInfo( return { ...me, + ...(me.banners && { banners: filterOutdatedVersionUpdateBanners(me.banners) }), email: verifiedEmail ? verifiedEmail.address : undefined, settings: { profile: {}, diff --git a/apps/meteor/app/api/server/helpers/parseJsonQuery.ts b/apps/meteor/app/api/server/helpers/parseJsonQuery.ts index 9dd6c9c0448af..fafaebcf59c94 100644 --- a/apps/meteor/app/api/server/helpers/parseJsonQuery.ts +++ b/apps/meteor/app/api/server/helpers/parseJsonQuery.ts @@ -26,7 +26,8 @@ export async function parseJsonQuery(api: GenericRouteExecutionContext): Promise query: Record; }> { const { userId = '', response, route, logger } = api; - + const isUsersRoute = route.includes('/v1/users.'); + const canViewFullOtherUserInfo = isUsersRoute && (await hasPermissionAsync(userId, 'view-full-other-user-info')); const params = isPlainObject(api.queryParams) ? api.queryParams : {}; const queryFields = Array.isArray(api.queryFields) ? (api.queryFields as string[]) : []; const queryOperations = Array.isArray(api.queryOperations) ? (api.queryOperations as string[]) : []; @@ -85,13 +86,9 @@ export async function parseJsonQuery(api: GenericRouteExecutionContext): Promise // Verify the user's selected fields only contains ones which their role allows if (typeof fields === 'object') { let nonSelectableFields = Object.keys(API.v1.defaultFieldsToExclude); - if (route.includes('/v1/users.')) { + if (isUsersRoute) { nonSelectableFields = nonSelectableFields.concat( - Object.keys( - (await hasPermissionAsync(userId, 'view-full-other-user-info')) - ? API.v1.limitedUserFieldsToExcludeIfIsPrivilegedUser - : API.v1.limitedUserFieldsToExclude, - ), + Object.keys(canViewFullOtherUserInfo ? API.v1.limitedUserFieldsToExcludeIfIsPrivilegedUser : API.v1.limitedUserFieldsToExclude), ); } @@ -104,8 +101,8 @@ export async function parseJsonQuery(api: GenericRouteExecutionContext): Promise // Limit the fields by default fields = Object.assign({}, fields, API.v1.defaultFieldsToExclude); - if (route.includes('/v1/users.')) { - if (await hasPermissionAsync(userId, 'view-full-other-user-info')) { + if (isUsersRoute) { + if (canViewFullOtherUserInfo) { fields = Object.assign(fields, API.v1.limitedUserFieldsToExcludeIfIsPrivilegedUser); } else { fields = Object.assign(fields, API.v1.limitedUserFieldsToExclude); @@ -134,8 +131,8 @@ export async function parseJsonQuery(api: GenericRouteExecutionContext): Promise if (typeof query === 'object') { let nonQueryableFields = Object.keys(API.v1.defaultFieldsToExclude); - if (route.includes('/v1/users.')) { - if (await hasPermissionAsync(userId, 'view-full-other-user-info')) { + if (isUsersRoute) { + if (canViewFullOtherUserInfo) { nonQueryableFields = nonQueryableFields.concat(Object.keys(API.v1.limitedUserFieldsToExcludeIfIsPrivilegedUser)); } else { nonQueryableFields = nonQueryableFields.concat(Object.keys(API.v1.limitedUserFieldsToExclude)); diff --git a/apps/meteor/app/api/server/index.ts b/apps/meteor/app/api/server/index.ts index 59986d6e2da87..176141af83e08 100644 --- a/apps/meteor/app/api/server/index.ts +++ b/apps/meteor/app/api/server/index.ts @@ -1,6 +1,5 @@ import './ajv'; import './helpers/composeRoomWithLastMessage'; -import './helpers/getLoggedInUser'; import './helpers/getPaginationItems'; import './helpers/getUserFromParams'; import './helpers/getUserInfo'; diff --git a/apps/meteor/app/api/server/middlewares/authenticationHono.ts b/apps/meteor/app/api/server/middlewares/authenticationHono.ts new file mode 100644 index 0000000000000..affb11f28ff34 --- /dev/null +++ b/apps/meteor/app/api/server/middlewares/authenticationHono.ts @@ -0,0 +1,48 @@ +import { type IUser, type RequiredField } from '@rocket.chat/core-typings'; +import { type Logger } from '@rocket.chat/logger'; +import type { MiddlewareHandler } from 'hono'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../settings/server'; +import { applyBreakingChanges, type APIClass } from '../ApiClass'; +import { convertHonoContextToApiActionContext, type HonoContext } from '../router'; + +const isUserWithUsername = (user: IUser | null): user is RequiredField => { + return user !== null && typeof user === 'object' && 'username' in user && user.username !== undefined; +}; + +export function authenticationMiddlewareForHono( + api: APIClass>, + options: { + authRequired?: boolean; + authOrAnonRequired?: boolean; + userWithoutUsername?: boolean; + logger: Logger; + }, +): MiddlewareHandler { + return async (c: HonoContext, next) => { + const user = await api.authenticatedRoute(convertHonoContextToApiActionContext(c, { logger: options.logger })); + const shouldPreventAnonymousRead = !user && options.authOrAnonRequired && !settings.get('Accounts_AllowAnonymousRead'); + const shouldPreventUserRead = !user && options.authRequired; + + if (shouldPreventAnonymousRead || shouldPreventUserRead) { + const result = api.unauthorized('You must be logged in to do this.'); + // TODO: MAJOR + if (!applyBreakingChanges) { + Object.assign(result.body, { + status: 'error', + message: 'You must be logged in to do this.', + }); + } + + return c.json(result.body, result.statusCode); + } + + if (user && !options.userWithoutUsername && !isUserWithUsername(user)) { + throw new Meteor.Error('error-unauthorized', 'Users must have a username'); + } + + c.set('user', user); + return next(); + }; +} diff --git a/apps/meteor/app/api/server/middlewares/permissions.ts b/apps/meteor/app/api/server/middlewares/permissions.ts new file mode 100644 index 0000000000000..b168eadfed684 --- /dev/null +++ b/apps/meteor/app/api/server/middlewares/permissions.ts @@ -0,0 +1,56 @@ +import { Logger } from '@rocket.chat/logger'; +import type { Method } from '@rocket.chat/rest-typings'; +import type { MiddlewareHandler } from 'hono'; + +import { applyBreakingChanges } from '../ApiClass'; +import { API } from '../api'; +import { type PermissionsPayload, checkPermissionsForInvocation } from '../api.helpers'; +import type { TypedOptions } from '../definition'; +import type { HonoContext } from '../router'; + +const logger = new Logger('PermissionsMiddleware'); + +export const permissionsMiddleware = + (options: TypedOptions): MiddlewareHandler => + async (c: HonoContext, next) => { + if (!options.permissionsRequired) { + return next(); + } + + const user = c.get('user'); + + if (!user) { + if (applyBreakingChanges) { + const unauthorized = API.v1.unauthorized('You must be logged in to do this'); + return c.json(unauthorized.body, unauthorized.statusCode); + } + + const failure = API.v1.forbidden('User does not have the permissions required for this action [error-unauthorized]'); + return c.json(failure.body, failure.statusCode); + } + + let hasPermission: boolean; + try { + hasPermission = await checkPermissionsForInvocation( + user._id, + options.permissionsRequired as PermissionsPayload, + c.req.method as Method, + ); + } catch (e) { + logger.error({ msg: 'Error checking permissions for invocation', err: e }); + const error = API.v1.internalError(); + return c.json(error.body, error.statusCode); + } + + if (!hasPermission) { + if (applyBreakingChanges) { + const forbidden = API.v1.forbidden('User does not have the permissions required for this action [error-unauthorized]'); + return c.json(forbidden.body, forbidden.statusCode); + } + + const failure = API.v1.forbidden('User does not have the permissions required for this action [error-unauthorized]'); + return c.json(failure.body, failure.statusCode); + } + + return next(); + }; diff --git a/apps/meteor/app/api/server/router.ts b/apps/meteor/app/api/server/router.ts index 41ca09d4f1a32..cf957a0344f30 100644 --- a/apps/meteor/app/api/server/router.ts +++ b/apps/meteor/app/api/server/router.ts @@ -1,18 +1,20 @@ import type { IncomingMessage } from 'node:http'; +import type { IUser } from '@rocket.chat/core-typings'; import type { ResponseSchema } from '@rocket.chat/http-router'; import { Router } from '@rocket.chat/http-router'; +import { type Logger } from '@rocket.chat/logger'; import type { Context } from 'hono'; import type { TypedOptions } from './definition'; -type HonoContext = Context<{ +export type HonoContext = Context<{ Bindings: { incoming: IncomingMessage }; Variables: { - 'remoteAddress': string; - 'bodyParams': Record; - 'bodyParams-override': Record | undefined; - 'queryParams': Record; + remoteAddress: string; + bodyParams: Record; + queryParams: Record; + user?: IUser | null; }; }>; @@ -26,6 +28,7 @@ export type APIActionContext = { response: any; route: string; incoming: IncomingMessage; + logger: Logger; }; export type APIActionHandler = (this: APIActionContext, request: Request) => Promise>; @@ -36,28 +39,43 @@ export class RocketChatAPIRouter< [x: string]: unknown; } = NonNullable, > extends Router { - protected override convertActionToHandler(action: APIActionHandler): (c: HonoContext) => Promise> { + protected override convertActionToHandler( + action: APIActionHandler, + logger: Logger, + ): (c: HonoContext) => Promise> { return async (c: HonoContext): Promise> => { - const { req, res } = c; + const { req } = c; const request = req.raw.clone(); - const context: APIActionContext = { - requestIp: c.get('remoteAddress'), - urlParams: req.param(), - queryParams: c.get('queryParams'), - bodyParams: c.get('bodyParams-override') || c.get('bodyParams'), - request, - path: req.path, - response: res, - route: req.routePath, - incoming: c.env.incoming, - }; + const context = convertHonoContextToApiActionContext(c, { logger }); return action.apply(context, [request]); }; } } +export const convertHonoContextToApiActionContext = ( + c: HonoContext, + options: { + logger: Logger; + }, +): APIActionContext => { + const user = c.get('user'); + return { + requestIp: c.get('remoteAddress'), + urlParams: c.req.param(), + queryParams: c.get('queryParams'), + bodyParams: c.get('bodyParams'), + request: c.req.raw, + path: c.req.path, + response: c.res, + route: c.req.routePath, + incoming: c.env.incoming, + logger: options.logger, + ...(user && { user, userId: user._id }), + }; +}; + export type ExtractRouterEndpoints> = TRoute extends RocketChatAPIRouter ? TOperations : never; diff --git a/apps/meteor/app/api/server/v1/assets.ts b/apps/meteor/app/api/server/v1/assets.ts index 2843cf8627d51..fda2522406021 100644 --- a/apps/meteor/app/api/server/v1/assets.ts +++ b/apps/meteor/app/api/server/v1/assets.ts @@ -1,5 +1,11 @@ import { Settings } from '@rocket.chat/models'; -import { isAssetsUnsetAssetProps } from '@rocket.chat/rest-typings'; +import { + ajv, + isAssetsUnsetAssetProps, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateBadRequestErrorResponse, +} from '@rocket.chat/rest-typings'; import { updateAuditedByUser } from '../../../../server/settings/lib/auditedSettingUpdates'; import { RocketChatAssets, refreshClients } from '../../../assets/server'; @@ -8,87 +14,102 @@ import { settings } from '../../../settings/server'; import { API } from '../api'; import { getUploadFormData } from '../lib/getUploadFormData'; -API.v1.addRoute( +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.post( 'assets.setAsset', { authRequired: true, permissionsRequired: ['manage-assets'], + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const asset = await getUploadFormData( - { - request: this.request, - }, - { field: 'asset', sizeLimit: settings.get('FileUpload_MaxFileSize') }, - ); + async function action() { + const asset = await getUploadFormData( + { + request: this.request, + }, + { field: 'asset', sizeLimit: settings.get('FileUpload_MaxFileSize') }, + ); - const { fileBuffer, fields, filename, mimetype } = asset; + const { fileBuffer, fields, filename, mimetype } = asset; - const { refreshAllClients, assetName: customName } = fields; + const { refreshAllClients, assetName: customName } = fields; - const assetName = customName || filename; - const assetsKeys = Object.keys(RocketChatAssets.assets); + const assetName = customName || filename; + const assetsKeys = Object.keys(RocketChatAssets.assets); - const isValidAsset = assetsKeys.includes(assetName); - if (!isValidAsset) { - throw new Error('Invalid asset'); - } + const isValidAsset = assetsKeys.includes(assetName); + if (!isValidAsset) { + throw new Error('Invalid asset'); + } - const { key, value } = await RocketChatAssets.setAssetWithBuffer(fileBuffer, mimetype, assetName); + const { key, value } = await RocketChatAssets.setAssetWithBuffer(fileBuffer, mimetype, assetName); - const { modifiedCount } = await updateAuditedByUser({ - _id: this.userId, - username: this.user.username!, - ip: this.requestIp, - useragent: this.request.headers.get('user-agent') || '', - })(Settings.updateValueById, key, value); + const { modifiedCount } = await updateAuditedByUser({ + _id: this.userId, + username: this.user.username ?? '', + ip: this.requestIp ?? '', + useragent: this.request.headers.get('user-agent') ?? '', + })(Settings.updateValueById, key, value); - if (modifiedCount) { - void notifyOnSettingChangedById(key); - } + if (modifiedCount) { + void notifyOnSettingChangedById(key); + } - if (refreshAllClients) { - await refreshClients(this.userId); - } + if (refreshAllClients) { + await refreshClients(this.userId); + } - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'assets.unsetAsset', { authRequired: true, - validateParams: isAssetsUnsetAssetProps, + body: isAssetsUnsetAssetProps, permissionsRequired: ['manage-assets'], - }, - { - async post() { - const { assetName, refreshAllClients } = this.bodyParams; - const isValidAsset = Object.keys(RocketChatAssets.assets).includes(assetName); - if (!isValidAsset) { - throw Error('Invalid asset'); - } - - const { key, value } = await RocketChatAssets.unsetAsset(assetName); - - const { modifiedCount } = await updateAuditedByUser({ - _id: this.userId, - username: this.user.username!, - ip: this.requestIp, - useragent: this.request.headers.get('user-agent') || '', - })(Settings.updateValueById, key, value); - - if (modifiedCount) { - void notifyOnSettingChangedById(key); - } - - if (refreshAllClients) { - await refreshClients(this.userId); - } - return API.v1.success(); + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const { assetName, refreshAllClients } = this.bodyParams; + const isValidAsset = Object.keys(RocketChatAssets.assets).includes(assetName); + if (!isValidAsset) { + throw Error('Invalid asset'); + } + + const { key, value } = await RocketChatAssets.unsetAsset(assetName); + + const { modifiedCount } = await updateAuditedByUser({ + _id: this.userId, + username: this.user.username ?? '', + ip: this.requestIp ?? '', + useragent: this.request.headers.get('user-agent') ?? '', + })(Settings.updateValueById, key, value); + + if (modifiedCount) { + void notifyOnSettingChangedById(key); + } + + if (refreshAllClients) { + await refreshClients(this.userId); + } + return API.v1.success(); + }, ); diff --git a/apps/meteor/app/api/server/v1/autotranslate.ts b/apps/meteor/app/api/server/v1/autotranslate.ts index a5c167f7d0a89..74d857f5b63fa 100644 --- a/apps/meteor/app/api/server/v1/autotranslate.ts +++ b/apps/meteor/app/api/server/v1/autotranslate.ts @@ -1,7 +1,10 @@ +import type { IMessage, ISupportedLanguage } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; import { + ajv, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, isAutotranslateSaveSettingsParamsPOST, - isAutotranslateTranslateMessageParamsPOST, isAutotranslateGetSupportedLanguagesParamsGET, } from '@rocket.chat/rest-typings'; @@ -9,16 +12,54 @@ import { getSupportedLanguages } from '../../../autotranslate/server/functions/g import { saveAutoTranslateSettings } from '../../../autotranslate/server/functions/saveSettings'; import { translateMessage } from '../../../autotranslate/server/functions/translateMessage'; import { settings } from '../../../settings/server'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; -API.v1.addRoute( - 'autotranslate.getSupportedLanguages', - { - authRequired: true, - validateParams: isAutotranslateGetSupportedLanguagesParamsGET, +type AutotranslateTranslateMessageParamsPOST = { + messageId: string; + targetLanguage?: string; +}; + +const AutotranslateTranslateMessageParamsPostSchema = { + type: 'object', + properties: { + messageId: { + type: 'string', + }, + targetLanguage: { + type: 'string', + nullable: true, + }, }, - { - async get() { + required: ['messageId'], + additionalProperties: false, +}; + +const isAutotranslateTranslateMessageParamsPOST = ajv.compile( + AutotranslateTranslateMessageParamsPostSchema, +); + +const autotranslateEndpoints = API.v1 + .get( + 'autotranslate.getSupportedLanguages', + { + authRequired: true, + query: isAutotranslateGetSupportedLanguagesParamsGET, + response: { + 200: ajv.compile<{ languages: ISupportedLanguage[] }>({ + type: 'object', + properties: { + languages: { type: 'array', items: { type: 'object' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['languages', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { if (!settings.get('AutoTranslate_Enabled')) { return API.v1.failure('AutoTranslate is disabled.'); } @@ -27,17 +68,24 @@ API.v1.addRoute( return API.v1.success({ languages: languages || [] }); }, - }, -); - -API.v1.addRoute( - 'autotranslate.saveSettings', - { - authRequired: true, - validateParams: isAutotranslateSaveSettingsParamsPOST, - }, - { - async post() { + ) + .post( + 'autotranslate.saveSettings', + { + authRequired: true, + body: isAutotranslateSaveSettingsParamsPOST, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { roomId, field, value, defaultLanguage } = this.bodyParams; if (!settings.get('AutoTranslate_Enabled')) { return API.v1.failure('AutoTranslate is disabled.'); @@ -66,17 +114,27 @@ API.v1.addRoute( return API.v1.success(); }, - }, -); - -API.v1.addRoute( - 'autotranslate.translateMessage', - { - authRequired: true, - validateParams: isAutotranslateTranslateMessageParamsPOST, - }, - { - async post() { + ) + .post( + 'autotranslate.translateMessage', + { + authRequired: true, + body: isAutotranslateTranslateMessageParamsPOST, + 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() { const { messageId, targetLanguage } = this.bodyParams; if (!settings.get('AutoTranslate_Enabled')) { return API.v1.failure('AutoTranslate is disabled.'); @@ -91,7 +149,17 @@ API.v1.addRoute( const translatedMessage = await translateMessage(targetLanguage, message); + if (!translatedMessage) { + return API.v1.failure('Failed to translate message.'); + } + return API.v1.success({ message: translatedMessage }); }, - }, -); + ); + +type AutotranslateEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends AutotranslateEndpoints {} +} diff --git a/apps/meteor/app/api/server/v1/banners.ts b/apps/meteor/app/api/server/v1/banners.ts index ff344362df6c5..46b32c853c131 100644 --- a/apps/meteor/app/api/server/v1/banners.ts +++ b/apps/meteor/app/api/server/v1/banners.ts @@ -1,8 +1,32 @@ import { Banner } from '@rocket.chat/core-services'; -import { isBannersDismissProps, isBannersProps } from '@rocket.chat/rest-typings'; +import type { IBanner } from '@rocket.chat/core-typings'; +import { + ajv, + isBannersDismissProps, + isBannersProps, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; import { API } from '../api'; +const bannersResponseSchema = ajv.compile<{ banners: IBanner[] }>({ + type: 'object', + properties: { + banners: { type: 'array', items: { $ref: '#/components/schemas/IBanner' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['banners', 'success'], + additionalProperties: false, +}); + +const dismissResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + /** * @openapi * /api/v1/banners/{id}: @@ -49,19 +73,24 @@ import { API } from '../api'; * schema: * $ref: '#/components/schemas/ApiFailureV1' */ -API.v1.addRoute( +API.v1.get( 'banners/:id', - { authRequired: true, validateParams: isBannersProps }, { - // TODO: move to users/:id/banners - async get() { - const { platform } = this.queryParams; - const { id } = this.urlParams; + authRequired: true, + query: isBannersProps, + response: { + 200: bannersResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { platform } = this.queryParams; + const { id } = this.urlParams; - const banners = await Banner.getBannersForUser(this.userId, platform, id); + const banners = await Banner.getBannersForUser(this.userId, platform, id); - return API.v1.success({ banners }); - }, + return API.v1.success({ banners }); }, ); @@ -102,17 +131,23 @@ API.v1.addRoute( * schema: * $ref: '#/components/schemas/ApiFailureV1' */ -API.v1.addRoute( +API.v1.get( 'banners', - { authRequired: true, validateParams: isBannersProps }, { - async get() { - const { platform } = this.queryParams; + authRequired: true, + query: isBannersProps, + response: { + 200: bannersResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { platform } = this.queryParams; - const banners = await Banner.getBannersForUser(this.userId, platform); + const banners = await Banner.getBannersForUser(this.userId, platform); - return API.v1.success({ banners }); - }, + return API.v1.success({ banners }); }, ); @@ -149,15 +184,21 @@ API.v1.addRoute( * schema: * $ref: '#/components/schemas/ApiFailureV1' */ -API.v1.addRoute( +API.v1.post( 'banners.dismiss', - { authRequired: true, validateParams: isBannersDismissProps }, { - async post() { - const { bannerId } = this.bodyParams; - - await Banner.dismiss(this.userId, bannerId); - return API.v1.success(); + authRequired: true, + body: isBannersDismissProps, + response: { + 200: dismissResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { bannerId } = this.bodyParams; + + await Banner.dismiss(this.userId, bannerId); + return API.v1.success(); + }, ); diff --git a/apps/meteor/app/api/server/v1/calendar.ts b/apps/meteor/app/api/server/v1/calendar.ts index 5eff639a80f5e..8209bba4490c2 100644 --- a/apps/meteor/app/api/server/v1/calendar.ts +++ b/apps/meteor/app/api/server/v1/calendar.ts @@ -1,145 +1,224 @@ import { Calendar } from '@rocket.chat/core-services'; +import type { ICalendarEvent } from '@rocket.chat/core-typings'; import { + ajv, isCalendarEventListProps, isCalendarEventCreateProps, isCalendarEventImportProps, isCalendarEventInfoProps, isCalendarEventUpdateProps, isCalendarEventDeleteProps, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { API } from '../api'; -API.v1.addRoute( +const successWithDataSchema = ajv.compile<{ data: ICalendarEvent[] }>({ + type: 'object', + properties: { + data: {}, + success: { type: 'boolean', enum: [true] }, + }, + required: ['data', 'success'], + additionalProperties: false, +}); + +const successWithEventSchema = ajv.compile<{ event: ICalendarEvent }>({ + type: 'object', + properties: { + event: { $ref: '#/components/schemas/ICalendarEvent' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['event', 'success'], + additionalProperties: false, +}); + +const successWithIdSchema = ajv.compile<{ id: ICalendarEvent['_id'] }>({ + type: 'object', + properties: { + id: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['id', 'success'], + additionalProperties: false, +}); + +const successSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.get( 'calendar-events.list', - { authRequired: true, validateParams: isCalendarEventListProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 } }, { - async get() { - const { userId } = this; - const { date } = this.queryParams; + authRequired: true, + query: isCalendarEventListProps, + rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 }, + response: { + 200: successWithDataSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { userId } = this; + const { date } = this.queryParams; - const data = await Calendar.list(userId, new Date(date)); + const data = await Calendar.list(userId, new Date(date)); - return API.v1.success({ data }); - }, + return API.v1.success({ data }); }, ); -API.v1.addRoute( +API.v1.get( 'calendar-events.info', - { authRequired: true, validateParams: isCalendarEventInfoProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 } }, { - async get() { - const { userId } = this; - const { id } = this.queryParams; + authRequired: true, + query: isCalendarEventInfoProps, + rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 }, + response: { + 200: successWithEventSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { userId } = this; + const { id } = this.queryParams; - const event = await Calendar.get(id); + const event = await Calendar.get(id); - if (!event || event.uid !== userId) { - return API.v1.failure(); - } + if (event?.uid !== userId) { + return API.v1.failure(); + } - return API.v1.success({ event }); - }, + return API.v1.success({ event }); }, ); -API.v1.addRoute( +API.v1.post( 'calendar-events.create', - { authRequired: true, validateParams: isCalendarEventCreateProps }, { - async post() { - const { userId: uid } = this; - const { startTime, endTime, externalId, subject, description, meetingUrl, reminderMinutesBeforeStart, busy } = this.bodyParams; - - const id = await Calendar.create({ - uid, - startTime: new Date(startTime), - ...(endTime && { endTime: new Date(endTime) }), - externalId, - subject, - description, - meetingUrl, - reminderMinutesBeforeStart, - ...(typeof busy === 'boolean' && { busy }), - }); - - return API.v1.success({ id }); + authRequired: true, + body: isCalendarEventCreateProps, + response: { + 200: successWithIdSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { userId: uid } = this; + const { startTime, endTime, externalId, subject, description, meetingUrl, reminderMinutesBeforeStart, busy } = this.bodyParams; + + const id = await Calendar.create({ + uid, + startTime: new Date(startTime), + ...(endTime && { endTime: new Date(endTime) }), + externalId, + subject, + description, + meetingUrl, + reminderMinutesBeforeStart, + ...(typeof busy === 'boolean' && { busy }), + }); + + return API.v1.success({ id }); + }, ); -API.v1.addRoute( +API.v1.post( 'calendar-events.import', - { authRequired: true, validateParams: isCalendarEventImportProps }, { - async post() { - const { userId: uid } = this; - const { startTime, endTime, externalId, subject, description, meetingUrl, reminderMinutesBeforeStart, busy } = this.bodyParams; - - const id = await Calendar.import({ - uid, - startTime: new Date(startTime), - ...(endTime && { endTime: new Date(endTime) }), - externalId, - subject, - description, - meetingUrl, - reminderMinutesBeforeStart, - ...(typeof busy === 'boolean' && { busy }), - }); - - return API.v1.success({ id }); + authRequired: true, + body: isCalendarEventImportProps, + response: { + 200: successWithIdSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { userId: uid } = this; + const { startTime, endTime, externalId, subject, description, meetingUrl, reminderMinutesBeforeStart, busy } = this.bodyParams; + + const id = await Calendar.import({ + uid, + startTime: new Date(startTime), + ...(endTime && { endTime: new Date(endTime) }), + externalId, + subject, + description, + meetingUrl, + reminderMinutesBeforeStart, + ...(typeof busy === 'boolean' && { busy }), + }); + + return API.v1.success({ id }); + }, ); -API.v1.addRoute( +API.v1.post( 'calendar-events.update', - { authRequired: true, validateParams: isCalendarEventUpdateProps }, { - async post() { - const { userId } = this; - const { eventId, startTime, endTime, subject, description, meetingUrl, reminderMinutesBeforeStart, busy } = this.bodyParams; - - const event = await Calendar.get(eventId); - - if (!event || event.uid !== userId) { - throw new Error('invalid-calendar-event'); - } - - await Calendar.update(eventId, { - startTime: new Date(startTime), - ...(endTime && { endTime: new Date(endTime) }), - subject, - description, - meetingUrl, - reminderMinutesBeforeStart, - ...(typeof busy === 'boolean' && { busy }), - }); - - return API.v1.success(); + authRequired: true, + body: isCalendarEventUpdateProps, + response: { + 200: successSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { userId } = this; + const { eventId, startTime, endTime, subject, description, meetingUrl, reminderMinutesBeforeStart, busy } = this.bodyParams; + + const event = await Calendar.get(eventId); + + if (event?.uid !== userId) { + throw new Error('invalid-calendar-event'); + } + + await Calendar.update(eventId, { + startTime: new Date(startTime), + ...(endTime && { endTime: new Date(endTime) }), + subject, + description, + meetingUrl, + reminderMinutesBeforeStart, + ...(typeof busy === 'boolean' && { busy }), + }); + + return API.v1.success(); + }, ); -API.v1.addRoute( +API.v1.post( 'calendar-events.delete', - { authRequired: true, validateParams: isCalendarEventDeleteProps }, { - async post() { - const { userId } = this; - const { eventId } = this.bodyParams; + authRequired: true, + body: isCalendarEventDeleteProps, + response: { + 200: successSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { userId } = this; + const { eventId } = this.bodyParams; - const event = await Calendar.get(eventId); + const event = await Calendar.get(eventId); - if (!event || event.uid !== userId) { - throw new Error('invalid-calendar-event'); - } + if (event?.uid !== userId) { + throw new Error('invalid-calendar-event'); + } - await Calendar.delete(eventId); + await Calendar.delete(eventId); - return API.v1.success(); - }, + return API.v1.success(); }, ); diff --git a/apps/meteor/app/api/server/v1/call-history.ts b/apps/meteor/app/api/server/v1/call-history.ts index 606632571ac91..33412c6ae58c7 100644 --- a/apps/meteor/app/api/server/v1/call-history.ts +++ b/apps/meteor/app/api/server/v1/call-history.ts @@ -3,6 +3,7 @@ import { CallHistory, MediaCalls } from '@rocket.chat/models'; import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings'; import { ajv, + ajvQuery, validateNotFoundErrorResponse, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, @@ -61,7 +62,7 @@ const CallHistoryListSchema = { additionalProperties: false, }; -export const isCallHistoryListProps = ajv.compile(CallHistoryListSchema); +export const isCallHistoryListProps = ajvQuery.compile(CallHistoryListSchema); const callHistoryListEndpoints = API.v1.get( 'call-history.list', @@ -185,7 +186,7 @@ const CallHistoryInfoSchema = { ], }; -export const isCallHistoryInfoProps = ajv.compile(CallHistoryInfoSchema); +export const isCallHistoryInfoProps = ajvQuery.compile(CallHistoryInfoSchema); const callHistoryInfoEndpoints = API.v1.get( 'call-history.info', diff --git a/apps/meteor/app/api/server/v1/channels.ts b/apps/meteor/app/api/server/v1/channels.ts index 0aa24f5097b04..eaeab9a9bb108 100644 --- a/apps/meteor/app/api/server/v1/channels.ts +++ b/apps/meteor/app/api/server/v1/channels.ts @@ -54,7 +54,6 @@ import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMes import { API } from '../api'; import { addUserToFileObj } from '../helpers/addUserToFileObj'; import { composeRoomWithLastMessage } from '../helpers/composeRoomWithLastMessage'; -import { getLoggedInUser } from '../helpers/getLoggedInUser'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { getUserFromParams, getUserListFromParams } from '../helpers/getUserFromParams'; @@ -206,7 +205,7 @@ API.v1.addRoute( async get() { const findResult = await findChannelByIdOrName({ params: this.queryParams }); - const roles = await executeGetRoomRoles(findResult._id, this.userId); + const roles = await executeGetRoomRoles(findResult._id, this.user); return API.v1.success({ roles, @@ -310,7 +309,7 @@ API.v1.addRoute( rid: findResult._id, ...parseIds(mentionIds, 'mentions._id'), ...parseIds(starredIds, 'starred._id'), - ...(pinned && pinned.toLowerCase() === 'true' ? { pinned: true } : {}), + ...(pinned?.toLowerCase() === 'true' ? { pinned: true } : {}), _hidden: { $ne: true }, }; @@ -784,7 +783,6 @@ API.v1.addRoute( const teamMembers = []; for (const team of teams) { - // eslint-disable-next-line no-await-in-loop const { records: members } = await Team.members(this.userId, team._id, canSeeAllTeams, { offset: 0, count: Number.MAX_SAFE_INTEGER, @@ -1147,9 +1145,7 @@ API.v1.addRoute( return API.v1.failure('Channel does not exists'); } - const user = await getLoggedInUser(this.request); - - if (!room || !user || !(await canAccessRoomAsync(room, user))) { + if (!(await canAccessRoomAsync(room, this.user))) { throw new Meteor.Error('error-not-allowed', 'Not Allowed'); } diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index f5a9250fe29b6..a00d57e46ae72 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -14,12 +14,8 @@ import { isChatPostMessageProps, isChatSearchProps, isChatSendMessageProps, - isChatStarMessageProps, - isChatUnstarMessageProps, isChatIgnoreUserProps, isChatGetPinnedMessagesProps, - isChatFollowMessageProps, - isChatUnfollowMessageProps, isChatGetMentionedMessagesProps, isChatReactProps, isChatGetDeletedMessagesProps, @@ -59,6 +55,78 @@ import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { findDiscussionsFromRoom, findMentionedMessages, findStarredMessages } from '../lib/messages'; +type ChatStarMessageLocal = { + messageId: IMessage['_id']; +}; + +type ChatUnstarMessageLocal = { + messageId: IMessage['_id']; +}; + +const ChatStarMessageLocalSchema = { + type: 'object', + properties: { + messageId: { + type: 'string', + minLength: 1, + }, + }, + required: ['messageId'], + additionalProperties: false, +}; + +const ChatUnstarMessageLocalSchema = { + type: 'object', + properties: { + messageId: { + type: 'string', + minLength: 1, + }, + }, + required: ['messageId'], + additionalProperties: false, +}; + +type ChatFollowMessageLocal = { + mid: string; +}; + +const ChatFollowMessageLocalSchema = { + type: 'object', + properties: { + mid: { + type: 'string', + minLength: 1, + }, + }, + required: ['mid'], + additionalProperties: false, +}; + +type ChatUnfollowMessageLocal = { + mid: string; +}; + +const ChatUnfollowMessageLocalSchema = { + type: 'object', + properties: { + mid: { + type: 'string', + minLength: 1, + }, + }, + required: ['mid'], + additionalProperties: false, +}; + +const isChatStarMessageLocalProps = ajv.compile(ChatStarMessageLocalSchema); + +const isChatUnstarMessageLocalProps = ajv.compile(ChatUnstarMessageLocalSchema); + +const isChatFollowMessageLocalProps = ajv.compile(ChatFollowMessageLocalSchema); + +const isChatUnfollowMessageLocalProps = ajv.compile(ChatUnfollowMessageLocalSchema); + API.v1.addRoute( 'chat.delete', { authRequired: true, validateParams: isChatDeleteProps }, @@ -350,6 +418,146 @@ const chatEndpoints = API.v1 message, }); }, + ) + .post( + 'chat.starMessage', + { + authRequired: true, + body: isChatStarMessageLocalProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const msg = await Messages.findOneById(this.bodyParams.messageId); + + if (!msg) { + throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); + } + + await starMessage(this.user, { + _id: msg._id, + rid: msg.rid, + starred: true, + }); + + return API.v1.success(); + }, + ) + .post( + 'chat.unStarMessage', + { + authRequired: true, + body: isChatUnstarMessageLocalProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const msg = await Messages.findOneById(this.bodyParams.messageId); + + if (!msg) { + throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); + } + + await starMessage(this.user, { + _id: msg._id, + rid: msg.rid, + starred: false, + }); + + return API.v1.success(); + }, + ) + .post( + 'chat.followMessage', + { + authRequired: true, + body: isChatFollowMessageLocalProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const { mid } = this.bodyParams; + + if (!mid) { + throw new Meteor.Error('The required "mid" body param is missing.'); + } + + await followMessage(this.user, { mid }); + + return API.v1.success(); + }, + ) + .post( + 'chat.unfollowMessage', + { + authRequired: true, + body: isChatUnfollowMessageLocalProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const { mid } = this.bodyParams; + + if (!mid) { + throw new Meteor.Error('The required "mid" body param is missing.'); + } + + await unfollowMessage(this.user, { mid }); + + return API.v1.success(); + }, ); API.v1.addRoute( @@ -434,7 +642,7 @@ API.v1.addRoute( } const sent = await applyAirGappedRestrictionsValidation(() => - executeSendMessage(this.userId, this.bodyParams.message as Pick, { previewUrls: this.bodyParams.previewUrls }), + executeSendMessage(this.user, this.bodyParams.message as Pick, { previewUrls: this.bodyParams.previewUrls }), ); const [message] = await normalizeMessagesForUser([sent], this.userId); @@ -445,50 +653,6 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'chat.starMessage', - { authRequired: true, validateParams: isChatStarMessageProps }, - { - async post() { - const msg = await Messages.findOneById(this.bodyParams.messageId); - - if (!msg) { - throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); - } - - await starMessage(this.user, { - _id: msg._id, - rid: msg.rid, - starred: true, - }); - - return API.v1.success(); - }, - }, -); - -API.v1.addRoute( - 'chat.unStarMessage', - { authRequired: true, validateParams: isChatUnstarMessageProps }, - { - async post() { - const msg = await Messages.findOneById(this.bodyParams.messageId); - - if (!msg) { - throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); - } - - await starMessage(this.user, { - _id: msg._id, - rid: msg.rid, - starred: false, - }); - - return API.v1.success(); - }, - }, -); - API.v1.addRoute( 'chat.react', { authRequired: true, validateParams: isChatReactProps }, @@ -793,42 +957,6 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'chat.followMessage', - { authRequired: true, validateParams: isChatFollowMessageProps }, - { - async post() { - const { mid } = this.bodyParams; - - if (!mid) { - throw new Meteor.Error('The required "mid" body param is missing.'); - } - - await followMessage(this.user, { mid }); - - return API.v1.success(); - }, - }, -); - -API.v1.addRoute( - 'chat.unfollowMessage', - { authRequired: true, validateParams: isChatUnfollowMessageProps }, - { - async post() { - const { mid } = this.bodyParams; - - if (!mid) { - throw new Meteor.Error('The required "mid" body param is missing.'); - } - - await unfollowMessage(this.user, { mid }); - - return API.v1.success(); - }, - }, -); - API.v1.addRoute( 'chat.getMentionedMessages', { authRequired: true, validateParams: isChatGetMentionedMessagesProps }, diff --git a/apps/meteor/app/api/server/v1/cloud.ts b/apps/meteor/app/api/server/v1/cloud.ts index acf62e7f1bb62..1338192c00ec5 100644 --- a/apps/meteor/app/api/server/v1/cloud.ts +++ b/apps/meteor/app/api/server/v1/cloud.ts @@ -1,8 +1,16 @@ -import { isCloudConfirmationPollProps, isCloudCreateRegistrationIntentProps, isCloudManualRegisterProps } from '@rocket.chat/rest-typings'; +import type { CloudRegistrationIntentData, CloudConfirmationPollData, CloudRegistrationStatus } from '@rocket.chat/core-typings'; +import { + isCloudConfirmationPollProps, + isCloudCreateRegistrationIntentProps, + isCloudManualRegisterProps, + ajv, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateBadRequestErrorResponse, +} from '@rocket.chat/rest-typings'; import { CloudWorkspaceRegistrationError } from '../../../../lib/errors/CloudWorkspaceRegistrationError'; import { SystemLogger } from '../../../../server/lib/logger/system'; -import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { getCheckoutUrl } from '../../../cloud/server/functions/getCheckoutUrl'; import { getConfirmationPoll } from '../../../cloud/server/functions/getConfirmationPoll'; import { @@ -17,144 +25,253 @@ import { startRegisterWorkspaceSetupWizard } from '../../../cloud/server/functio import { syncWorkspace } from '../../../cloud/server/functions/syncWorkspace'; import { API } from '../api'; -API.v1.addRoute( +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +const manualRegisterResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +const createRegistrationIntentResponseSchema = ajv.compile<{ intentData: CloudRegistrationIntentData }>({ + type: 'object', + properties: { + intentData: { $ref: '#/components/schemas/CloudRegistrationIntentData' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['intentData', 'success'], + additionalProperties: false, +}); + +const registerPreIntentResponseSchema = ajv.compile<{ offline: boolean }>({ + type: 'object', + properties: { + offline: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['offline', 'success'], + additionalProperties: false, +}); + +const confirmationPollResponseSchema = ajv.compile<{ pollData: CloudConfirmationPollData }>({ + type: 'object', + properties: { + pollData: { + oneOf: [ + { $ref: '#/components/schemas/CloudConfirmationPollDataPending' }, + { $ref: '#/components/schemas/CloudConfirmationPollDataSuccess' }, + ], + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['pollData', 'success'], + additionalProperties: false, +}); + +const registrationStatusResponseSchema = ajv.compile<{ registrationStatus: CloudRegistrationStatus }>({ + type: 'object', + properties: { + registrationStatus: { $ref: '#/components/schemas/CloudRegistrationStatus' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['registrationStatus', 'success'], + additionalProperties: false, +}); + +const checkoutUrlResponseSchema = ajv.compile<{ url: string }>({ + type: 'object', + properties: { + url: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['url', 'success'], + additionalProperties: false, +}); + +API.v1.post( 'cloud.manualRegister', - { authRequired: true, permissionsRequired: ['register-on-cloud'], validateParams: isCloudManualRegisterProps }, { - async post() { - const registrationInfo = await retrieveRegistrationStatus(); + authRequired: true, + permissionsRequired: ['register-on-cloud'], + body: isCloudManualRegisterProps, + response: { + 200: manualRegisterResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const registrationInfo = await retrieveRegistrationStatus(); - if (registrationInfo.workspaceRegistered) { - return API.v1.failure('Workspace is already registered'); - } + if (registrationInfo.workspaceRegistered) { + return API.v1.failure('Workspace is already registered'); + } - const settingsData = JSON.parse(Buffer.from(this.bodyParams.cloudBlob, 'base64').toString()); + const settingsData = JSON.parse(Buffer.from(this.bodyParams.cloudBlob, 'base64').toString()); - await saveRegistrationDataManual(settingsData); + await saveRegistrationDataManual(settingsData); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'cloud.createRegistrationIntent', - { authRequired: true, permissionsRequired: ['manage-cloud'], validateParams: isCloudCreateRegistrationIntentProps }, { - async post() { - const intentData = await startRegisterWorkspaceSetupWizard(this.bodyParams.resend, this.bodyParams.email); + authRequired: true, + permissionsRequired: ['manage-cloud'], + body: isCloudCreateRegistrationIntentProps, + response: { + 200: createRegistrationIntentResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const intentData = await startRegisterWorkspaceSetupWizard(this.bodyParams.resend, this.bodyParams.email); - if (intentData) { - return API.v1.success({ intentData }); - } + if (intentData) { + return API.v1.success({ intentData }); + } - return API.v1.failure('Invalid query'); - }, + return API.v1.failure('Invalid query'); }, ); -API.v1.addRoute( +API.v1.post( 'cloud.registerPreIntent', - { authRequired: true, permissionsRequired: ['manage-cloud'] }, { - async post() { - return API.v1.success({ offline: !(await registerPreIntentWorkspaceWizard()) }); + authRequired: true, + permissionsRequired: ['manage-cloud'], + response: { + 200: registerPreIntentResponseSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + return API.v1.success({ offline: !(await registerPreIntentWorkspaceWizard()) }); + }, ); -API.v1.addRoute( +API.v1.get( 'cloud.confirmationPoll', - { authRequired: true, permissionsRequired: ['manage-cloud'], validateParams: isCloudConfirmationPollProps }, { - async get() { - const { deviceCode } = this.queryParams; + authRequired: true, + permissionsRequired: ['manage-cloud'], + query: isCloudConfirmationPollProps, + response: { + 200: confirmationPollResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { deviceCode } = this.queryParams; - const pollData = await getConfirmationPoll(deviceCode); - if (pollData) { - if ('successful' in pollData && pollData.successful) { - await saveRegistrationData(pollData.payload); - } - return API.v1.success({ pollData }); + const pollData = await getConfirmationPoll(deviceCode); + if (pollData) { + if ('successful' in pollData && pollData.successful) { + await saveRegistrationData(pollData.payload); } + return API.v1.success({ pollData }); + } - return API.v1.failure('Invalid query'); - }, + return API.v1.failure('Invalid query'); }, ); -API.v1.addRoute( +API.v1.get( 'cloud.registrationStatus', - { authRequired: true }, { - async get() { - if (!(await hasPermissionAsync(this.userId, 'manage-cloud'))) { - return API.v1.forbidden(); - } - - const registrationStatus = await retrieveRegistrationStatus(); - - return API.v1.success({ registrationStatus }); + authRequired: true, + permissionsRequired: ['manage-cloud'], + response: { + 200: registrationStatusResponseSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const registrationStatus = await retrieveRegistrationStatus(); + + return API.v1.success({ registrationStatus }); + }, ); -API.v1.addRoute( +API.v1.post( 'cloud.syncWorkspace', { authRequired: true, permissionsRequired: ['manage-cloud'], rateLimiterOptions: { numRequestsAllowed: 2, intervalTimeInMS: 60000 }, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - try { - await syncWorkspace(); + async function action() { + try { + await syncWorkspace(); - return API.v1.success({ success: true }); - } catch (error) { - return API.v1.failure('Error during workspace sync'); - } - }, + return API.v1.success(); + } catch (error) { + return API.v1.failure('Error during workspace sync'); + } }, ); -API.v1.addRoute( +API.v1.post( 'cloud.removeLicense', { authRequired: true, permissionsRequired: ['manage-cloud'], rateLimiterOptions: { numRequestsAllowed: 2, intervalTimeInMS: 60000 }, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - try { - await removeLicense(); - return API.v1.success({ success: true }); - } catch (error) { - switch (true) { - case error instanceof CloudWorkspaceRegistrationError: - case error instanceof CloudWorkspaceAccessTokenEmptyError: - case error instanceof CloudWorkspaceAccessTokenError: { - SystemLogger.info({ - msg: 'Manual license removal failed', - endpoint: 'cloud.removeLicense', - error, - }); - break; - } - default: { - SystemLogger.error({ - msg: 'Manual license removal failed', - endpoint: 'cloud.removeLicense', - error, - }); - break; - } + async function action() { + try { + await removeLicense(); + return API.v1.success(); + } catch (error) { + switch (true) { + case error instanceof CloudWorkspaceRegistrationError: + case error instanceof CloudWorkspaceAccessTokenEmptyError: + case error instanceof CloudWorkspaceAccessTokenError: { + SystemLogger.info({ + msg: 'Manual license removal failed', + endpoint: 'cloud.removeLicense', + error, + }); + break; + } + default: { + SystemLogger.error({ + msg: 'Manual license removal failed', + endpoint: 'cloud.removeLicense', + error, + }); + break; } } return API.v1.failure('License removal failed'); - }, + } }, ); @@ -170,18 +287,25 @@ declare module '@rocket.chat/rest-typings' { } } -API.v1.addRoute( +API.v1.get( 'cloud.checkoutUrl', - { authRequired: true, permissionsRequired: ['manage-cloud'] }, { - async get() { - const checkoutUrl = await getCheckoutUrl(); + authRequired: true, + permissionsRequired: ['manage-cloud'], + response: { + 200: checkoutUrlResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const checkoutUrl = await getCheckoutUrl(); - if (!checkoutUrl.url) { - return API.v1.failure(); - } + if (!checkoutUrl.url) { + return API.v1.failure(); + } - return API.v1.success({ url: checkoutUrl.url }); - }, + return API.v1.success({ url: checkoutUrl.url }); }, ); diff --git a/apps/meteor/app/api/server/v1/commands.ts b/apps/meteor/app/api/server/v1/commands.ts index fea1d978cbafe..408222052b7d9 100644 --- a/apps/meteor/app/api/server/v1/commands.ts +++ b/apps/meteor/app/api/server/v1/commands.ts @@ -1,35 +1,86 @@ import { Apps } from '@rocket.chat/apps'; +import type { SlashCommand } 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 objectPath from 'object-path'; import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { executeSlashCommandPreview } from '../../../lib/server/methods/executeSlashCommandPreview'; import { getSlashCommandPreviews } from '../../../lib/server/methods/getSlashCommandPreviews'; import { slashCommands } from '../../../utils/server/slashCommand'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; -import { getLoggedInUser } from '../helpers/getLoggedInUser'; import { getPaginationItems } from '../helpers/getPaginationItems'; -API.v1.addRoute( +type CommandsGetParams = { command: string }; + +const CommandsGetParamsSchema = { + type: 'object', + properties: { + command: { type: 'string' }, + }, + required: ['command'], + additionalProperties: false, +}; + +const isCommandsGetParams = ajvQuery.compile(CommandsGetParamsSchema); + +const commandsEndpoints = API.v1.get( 'commands.get', - { authRequired: true }, { - get() { - const params = this.queryParams; + 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', 'success'], + additionalProperties: false, + }), + }, + }, - if (typeof params.command !== 'string') { - return API.v1.failure('The query param "command" must be provided.'); - } + 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: cmd }); - }, + return API.v1.success({ + command: { + command: cmd.command, + description: cmd.description, + params: cmd.params, + clientOnly: cmd.clientOnly, + providesPreview: cmd.providesPreview, + }, + }); }, ); @@ -248,7 +299,6 @@ API.v1.addRoute( // Expects these query params: command: 'giphy', params: 'mine', roomId: 'value' async get() { const query = this.queryParams; - const user = await getLoggedInUser(this.request); if (typeof query.command !== 'string') { return API.v1.failure('You must provide a command to get the previews from.'); @@ -267,7 +317,7 @@ API.v1.addRoute( return API.v1.failure('The command provided does not exist (or is disabled).'); } - if (!(await canAccessRoomIdAsync(query.roomId, user?._id))) { + if (!(await canAccessRoomIdAsync(query.roomId, this.userId))) { return API.v1.forbidden(); } @@ -352,3 +402,10 @@ API.v1.addRoute( }, }, ); + +export type CommandsEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends CommandsEndpoints {} +} diff --git a/apps/meteor/app/api/server/v1/custom-sounds.ts b/apps/meteor/app/api/server/v1/custom-sounds.ts index d41362641f75b..149a8a20a79e0 100644 --- a/apps/meteor/app/api/server/v1/custom-sounds.ts +++ b/apps/meteor/app/api/server/v1/custom-sounds.ts @@ -1,9 +1,12 @@ import type { ICustomSound } from '@rocket.chat/core-typings'; import { CustomSounds } from '@rocket.chat/models'; -import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings'; +import type { PaginatedResult } from '@rocket.chat/rest-typings'; import { + isCustomSoundsGetOneProps, + isCustomSoundsListProps, ajv, validateBadRequestErrorResponse, + validateNotFoundErrorResponse, validateForbiddenErrorResponse, validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; @@ -13,108 +16,115 @@ import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; -type CustomSoundsList = PaginatedRequest<{ name?: string }>; - -const CustomSoundsListSchema = { - type: 'object', - properties: { - count: { - type: 'number', - nullable: true, - }, - offset: { - type: 'number', - nullable: true, - }, - sort: { - type: 'string', - nullable: true, - }, - name: { - type: 'string', - nullable: true, - }, - query: { - type: 'string', - nullable: true, +const customSoundsEndpoints = API.v1 + .get( + 'custom-sounds.list', + { + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile< + PaginatedResult<{ + sounds: ICustomSound[]; + }> + >({ + additionalProperties: false, + type: 'object', + properties: { + count: { + type: 'number', + description: 'The number of sounds returned in this response.', + }, + offset: { + type: 'number', + description: 'The number of sounds that were skipped in this response.', + }, + total: { + type: 'number', + description: 'The total number of sounds that match the query.', + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + sounds: { + type: 'array', + items: { + $ref: '#/components/schemas/ICustomSound', + }, + }, + }, + required: ['count', 'offset', 'total', 'sounds', 'success'], + }), + }, + query: isCustomSoundsListProps, + authRequired: true, }, - }, - required: [], - additionalProperties: false, -}; + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams as Record); + const { sort, query } = await this.parseJsonQuery(); -export const isCustomSoundsListProps = ajv.compile(CustomSoundsListSchema); + const { name } = this.queryParams; -const customSoundsEndpoints = API.v1.get( - 'custom-sounds.list', - { - response: { - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - 403: validateForbiddenErrorResponse, - 200: ajv.compile< - PaginatedResult<{ - sounds: ICustomSound[]; - }> - >({ - additionalProperties: false, - type: 'object', - properties: { - count: { - type: 'number', - description: 'The number of sounds returned in this response.', - }, - offset: { - type: 'number', - description: 'The number of sounds that were skipped in this response.', - }, - total: { - type: 'number', - description: 'The total number of sounds that match the query.', - }, - success: { - type: 'boolean', - description: 'Indicates if the request was successful.', - }, - sounds: { - type: 'array', - items: { + const filter = { + ...query, + ...(name ? { name: { $regex: escapeRegExp(name as string), $options: 'i' } } : {}), + }; + + const { cursor, totalCount } = CustomSounds.findPaginated(filter, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + }); + const [sounds, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + sounds, + count: sounds.length, + offset, + total, + }); + }, + ) + .get( + 'custom-sounds.getOne', + { + response: { + 200: ajv.compile<{ sound: ICustomSound; success: boolean }>({ + additionalProperties: false, + type: 'object', + properties: { + sound: { $ref: '#/components/schemas/ICustomSound', }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, }, - }, - required: ['count', 'offset', 'total', 'sounds', 'success'], - }), + required: ['sound', 'success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + query: isCustomSoundsGetOneProps, + authRequired: true, }, - query: isCustomSoundsListProps, - authRequired: true, - }, - async function action() { - const { offset, count } = await getPaginationItems(this.queryParams as Record); - const { sort, query } = await this.parseJsonQuery(); + async function action() { + const { _id } = this.queryParams; - const { name } = this.queryParams; + const sound = await CustomSounds.findOneById(_id); - const filter = { - ...query, - ...(name ? { name: { $regex: escapeRegExp(name as string), $options: 'i' } } : {}), - }; + if (!sound) { + return API.v1.notFound('Custom Sound not found.'); + } - const { cursor, totalCount } = CustomSounds.findPaginated(filter, { - sort: sort || { name: 1 }, - skip: offset, - limit: count, - }); - const [sounds, total] = await Promise.all([cursor.toArray(), totalCount]); - - return API.v1.success({ - sounds, - count: sounds.length, - offset, - total, - }); - }, -); + return API.v1.success({ sound }); + }, + ); export type CustomSoundEndpoints = ExtractRoutesFromAPI; 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 037928cf1cdcf..573a8a1a123b0 100644 --- a/apps/meteor/app/api/server/v1/custom-user-status.ts +++ b/apps/meteor/app/api/server/v1/custom-user-status.ts @@ -1,46 +1,124 @@ +import type { ICustomUserStatus } from '@rocket.chat/core-typings'; import { CustomUserStatus } from '@rocket.chat/models'; -import { isCustomUserStatusListProps } from '@rocket.chat/rest-typings'; +import { ajv, ajvQuery, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } 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'; import { insertOrUpdateUserStatus } from '../../../user-status/server/methods/insertOrUpdateUserStatus'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; -API.v1.addRoute( - 'custom-user-status.list', - { authRequired: true, validateParams: isCustomUserStatusListProps }, - { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams as Record); - const { sort, query } = await this.parseJsonQuery(); - - const { name, _id } = this.queryParams; - - const filter = { - ...query, - ...(name ? { name: { $regex: escapeRegExp(name as string), $options: 'i' } } : {}), - ...(_id ? { _id } : {}), - }; +type CustomUserStatusListProps = PaginatedRequest<{ name?: string; _id?: string; query?: string }>; - const { cursor, totalCount } = CustomUserStatus.findPaginated(filter, { - sort: sort || { name: 1 }, - skip: offset, - limit: count, - }); +const CustomUserStatusListSchema = { + type: 'object', + properties: { + count: { + type: 'number', + nullable: true, + }, + offset: { + type: 'number', + nullable: true, + }, + sort: { + type: 'string', + nullable: true, + }, + name: { + type: 'string', + nullable: true, + }, + _id: { + type: 'string', + nullable: true, + }, + query: { + type: 'string', + nullable: true, + }, + }, + required: [], + additionalProperties: false, +}; - const [statuses, total] = await Promise.all([cursor.toArray(), totalCount]); +const isCustomUserStatusListProps = ajvQuery.compile(CustomUserStatusListSchema); - return API.v1.success({ - statuses, - count: statuses.length, - offset, - total, - }); +const customUserStatusEndpoints = API.v1.get( + 'custom-user-status.list', + { + authRequired: true, + query: isCustomUserStatusListProps, + response: { + 200: ajv.compile< + PaginatedResult<{ + statuses: ICustomUserStatus[]; + }> + >({ + type: 'object', + properties: { + statuses: { + type: 'array', + items: { + $ref: '#/components/schemas/ICustomUserStatus', + }, + }, + count: { + type: 'number', + description: 'The number of custom user statuses returned in this response.', + }, + offset: { + type: 'number', + description: 'The number of custom user statuses that were skipped in this response.', + }, + total: { + type: 'number', + description: 'The total number of custom user statuses that match the query.', + }, + success: { + type: 'boolean', + enum: [true], + description: 'Indicates if the request was successful.', + }, + }, + required: ['success', 'statuses', 'count', 'offset', 'total'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams as Record); + const { sort, query } = await this.parseJsonQuery(); + + const { name, _id } = this.queryParams; + + const filter = { + ...query, + ...(name ? { name: { $regex: escapeRegExp(name as string), $options: 'i' } } : {}), + ...(_id ? { _id } : {}), + }; + + const { cursor, totalCount } = CustomUserStatus.findPaginated(filter, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + }); + + const [statuses, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + statuses, + count: statuses.length, + offset, + total, + }); + }, ); API.v1.addRoute( @@ -127,3 +205,10 @@ API.v1.addRoute( }, }, ); + +export type CustomUserStatusEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends CustomUserStatusEndpoints {} +} diff --git a/apps/meteor/app/api/server/v1/e2e.ts b/apps/meteor/app/api/server/v1/e2e.ts index bfc70ba8b31a1..489a9e27a5953 100644 --- a/apps/meteor/app/api/server/v1/e2e.ts +++ b/apps/meteor/app/api/server/v1/e2e.ts @@ -1,14 +1,12 @@ +import type { IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; import { Subscriptions, Users } from '@rocket.chat/models'; import { ajv, + ajvQuery, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse, - ise2eGetUsersOfRoomWithoutKeyParamsGET, + validateForbiddenErrorResponse, ise2eSetUserPublicAndPrivateKeysParamsPOST, - ise2eUpdateGroupKeyParamsPOST, - isE2EProvideUsersGroupKeyProps, - isE2EFetchUsersWaitingForGroupKeyProps, - isE2EResetRoomKeyProps, } from '@rocket.chat/rest-typings'; import ExpiryMap from 'expiry-map'; @@ -34,6 +32,28 @@ type E2eSetRoomKeyIdProps = { keyID: string; }; +type e2eGetUsersOfRoomWithoutKeyParamsGET = { + rid: string; +}; + +type e2eUpdateGroupKeyParamsPOST = { + uid: string; + rid: string; + key: string; +}; + +type E2EFetchUsersWaitingForGroupKeyProps = { roomIds: string[] }; + +type E2EProvideUsersGroupKeyProps = { + usersSuggestedGroupKeys: Record; +}; + +type E2EResetRoomKeyProps = { + rid: string; + e2eKey: string; + e2eKeyId: string; +}; + const E2eSetRoomKeyIdSchema = { type: 'object', properties: { @@ -48,213 +68,279 @@ const E2eSetRoomKeyIdSchema = { additionalProperties: false, }; -const isE2eSetRoomKeyIdProps = ajv.compile(E2eSetRoomKeyIdSchema); - -const e2eEndpoints = API.v1.post( - 'e2e.setRoomKeyID', - { - authRequired: true, - body: isE2eSetRoomKeyIdProps, - response: { - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - 200: ajv.compile({ - type: 'object', - properties: { - success: { type: 'boolean', enum: [true] }, - }, - required: ['success'], - }), +const e2eGetUsersOfRoomWithoutKeyParamsGETSchema = { + type: 'object', + properties: { + rid: { + type: 'string', }, }, + additionalProperties: false, + required: ['rid'], +}; - async function action() { - const { rid, keyID } = this.bodyParams; - - await setRoomKeyIDMethod(this.userId, rid, keyID); +const e2eUpdateGroupKeyParamsPOSTSchema = { + type: 'object', + properties: { + uid: { + type: 'string', + }, + rid: { + type: 'string', + }, + key: { + type: 'string', + }, + }, + additionalProperties: false, + required: ['uid', 'rid', 'key'], +}; - return API.v1.success(); +const E2EFetchUsersWaitingForGroupKeySchema = { + type: 'object', + properties: { + roomIds: { + type: 'array', + items: { + type: 'string', + }, + }, }, -); + required: ['roomIds'], + additionalProperties: false, +}; -API.v1.addRoute( - 'e2e.fetchMyKeys', - { - authRequired: true, +const E2EProvideUsersGroupKeySchema = { + type: 'object', + properties: { + usersSuggestedGroupKeys: { + type: 'object', + additionalProperties: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + key: { type: 'string' }, + oldKeys: { + type: 'array', + items: { + type: 'object', + properties: { e2eKeyId: { type: 'string' }, ts: { type: 'string' }, E2EKey: { type: 'string' } }, + }, + }, + }, + required: ['_id', 'key'], + additionalProperties: false, + }, + }, + }, }, - { - async get() { - const result = await Users.fetchKeysByUserId(this.userId); + required: ['usersSuggestedGroupKeys'], + additionalProperties: false, +}; - return API.v1.success(result); +const E2EResetRoomKeySchema = { + type: 'object', + properties: { + rid: { + type: 'string', + }, + e2eKey: { + type: 'string', + }, + e2eKeyId: { + type: 'string', }, }, + required: ['rid', 'e2eKey', 'e2eKeyId'], + additionalProperties: false, +}; + +const isE2eSetRoomKeyIdProps = ajv.compile(E2eSetRoomKeyIdSchema); + +const ise2eGetUsersOfRoomWithoutKeyParamsGET = ajv.compile( + e2eGetUsersOfRoomWithoutKeyParamsGETSchema, ); -API.v1.addRoute( - 'e2e.getUsersOfRoomWithoutKey', - { - authRequired: true, - validateParams: ise2eGetUsersOfRoomWithoutKeyParamsGET, - }, - { - async get() { - const { rid } = this.queryParams; +const ise2eUpdateGroupKeyParamsPOST = ajv.compile(e2eUpdateGroupKeyParamsPOSTSchema); - const result = await getUsersOfRoomWithoutKeyMethod(this.userId, rid); +const isE2EFetchUsersWaitingForGroupKeyProps = ajvQuery.compile( + E2EFetchUsersWaitingForGroupKeySchema, +); - return API.v1.success(result); +const isE2EProvideUsersGroupKeyProps = ajv.compile(E2EProvideUsersGroupKeySchema); + +const isE2EResetRoomKeyProps = ajv.compile(E2EResetRoomKeySchema); + +const e2eEndpoints = API.v1 + .post( + 'e2e.setRoomKeyID', + { + authRequired: true, + body: isE2eSetRoomKeyIdProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + }), + }, }, - }, -); -/** - * @openapi - * /api/v1/e2e.setUserPublicAndPrivateKeys: - * post: - * description: Sets the end-to-end encryption keys for the authenticated user - * security: - * - autenticated: {} - * requestBody: - * description: A tuple containing the public and the private keys - * content: - * application/json: - * schema: - * type: object - * properties: - * public_key: - * type: string - * private_key: - * type: string - * force: - * type: boolean - * responses: - * 200: - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ApiSuccessV1' - * default: - * description: Unexpected error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ApiFailureV1' - */ -API.v1.addRoute( - 'e2e.setUserPublicAndPrivateKeys', - { - authRequired: true, - validateParams: ise2eSetUserPublicAndPrivateKeysParamsPOST, - }, - { - async post() { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { public_key, private_key, force } = this.bodyParams; + async function action() { + const { rid, keyID } = this.bodyParams; - await setUserPublicAndPrivateKeysMethod(this.userId, { - public_key, - private_key, - force, - }); + await setRoomKeyIDMethod(this.userId, rid, keyID); return API.v1.success(); }, - }, -); + ) + .get( + 'e2e.fetchMyKeys', + { + authRequired: true, + query: undefined, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ public_key?: string; private_key?: string }>({ + type: 'object', + properties: { + public_key: { type: 'string' }, + private_key: { type: 'string' }, + }, + }), + }, + }, + async function action() { + const result = await Users.fetchKeysByUserId(this.userId); -/** - * @openapi - * /api/v1/e2e.updateGroupKey: - * post: - * description: Updates the end-to-end encryption key for a user on a room - * security: - * - autenticated: {} - * requestBody: - * description: A tuple containing the user ID, the room ID, and the key - * content: - * application/json: - * schema: - * type: object - * properties: - * uid: - * type: string - * rid: - * type: string - * key: - * type: string - * responses: - * 200: - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ApiSuccessV1' - * default: - * description: Unexpected error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ApiFailureV1' - */ -API.v1.addRoute( - 'e2e.updateGroupKey', - { - authRequired: true, - validateParams: ise2eUpdateGroupKeyParamsPOST, - }, - { - async post() { - const { uid, rid, key } = this.bodyParams; + return API.v1.success(result as { public_key?: string; private_key?: string }); + }, + ) + .get( + 'e2e.getUsersOfRoomWithoutKey', + { + authRequired: true, + query: ise2eGetUsersOfRoomWithoutKeyParamsGET, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ + users: Pick[]; + }>({ + type: 'object', + properties: { + users: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + e2e: { + type: 'object', + properties: { + private_key: { type: 'string' }, + public_key: { type: 'string' }, + }, + }, + }, + required: ['_id'], + }, + }, + }, + required: ['users'], + }), + }, + }, - await updateGroupKey(rid, uid, key, this.userId); + async function action() { + const { rid } = this.queryParams; - return API.v1.success(); + const result = await getUsersOfRoomWithoutKeyMethod(this.userId, rid); + + return API.v1.success(result); + }, + ) + .post( + 'e2e.rejectSuggestedGroupKey', + { + authRequired: true, + body: ise2eGetUsersOfRoomWithoutKeyParamsGET, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + }), + }, }, - }, -); -API.v1.addRoute( - 'e2e.acceptSuggestedGroupKey', - { - authRequired: true, - validateParams: ise2eGetUsersOfRoomWithoutKeyParamsGET, - }, - { - async post() { + async function action() { const { rid } = this.bodyParams; - await handleSuggestedGroupKey('accept', rid, this.userId, 'e2e.acceptSuggestedGroupKey'); + await handleSuggestedGroupKey('reject', rid, this.userId, 'e2e.rejectSuggestedGroupKey'); return API.v1.success(); }, - }, -); + ) + .post( + 'e2e.acceptSuggestedGroupKey', + { + authRequired: true, + body: ise2eGetUsersOfRoomWithoutKeyParamsGET, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + }), + }, + }, -API.v1.addRoute( - 'e2e.rejectSuggestedGroupKey', - { - authRequired: true, - validateParams: ise2eGetUsersOfRoomWithoutKeyParamsGET, - }, - { - async post() { + async function action() { const { rid } = this.bodyParams; - await handleSuggestedGroupKey('reject', rid, this.userId, 'e2e.rejectSuggestedGroupKey'); + await handleSuggestedGroupKey('accept', rid, this.userId, 'e2e.acceptSuggestedGroupKey'); return API.v1.success(); }, - }, -); + ) + .get( + 'e2e.fetchUsersWaitingForGroupKey', + { + authRequired: true, + query: isE2EFetchUsersWaitingForGroupKeyProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ + usersWaitingForE2EKeys: Record; + }>({ + type: 'object', + properties: { + usersWaitingForE2EKeys: { + type: 'object', + additionalProperties: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + public_key: { type: 'string' }, + }, + required: ['_id', 'public_key'], + }, + }, + }, + }, + required: ['usersWaitingForE2EKeys'], + }), + }, + }, -API.v1.addRoute( - 'e2e.fetchUsersWaitingForGroupKey', - { - authRequired: true, - validateParams: isE2EFetchUsersWaitingForGroupKeyProps, - }, - { - async get() { + async function action() { if (!settings.get('E2E_Enable')) { return API.v1.success({ usersWaitingForE2EKeys: {} }); } @@ -268,17 +354,44 @@ API.v1.addRoute( usersWaitingForE2EKeys, }); }, - }, -); + ) + + .post( + 'e2e.updateGroupKey', + { + authRequired: true, + body: ise2eUpdateGroupKeyParamsPOST, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + }), + }, + }, + async function action() { + const { uid, rid, key } = this.bodyParams; -API.v1.addRoute( - 'e2e.provideUsersSuggestedGroupKeys', - { - authRequired: true, - validateParams: isE2EProvideUsersGroupKeyProps, - }, - { - async post() { + await updateGroupKey(rid, uid, key, this.userId); + + return API.v1.success(); + }, + ) + .post( + 'e2e.provideUsersSuggestedGroupKeys', + { + authRequired: true, + body: isE2EProvideUsersGroupKeyProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + }), + }, + }, + + async function action() { if (!settings.get('E2E_Enable')) { return API.v1.success(); } @@ -287,18 +400,27 @@ API.v1.addRoute( return API.v1.success(); }, - }, -); + ) + // This should have permissions + .post( + 'e2e.resetRoomKey', + { + authRequired: true, + body: isE2EResetRoomKeyProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile({ + type: 'object', + }), + }, + }, -// This should have permissions -API.v1.addRoute( - 'e2e.resetRoomKey', - { authRequired: true, validateParams: isE2EResetRoomKeyProps }, - { - async post() { + async function action() { const { rid, e2eKey, e2eKeyId } = this.bodyParams; if (!(await hasPermissionAsync(this.userId, 'toggle-room-e2e-encryption', rid))) { - return API.v1.forbidden(); + return API.v1.forbidden('error-not-allowed'); } if (LockMap.has(rid)) { throw new Error('error-e2e-key-reset-in-progress'); @@ -320,10 +442,71 @@ API.v1.addRoute( LockMap.delete(rid); } }, - }, -); + ) + .post( + 'e2e.setUserPublicAndPrivateKeys', + { + authRequired: true, + body: ise2eSetUserPublicAndPrivateKeysParamsPOST, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + 400: validateBadRequestErrorResponse, + }, + }, + async function action() { + const { public_key, private_key, force } = this.bodyParams; + + await setUserPublicAndPrivateKeysMethod(this.userId, { + public_key, + private_key, + force, + }); + + return API.v1.success(); + }, + ); + +/** + * @openapi + * /api/v1/e2e.setUserPublicAndPrivateKeys: + * post: + * description: Sets the end-to-end encryption keys for the authenticated user + * security: + * - autenticated: {} + * requestBody: + * description: A tuple containing the public and the private keys + * content: + * application/json: + * schema: + * type: object + * properties: + * public_key: + * type: string + * private_key: + * type: string + * force: + * type: boolean + * responses: + * 200: + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiSuccessV1' + * default: + * description: Unexpected error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiFailureV1' + */ -export type E2eEndpoints = ExtractRoutesFromAPI; +type E2eEndpoints = ExtractRoutesFromAPI; declare module '@rocket.chat/rest-typings' { // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface diff --git a/apps/meteor/app/api/server/v1/email-inbox.ts b/apps/meteor/app/api/server/v1/email-inbox.ts index 89ede496b78ac..0df12b4c88e4d 100644 --- a/apps/meteor/app/api/server/v1/email-inbox.ts +++ b/apps/meteor/app/api/server/v1/email-inbox.ts @@ -1,171 +1,258 @@ +import type { IEmailInbox } from '@rocket.chat/core-typings'; import { EmailInbox, Users } from '@rocket.chat/models'; -import { check, Match } from 'meteor/check'; +import { + ajv, + isEmailInboxList, + isEmailInbox, + isEmailInboxSearch, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateNotFoundErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; import { sendTestEmailToInbox } from '../../../../server/features/EmailInbox/EmailInbox_Outgoing'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; -import { insertOneEmailInbox, findEmailInboxes, updateEmailInbox, removeEmailInbox } from '../lib/emailInbox'; +import { findEmailInboxes, insertOneEmailInbox, removeEmailInbox, updateEmailInbox } from '../lib/emailInbox'; -API.v1.addRoute( +const paginatedEmailInboxesResponseSchema = ajv.compile<{ emailInboxes: IEmailInbox[]; total: number; count: number; offset: number }>({ + type: 'object', + properties: { + emailInboxes: { type: 'array', items: { $ref: '#/components/schemas/IEmailInbox' } }, + total: { type: 'number' }, + count: { type: 'number' }, + offset: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['emailInboxes', 'total', 'count', 'offset', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'email-inbox.list', - { authRequired: true, permissionsRequired: ['manage-email-inbox'] }, { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, query } = await this.parseJsonQuery(); - const emailInboxes = await findEmailInboxes({ query, pagination: { offset, count, sort } }); - - return API.v1.success(emailInboxes); + authRequired: true, + permissionsRequired: ['manage-email-inbox'], + query: isEmailInboxList, + response: { + 200: paginatedEmailInboxesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, query } = await this.parseJsonQuery(); + const emailInboxes = await findEmailInboxes({ query, pagination: { offset, count, sort } }); + + return API.v1.success(emailInboxes); + }, ); -API.v1.addRoute( +API.v1.post( 'email-inbox', - { authRequired: true, permissionsRequired: ['manage-email-inbox'] }, { - async post() { - check(this.bodyParams, { - _id: Match.Maybe(String), - active: Boolean, - name: String, - email: String, - description: Match.Maybe(String), - senderInfo: Match.Maybe(String), - department: Match.Maybe(String), - smtp: Match.ObjectIncluding({ - server: String, - port: Number, - username: String, - password: String, - secure: Boolean, - }), - imap: Match.ObjectIncluding({ - server: String, - port: Number, - username: String, - password: String, - secure: Boolean, - maxRetries: Number, - }), - }); - - const emailInboxParams = this.bodyParams; - - let _id: string; - - if (!emailInboxParams?._id) { - const { insertedId } = await insertOneEmailInbox(this.userId, emailInboxParams); - - if (!insertedId) { - return API.v1.failure('Failed to create email inbox'); - } - - _id = insertedId; - } else { - const emailInbox = await updateEmailInbox({ ...emailInboxParams, _id: emailInboxParams._id }); - - if (!emailInbox?._id) { - return API.v1.failure('Failed to update email inbox'); - } - - _id = emailInbox._id; + authRequired: true, + permissionsRequired: ['manage-email-inbox'], + body: isEmailInbox, + response: { + 200: ajv.compile<{ _id: string }>({ + type: 'object', + properties: { _id: { type: 'string' }, success: { type: 'boolean', enum: [true] } }, + required: ['_id', 'success'], + additionalProperties: false, + }), + 400: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [false] }, error: { type: 'string' } }, + required: ['success', 'error'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const body = this.bodyParams; + const maxRetries = + 'maxRetries' in body.imap && typeof (body.imap as { maxRetries?: number }).maxRetries === 'number' + ? (body.imap as { maxRetries: number }).maxRetries + : 5; + const emailInboxParams = { + ...body, + imap: { + ...body.imap, + maxRetries, + }, + }; + + let _id: string; + + if (!emailInboxParams?._id) { + const { insertedId } = await insertOneEmailInbox(this.userId, emailInboxParams); + + if (!insertedId) { + return API.v1.failure('Failed to create email inbox'); } - return API.v1.success({ _id }); - }, + _id = insertedId; + } else { + const emailInbox = await updateEmailInbox({ ...emailInboxParams, _id: emailInboxParams._id }); + + if (!emailInbox?._id) { + return API.v1.failure('Failed to update email inbox'); + } + + _id = emailInbox._id; + } + + return API.v1.success({ _id }); }, ); -API.v1.addRoute( +API.v1.get( 'email-inbox/:_id', - { authRequired: true, permissionsRequired: ['manage-email-inbox'] }, { - async get() { - check(this.urlParams, { - _id: String, - }); - - const { _id } = this.urlParams; - if (!_id) { - throw new Error('error-invalid-param'); - } - const emailInbox = await EmailInbox.findOneById(_id); + authRequired: true, + permissionsRequired: ['manage-email-inbox'], + response: { + 200: ajv.compile({ + oneOf: [ + { + allOf: [ + { $ref: '#/components/schemas/IEmailInbox' }, + { + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + }, + ], + }, + { type: 'null' }, + ], + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + }, + async function action() { + const { _id } = this.urlParams; + if (!_id) { + throw new Error('error-invalid-param'); + } + const emailInbox = await EmailInbox.findOneById(_id); - if (!emailInbox) { - return API.v1.notFound(); - } + if (!emailInbox) { + return API.v1.notFound(); + } - return API.v1.success(emailInbox); + return API.v1.success(emailInbox); + }, +); + +API.v1.delete( + 'email-inbox/:_id', + { + authRequired: true, + permissionsRequired: ['manage-email-inbox'], + response: { + 200: ajv.compile<{ _id: string }>({ + type: 'object', + properties: { _id: { type: 'string' }, success: { type: 'boolean', enum: [true] } }, + required: ['_id', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, }, - async delete() { - check(this.urlParams, { - _id: String, - }); - - const { _id } = this.urlParams; - if (!_id) { - throw new Error('error-invalid-param'); - } + }, + async function action() { + const { _id } = this.urlParams; + if (!_id) { + throw new Error('error-invalid-param'); + } - const { deletedCount } = await removeEmailInbox(_id); + const { deletedCount } = await removeEmailInbox(_id); - if (!deletedCount) { - return API.v1.notFound(); - } + if (!deletedCount) { + return API.v1.notFound(); + } - return API.v1.success({ _id }); - }, + return API.v1.success({ _id }); }, ); -API.v1.addRoute( +API.v1.get( 'email-inbox.search', - { authRequired: true, permissionsRequired: ['manage-email-inbox'] }, { - async get() { - check(this.queryParams, { - email: String, - }); - - const { email } = this.queryParams; + authRequired: true, + permissionsRequired: ['manage-email-inbox'], + query: isEmailInboxSearch, + response: { + 200: ajv.compile<{ emailInbox: IEmailInbox | null }>({ + type: 'object', + properties: { + emailInbox: { oneOf: [{ $ref: '#/components/schemas/IEmailInbox' }, { type: 'null' }] }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['emailInbox', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { email } = this.queryParams; - // TODO: Chapter day backend - check if user has permission to view this email inbox instead of null values - // TODO: Chapter day: Remove this endpoint and move search to GET /email-inbox - const emailInbox = await EmailInbox.findByEmail(email); + const emailInbox = await EmailInbox.findByEmail(email); - return API.v1.success({ emailInbox }); - }, + return API.v1.success({ emailInbox }); }, ); -API.v1.addRoute( +API.v1.post( 'email-inbox.send-test/:_id', - { authRequired: true, permissionsRequired: ['manage-email-inbox'] }, { - async post() { - check(this.urlParams, { - _id: String, - }); - - const { _id } = this.urlParams; - if (!_id) { - throw new Error('error-invalid-param'); - } - const emailInbox = await EmailInbox.findOneById(_id); + authRequired: true, + permissionsRequired: ['manage-email-inbox'], + response: { + 200: ajv.compile<{ _id: string }>({ + type: 'object', + properties: { _id: { type: 'string' }, success: { type: 'boolean', enum: [true] } }, + required: ['_id', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + }, + async function action() { + const { _id } = this.urlParams; + if (!_id) { + throw new Error('error-invalid-param'); + } + const emailInbox = await EmailInbox.findOneById(_id); - if (!emailInbox) { - return API.v1.notFound(); - } + if (!emailInbox) { + return API.v1.notFound(); + } - const user = await Users.findOneById(this.userId); - if (!user) { - return API.v1.notFound(); - } + const user = await Users.findOneById(this.userId); + if (!user) { + return API.v1.notFound(); + } - await sendTestEmailToInbox(emailInbox, user); + await sendTestEmailToInbox(emailInbox, user); - return API.v1.success({ _id }); - }, + return API.v1.success({ _id }); }, ); diff --git a/apps/meteor/app/api/server/v1/emoji-custom.ts b/apps/meteor/app/api/server/v1/emoji-custom.ts index 15764b7f74e7d..043da3b0c5396 100644 --- a/apps/meteor/app/api/server/v1/emoji-custom.ts +++ b/apps/meteor/app/api/server/v1/emoji-custom.ts @@ -1,7 +1,7 @@ import { Media } from '@rocket.chat/core-services'; import type { IEmojiCustom } from '@rocket.chat/core-typings'; import { EmojiCustom } from '@rocket.chat/models'; -import { isEmojiCustomList } from '@rocket.chat/rest-typings'; +import { ajv, isEmojiCustomList, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Meteor } from 'meteor/meteor'; @@ -11,6 +11,7 @@ import { insertOrUpdateEmoji } from '../../../emoji-custom/server/lib/insertOrUp import { uploadEmojiCustomWithBuffer } from '../../../emoji-custom/server/lib/uploadEmojiCustom'; import { deleteEmojiCustom } from '../../../emoji-custom/server/methods/deleteEmojiCustom'; import { settings } from '../../../settings/server'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { findEmojisCustom } from '../lib/emoji-custom'; @@ -29,11 +30,43 @@ function validateDateParam(paramName: string, paramValue: string | undefined): D return date; } -API.v1.addRoute( - 'emoji-custom.list', - { authRequired: true, validateParams: isEmojiCustomList }, - { - async get() { +const emojiListResponseSchema = ajv.compile({ + type: 'object', + properties: { + emojis: { + type: 'object', + properties: { + update: { type: 'array', items: { type: 'object' } }, + remove: { type: 'array', items: { type: 'object' } }, + }, + required: ['update', 'remove'], + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['emojis', 'success'], + additionalProperties: false, +}); + +const emojiDeleteBodySchema = ajv.compile({ + type: 'object', + properties: { emojiId: { type: 'string' } }, + required: ['emojiId'], + additionalProperties: false, +}); + +const emojiCustomCreateEndpoints = API.v1 + .get( + 'emoji-custom.list', + { + authRequired: true, + query: isEmojiCustomList, + response: { + 200: emojiListResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { query } = await this.parseJsonQuery(); const { updatedSince, _updatedAt, _id } = this.queryParams; @@ -70,14 +103,28 @@ API.v1.addRoute( }, }); }, - }, -); - -API.v1.addRoute( - 'emoji-custom.all', - { authRequired: true }, - { - async get() { + ) + .get( + 'emoji-custom.all', + { + 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, + }), + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { offset, count } = await getPaginationItems(this.queryParams); const { sort, query } = await this.parseJsonQuery(); const { name } = this.queryParams; @@ -100,19 +147,46 @@ API.v1.addRoute( }), ); }, - }, -); - -API.v1.addRoute( - 'emoji-custom.create', - { authRequired: true }, - { - async post() { + ) + .post( + 'emoji-custom.create', + { + 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: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { const emoji = await getUploadFormData( { request: this.request, }, - { field: 'emoji', sizeLimit: settings.get('FileUpload_MaxFileSize') }, + { + field: 'emoji', + sizeLimit: settings.get('FileUpload_MaxFileSize'), + }, ); const { fields, fileBuffer, mimetype } = emoji; @@ -142,14 +216,23 @@ API.v1.addRoute( return API.v1.success(); }, - }, -); - -API.v1.addRoute( - 'emoji-custom.update', - { authRequired: true }, - { - async post() { + ) + .post( + 'emoji-custom.update', + { + authRequired: true, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const emoji = await getUploadFormData( { request: this.request, @@ -200,14 +283,24 @@ API.v1.addRoute( } return API.v1.success(); }, - }, -); - -API.v1.addRoute( - 'emoji-custom.delete', - { authRequired: true }, - { - async post() { + ) + .post( + 'emoji-custom.delete', + { + authRequired: true, + body: emojiDeleteBodySchema, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { emojiId } = this.bodyParams; if (!emojiId) { return API.v1.failure('The "emojiId" params is required!'); @@ -217,5 +310,13 @@ API.v1.addRoute( return API.v1.success(); }, - }, -); + ); + +type EmojiCustomCreateEndpoints = ExtractRoutesFromAPI; + +export type EmojiCustomEndpoints = EmojiCustomCreateEndpoints; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends EmojiCustomCreateEndpoints {} +} diff --git a/apps/meteor/app/api/server/v1/groups.ts b/apps/meteor/app/api/server/v1/groups.ts index 3fbe9c967a8e9..dba48f1a9ffa9 100644 --- a/apps/meteor/app/api/server/v1/groups.ts +++ b/apps/meteor/app/api/server/v1/groups.ts @@ -34,7 +34,6 @@ import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMes import { API } from '../api'; import { addUserToFileObj } from '../helpers/addUserToFileObj'; import { composeRoomWithLastMessage } from '../helpers/composeRoomWithLastMessage'; -import { getLoggedInUser } from '../helpers/getLoggedInUser'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { getUserFromParams, getUserListFromParams } from '../helpers/getUserFromParams'; @@ -68,7 +67,7 @@ async function getRoomFromParams(params: { roomId?: string } | { roomName?: stri } })(); - if (!room || room.t !== 'p') { + if (room?.t !== 'p') { throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); } @@ -274,7 +273,7 @@ API.v1.addRoute( room = await Rooms.findOneByName(params.roomName || ''); } - if (!room || room.t !== 'p') { + if (room?.t !== 'p') { throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); } @@ -510,7 +509,7 @@ API.v1.addRoute( oldestDate = new Date(this.queryParams.oldest); } - const inclusive = this.queryParams.inclusive || false; + const inclusive = this.queryParams.inclusive === 'true'; let count = 20; if (this.queryParams.count) { @@ -522,7 +521,7 @@ API.v1.addRoute( offset = parseInt(String(this.queryParams.offset)); } - const unreads = this.queryParams.unreads || false; + const unreads = this.queryParams.unreads === 'true'; const showThreadMessages = this.queryParams.showThreadMessages !== 'false'; @@ -792,7 +791,7 @@ API.v1.addRoute( rid: findResult.rid, ...parseIds(mentionIds, 'mentions._id'), ...parseIds(starredIds, 'starred._id'), - ...(pinned && pinned.toLowerCase() === 'true' ? { pinned: true } : {}), + ...(pinned?.toLowerCase() === 'true' ? { pinned: true } : {}), _hidden: { $ne: true }, }; @@ -839,12 +838,7 @@ API.v1.addRoute( return API.v1.failure('Group does not exists'); } - const user = await getLoggedInUser(this.request); - if (!user) { - return API.v1.failure('User does not exists'); - } - - if (!(await canAccessRoomAsync(room, user))) { + if (!(await canAccessRoomAsync(room, this.user))) { throw new Meteor.Error('error-not-allowed', 'Not Allowed'); } @@ -1198,7 +1192,7 @@ API.v1.addRoute( userId: this.userId, }); - const roles = await executeGetRoomRoles(findResult.rid, this.userId); + const roles = await executeGetRoomRoles(findResult.rid, this.user); return API.v1.success({ roles, diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts index 9972de8ce10cb..30b9f362bddca 100644 --- a/apps/meteor/app/api/server/v1/im.ts +++ b/apps/meteor/app/api/server/v1/im.ts @@ -6,6 +6,7 @@ import { Subscriptions, Uploads, Messages, Rooms, Users } from '@rocket.chat/mod import { ajv, validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, validateBadRequestErrorResponse, isDmFileProps, isDmMemberProps, @@ -99,6 +100,10 @@ type DmDeleteProps = username: string; }; +type DmCloseProps = { + roomId: string; +}; + const isDmDeleteProps = ajv.compile({ oneOf: [ { @@ -144,6 +149,40 @@ const dmDeleteEndpointsProps = { }, } as const; +const DmClosePropsSchema = { + type: 'object', + properties: { + roomId: { + type: 'string', + }, + }, + required: ['roomId'], + additionalProperties: false, +}; + +const isDmCloseProps = ajv.compile(DmClosePropsSchema); + +const dmCloseEndpointsProps = { + authRequired: true, + body: isDmCloseProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, +}; + const dmDeleteAction = (_path: Path): TypedAction => async function action() { const { room } = await findDirectMessageRoom(this.bodyParams, this.userId); @@ -160,51 +199,52 @@ const dmDeleteAction = (_path: Path): TypedAction(_path: Path): TypedAction => + async function action() { + const { roomId } = 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', { + method: 'dm.close', + }); + } + let subscription; -API.v1.addRoute( - ['dm.close', 'im.close'], - { authRequired: true }, - { - async post() { - const { roomId } = this.bodyParams; - if (!roomId) { - throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" is required'); + const roomExists = !!(await Rooms.findOneById(roomId)); + if (!roomExists) { + // even if the room doesn't exist, we should allow the user to close the subscription anyways + subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId); + } else { + const canAccess = await canAccessRoomIdAsync(roomId, this.userId); + if (!canAccess) { + return API.v1.forbidden('error-not-allowed'); } - let subscription; - - const roomExists = !!(await Rooms.findOneById(roomId)); - if (!roomExists) { - // even if the room doesn't exist, we should allow the user to close the subscription anyways - subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId); - } else { - const canAccess = await canAccessRoomIdAsync(roomId, this.userId); - if (!canAccess) { - return API.v1.forbidden(); - } + const { subscription: subs } = await findDirectMessageRoom({ roomId }, this.userId); - const { subscription: subs } = await findDirectMessageRoom({ roomId }, this.userId); + subscription = subs; + } - subscription = subs; - } + if (!subscription) { + return API.v1.failure(`The user is not subscribed to the room`); + } - if (!subscription) { - return API.v1.failure(`The user is not subscribed to the room`); - } + if (!subscription.open) { + return API.v1.failure(`The direct message room, is already closed to the sender`); + } - if (!subscription.open) { - return API.v1.failure(`The direct message room, is already closed to the sender`); - } + await hideRoomMethod(this.userId, roomId); - await hideRoomMethod(this.userId, roomId); + return API.v1.success(); + }; - return API.v1.success(); - }, - }, -); +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')); // https://github.com/RocketChat/Rocket.Chat/pull/9679 as reference API.v1.addRoute( diff --git a/apps/meteor/app/api/server/v1/import.ts b/apps/meteor/app/api/server/v1/import.ts index 6104076c4fb46..38a60d33ab024 100644 --- a/apps/meteor/app/api/server/v1/import.ts +++ b/apps/meteor/app/api/server/v1/import.ts @@ -1,6 +1,8 @@ import { Import } from '@rocket.chat/core-services'; +import type { IImport } from '@rocket.chat/core-typings'; import { Imports } from '@rocket.chat/models'; import { + ajv, isUploadImportFileParamsPOST, isDownloadPublicImportFileParamsPOST, isStartImportParamsPOST, @@ -12,6 +14,9 @@ import { isGetCurrentImportOperationParamsGET, isImportersListParamsGET, isImportAddUsersParamsPOST, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateBadRequestErrorResponse, } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; @@ -28,260 +33,363 @@ import { PendingAvatarImporter } from '../../../importer-pending-avatars/server/ import { PendingFileImporter } from '../../../importer-pending-files/server/PendingFileImporter'; import { API } from '../api'; -API.v1.addRoute( +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +const uploadImportFileResponseSchema = ajv.compile({ + type: 'object', + additionalProperties: true, +}); + +const countResponseSchema = ajv.compile<{ count: number }>({ + type: 'object', + properties: { + count: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['count', 'success'], + additionalProperties: false, +}); + +const operationResponseSchema = ajv.compile<{ operation: IImport | undefined }>({ + type: 'object', + properties: { + operation: { $ref: '#/components/schemas/IImport' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +const importersListResponseSchema = ajv.compile>({ + type: 'array', + items: { type: 'object', properties: { key: { type: 'string' }, name: { type: 'string' } } }, +}); + +API.v1.post( 'uploadImportFile', { authRequired: true, - validateParams: isUploadImportFileParamsPOST, permissionsRequired: ['run-import'], + body: isUploadImportFileParamsPOST, + response: { + 200: uploadImportFileResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { binaryContent, contentType, fileName, importerKey } = this.bodyParams; + async function action() { + const { binaryContent, contentType, fileName, importerKey } = this.bodyParams; - return API.v1.success(await executeUploadImportFile(this.userId, binaryContent, contentType, fileName, importerKey)); - }, + await executeUploadImportFile(this.userId, binaryContent, contentType, fileName, importerKey); + + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'downloadPublicImportFile', { authRequired: true, - validateParams: isDownloadPublicImportFileParamsPOST, permissionsRequired: ['run-import'], + body: isDownloadPublicImportFileParamsPOST, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { fileUrl, importerKey } = this.bodyParams; - await executeDownloadPublicImportFile(this.userId, fileUrl, importerKey); + async function action() { + const { fileUrl, importerKey } = this.bodyParams; + await executeDownloadPublicImportFile(this.userId, fileUrl, importerKey); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'startImport', { authRequired: true, - validateParams: isStartImportParamsPOST, permissionsRequired: ['run-import'], + body: isStartImportParamsPOST, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { input } = this.bodyParams; + async function action() { + const { input } = this.bodyParams; - await executeStartImport({ input }, this.userId); + await executeStartImport({ input }, this.userId); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.get( 'getImportFileData', { authRequired: true, - validateParams: isGetImportFileDataParamsGET, permissionsRequired: ['run-import'], + query: isGetImportFileDataParamsGET, + response: { + 200: ajv.compile>({ type: 'object', additionalProperties: true }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async get() { - const result = await executeGetImportFileData(); + async function action() { + const result = await executeGetImportFileData(); - return API.v1.success(result); - }, + return API.v1.success(typeof result === 'object' ? result : {}); }, ); -API.v1.addRoute( +API.v1.get( 'getImportProgress', { authRequired: true, - validateParams: isGetImportProgressParamsGET, permissionsRequired: ['run-import'], - }, - { - async get() { - const result = await executeGetImportProgress(); - return API.v1.success(result); + query: isGetImportProgressParamsGET, + response: { + 200: ajv.compile>({ type: 'object', additionalProperties: true }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const result = await executeGetImportProgress(); + + return API.v1.success(typeof result === 'object' ? result : {}); + }, ); -API.v1.addRoute( +API.v1.get( 'getLatestImportOperations', { authRequired: true, - validateParams: isGetLatestImportOperationsParamsGET, permissionsRequired: ['view-import-operations'], - }, - { - async get() { - const result = await executeGetLatestImportOperations(); - return API.v1.success(result); + query: isGetLatestImportOperationsParamsGET, + response: { + 200: ajv.compile>({ + type: 'array', + items: { + type: 'object', + }, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const operations = await executeGetLatestImportOperations(); + + return API.v1.success(operations); + }, ); -API.v1.addRoute( +API.v1.post( 'downloadPendingFiles', { authRequired: true, - validateParams: isDownloadPendingFilesParamsPOST, permissionsRequired: ['run-import'], - }, - { - async post() { - const importer = Importers.get('pending-files'); - if (!importer) { - throw new Meteor.Error('error-importer-not-defined', 'The Pending File Importer was not found.', 'downloadPendingFiles'); - } - - const operation = await Import.newOperation(this.userId, importer.name, importer.key); - const instance = new PendingFileImporter(importer, operation); - const count = await instance.prepareFileCount(); - - return API.v1.success({ - count, - }); + body: isDownloadPendingFilesParamsPOST, + response: { + 200: countResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const importer = Importers.get('pending-files'); + if (!importer) { + throw new Meteor.Error('error-importer-not-defined', 'The Pending File Importer was not found.', 'downloadPendingFiles'); + } + + const operation = await Import.newOperation(this.userId, importer.name, importer.key); + const instance = new PendingFileImporter(importer, operation); + const count = await instance.prepareFileCount(); + + return API.v1.success({ + count, + }); + }, ); -API.v1.addRoute( +API.v1.post( 'downloadPendingAvatars', { authRequired: true, - validateParams: isDownloadPendingAvatarsParamsPOST, permissionsRequired: ['run-import'], - }, - { - async post() { - const importer = Importers.get('pending-avatars'); - if (!importer) { - throw new Meteor.Error('error-importer-not-defined', 'The Pending File Importer was not found.', 'downloadPendingAvatars'); - } - - const operation = await Import.newOperation(this.userId, importer.name, importer.key); - const instance = new PendingAvatarImporter(importer, operation); - const count = await instance.prepareFileCount(); - - return API.v1.success({ - count, - }); + body: isDownloadPendingAvatarsParamsPOST, + response: { + 200: countResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const importer = Importers.get('pending-avatars'); + if (!importer) { + throw new Meteor.Error('error-importer-not-defined', 'The Pending File Importer was not found.', 'downloadPendingAvatars'); + } + + const operation = await Import.newOperation(this.userId, importer.name, importer.key); + const instance = new PendingAvatarImporter(importer, operation); + const count = await instance.prepareFileCount(); + + return API.v1.success({ + count, + }); + }, ); -API.v1.addRoute( +API.v1.get( 'getCurrentImportOperation', { authRequired: true, - validateParams: isGetCurrentImportOperationParamsGET, permissionsRequired: ['run-import'], - }, - { - async get() { - const operation = await Imports.findLastImport(); - return API.v1.success({ - operation, - }); + query: isGetCurrentImportOperationParamsGET, + response: { + 200: operationResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const operation = await Imports.findLastImport(); + + return API.v1.success({ + operation, + }); + }, ); -API.v1.addRoute( +API.v1.get( 'importers.list', { authRequired: true, - validateParams: isImportersListParamsGET, permissionsRequired: ['run-import'], + query: isImportersListParamsGET, + response: { + 200: importersListResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async get() { - const importers = Importers.getAllVisible().map(({ key, name }) => ({ key, name })); + async function action() { + const importers = Importers.getAllVisible().map(({ key, name }) => ({ key, name })); - return API.v1.success(importers); - }, + return API.v1.success(importers); }, ); -API.v1.addRoute( +API.v1.post( 'import.clear', { authRequired: true, permissionsRequired: ['run-import'], + response: { + 200: successResponseSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - await Import.clear(); + async function action() { + await Import.clear(); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'import.new', { authRequired: true, permissionsRequired: ['run-import'], + response: { + 200: operationResponseSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const operation = await Import.newOperation(this.userId, 'api', 'api'); + async function action() { + const operation = await Import.newOperation(this.userId, 'api', 'api'); - return API.v1.success({ operation }); - }, + return API.v1.success({ operation }); }, ); -API.v1.addRoute( +API.v1.get( 'import.status', { authRequired: true, permissionsRequired: ['run-import'], + response: { + 200: ajv.compile>({ type: 'object', additionalProperties: true }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async get() { - const status = await Import.status(); + async function action() { + const status = await Import.status(); - return API.v1.success(status); - }, + return API.v1.success(typeof status === 'object' ? status : {}); }, ); -API.v1.addRoute( +API.v1.post( 'import.addUsers', { authRequired: true, - validateParams: isImportAddUsersParamsPOST, permissionsRequired: ['run-import'], + body: isImportAddUsersParamsPOST, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { users } = this.bodyParams; + async function action() { + const { users } = this.bodyParams; - await Import.addUsers(users); + await Import.addUsers(users); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'import.run', { authRequired: true, permissionsRequired: ['run-import'], + response: { + 200: successResponseSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - await Import.run(this.userId); + async function action() { + await Import.run(this.userId); - return API.v1.success(); - }, + return API.v1.success(); }, ); diff --git a/apps/meteor/app/api/server/v1/instances.ts b/apps/meteor/app/api/server/v1/instances.ts index 47f98c856f446..6fad69d1c33b9 100644 --- a/apps/meteor/app/api/server/v1/instances.ts +++ b/apps/meteor/app/api/server/v1/instances.ts @@ -1,4 +1,5 @@ import { InstanceStatus } from '@rocket.chat/models'; +import { ajv, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse } from '@rocket.chat/rest-typings'; import { isRunningMs } from '../../../../server/lib/isRunningMs'; import { API } from '../api'; @@ -12,33 +13,82 @@ const getConnections = (() => { return () => getInstanceList(); })(); -API.v1.addRoute( +API.v1.get( 'instances.get', - { authRequired: true, permissionsRequired: ['view-statistics'] }, { - async get() { - const instanceRecords = await InstanceStatus.find().toArray(); - - const connections = await getConnections(); - - const result = instanceRecords.map((instanceRecord) => { - const connection = connections.find((c) => c.id === instanceRecord._id); - - return { - address: connection?.ipList[0], + authRequired: true, + permissionsRequired: ['view-statistics'], + response: { + 200: ajv.compile<{ + instances: { + address?: string; currentStatus: { - connected: connection?.available || false, - lastHeartbeatTime: connection?.lastHeartbeatTime, - local: connection?.local, + connected: boolean; + lastHeartbeatTime?: number; + local?: boolean; + }; + instanceRecord: object; + broadcastAuth: boolean; + }[]; + success: true; + }>({ + type: 'object', + properties: { + instances: { + type: 'array', + items: { + type: 'object', + properties: { + address: { type: 'string' }, + currentStatus: { + type: 'object', + properties: { + connected: { type: 'boolean' }, + lastHeartbeatTime: { type: 'number' }, + local: { type: 'boolean' }, + }, + required: ['connected'], + }, + instanceRecord: { type: 'object' }, + broadcastAuth: { type: 'boolean' }, + }, + required: ['currentStatus', 'instanceRecord', 'broadcastAuth'], + }, }, - instanceRecord, - broadcastAuth: true, - }; - }); - - return API.v1.success({ - instances: result, - }); + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['instances', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const instanceRecords = await InstanceStatus.find().toArray(); + + const connections = await getConnections(); + + const result = instanceRecords.map((instanceRecord) => { + const connection = connections.find((c) => c.id === instanceRecord._id); + + return { + address: connection?.ipList[0], + currentStatus: { + connected: connection?.available || false, + lastHeartbeatTime: connection?.lastHeartbeatTime, + local: connection?.local, + }, + instanceRecord, + broadcastAuth: true, + }; + }); + + return API.v1.success({ + instances: result, + }); + }, ); diff --git a/apps/meteor/app/api/server/v1/integrations.ts b/apps/meteor/app/api/server/v1/integrations.ts index 78d77fe007f39..d3dd51139bbc2 100644 --- a/apps/meteor/app/api/server/v1/integrations.ts +++ b/apps/meteor/app/api/server/v1/integrations.ts @@ -1,12 +1,16 @@ -import type { IIntegration, INewIncomingIntegration, INewOutgoingIntegration } from '@rocket.chat/core-typings'; +import type { IIntegration, IIntegrationHistory, INewIncomingIntegration, INewOutgoingIntegration } from '@rocket.chat/core-typings'; import { Integrations, IntegrationHistory } from '@rocket.chat/models'; import { + ajv, isIntegrationsCreateProps, isIntegrationsHistoryProps, isIntegrationsRemoveProps, isIntegrationsGetProps, isIntegrationsUpdateProps, isIntegrationsListProps, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Match, check } from 'meteor/check'; @@ -26,70 +30,106 @@ import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { findOneIntegration } from '../lib/integrations'; -API.v1.addRoute( +const integrationSuccessSchema = ajv.compile<{ integration: IIntegration | null }>({ + type: 'object', + properties: { + integration: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['integration', 'success'], + additionalProperties: false, +}); + +API.v1.post( 'integrations.create', - { authRequired: true, validateParams: isIntegrationsCreateProps }, { - async post() { - switch (this.bodyParams.type) { - case 'webhook-outgoing': - return API.v1.success({ integration: await addOutgoingIntegration(this.userId, this.bodyParams as INewOutgoingIntegration) }); - case 'webhook-incoming': - return API.v1.success({ integration: await addIncomingIntegration(this.userId, this.bodyParams as INewIncomingIntegration) }); - default: - return API.v1.failure('Invalid integration type.'); - } + authRequired: true, + body: isIntegrationsCreateProps, + response: { + 200: integrationSuccessSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + switch (this.bodyParams.type) { + case 'webhook-outgoing': + return API.v1.success({ + integration: await addOutgoingIntegration(this.userId, this.bodyParams as INewOutgoingIntegration), + }); + case 'webhook-incoming': + return API.v1.success({ + integration: await addIncomingIntegration(this.userId, this.bodyParams as INewIncomingIntegration), + }); + default: + return API.v1.failure('Invalid integration type.'); + } + }, ); -API.v1.addRoute( +API.v1.get( 'integrations.history', { authRequired: true, - validateParams: isIntegrationsHistoryProps, + query: isIntegrationsHistoryProps, permissionsRequired: { GET: { permissions: ['manage-outgoing-integrations', 'manage-own-outgoing-integrations'], operation: 'hasAny' }, }, - }, - { - async get() { - const { userId, queryParams } = this; - - if (!queryParams.id || queryParams.id.trim() === '') { - return API.v1.failure('Invalid integration id.'); - } - - const { id } = queryParams; - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields: projection, query } = await this.parseJsonQuery(); - const ourQuery = Object.assign(await mountIntegrationHistoryQueryBasedOnPermissions(userId, id), query); - - const { cursor, totalCount } = IntegrationHistory.findPaginated(ourQuery, { - sort: sort || { _updatedAt: -1 }, - skip: offset, - limit: count, - projection, - }); - - const [history, total] = await Promise.all([cursor.toArray(), totalCount]); - - return API.v1.success({ - history, - offset, - items: history.length, - count: history.length, - total, - }); + response: { + 200: ajv.compile<{ history: IIntegrationHistory[]; offset: number; items: number; count: number; total: number }>({ + type: 'object', + properties: { + history: { type: 'array', items: { type: 'object' } }, + offset: { type: 'number' }, + items: { type: 'number' }, + count: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['history', 'offset', 'items', 'count', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const { userId, queryParams } = this; + + if (!queryParams.id || queryParams.id.trim() === '') { + return API.v1.failure('Invalid integration id.'); + } + + const { id } = queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields: projection, query } = await this.parseJsonQuery(); + const ourQuery = Object.assign(await mountIntegrationHistoryQueryBasedOnPermissions(userId, id), query); + + const { cursor, totalCount } = IntegrationHistory.findPaginated(ourQuery, { + sort: sort || { _updatedAt: -1 }, + skip: offset, + limit: count, + projection, + }); + + const [history, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + history, + offset, + items: history.length, + count: history.length, + total, + }); + }, ); -API.v1.addRoute( +API.v1.get( 'integrations.list', { authRequired: true, - validateParams: isIntegrationsListProps, + query: isIntegrationsListProps, permissionsRequired: { GET: { permissions: [ @@ -101,46 +141,65 @@ API.v1.addRoute( operation: 'hasAny', }, }, - }, - { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); - const { name, type } = this.queryParams; - - const filter = { - ...query, - ...(name ? { name: { $regex: escapeRegExp(name as string), $options: 'i' } } : {}), - ...(type ? { type } : {}), - }; - - const ourQuery = Object.assign(await mountIntegrationQueryBasedOnPermissions(this.userId), filter) as Filter; - - const { cursor, totalCount } = Integrations.findPaginated(ourQuery, { - sort: sort || { ts: -1 }, - skip: offset, - limit: count, - projection: fields, - }); - - const [integrations, total] = await Promise.all([cursor.toArray(), totalCount]); - - return API.v1.success({ - integrations, - offset, - items: integrations.length, - count: integrations.length, - total, - }); + response: { + 200: ajv.compile<{ integrations: IIntegration[]; offset: number; items: number; count: number; total: number }>({ + type: 'object', + properties: { + integrations: { + type: 'array', + items: { type: 'object' }, + }, + offset: { type: 'number' }, + items: { type: 'number' }, + count: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['integrations', 'offset', 'items', 'count', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + const { name, type } = this.queryParams; + + const filter = { + ...query, + ...(name ? { name: { $regex: escapeRegExp(name), $options: 'i' } } : {}), + ...(type ? { type } : {}), + }; + + const ourQuery = Object.assign(await mountIntegrationQueryBasedOnPermissions(this.userId), filter) as Filter; + + const { cursor, totalCount } = Integrations.findPaginated(ourQuery, { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [integrations, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + integrations, + offset, + items: integrations.length, + count: integrations.length, + total, + }); + }, ); -API.v1.addRoute( +API.v1.post( 'integrations.remove', { authRequired: true, - validateParams: isIntegrationsRemoveProps, + body: isIntegrationsRemoveProps, permissionsRequired: { POST: { permissions: [ @@ -152,123 +211,151 @@ API.v1.addRoute( operation: 'hasAny', }, }, + response: { + 200: integrationSuccessSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { bodyParams } = this; - - let integration: IIntegration | null = null; - switch (bodyParams.type) { - case 'webhook-outgoing': - if (!bodyParams.target_url && !bodyParams.integrationId) { - return API.v1.failure('An integrationId or target_url needs to be provided.'); - } - - if (bodyParams.target_url) { - integration = await Integrations.findOne({ urls: bodyParams.target_url }); - } else if (bodyParams.integrationId) { - integration = await Integrations.findOne({ _id: bodyParams.integrationId }); - } - - if (!integration) { - return API.v1.failure('No integration found.'); - } - - const outgoingId = integration._id; - - await deleteOutgoingIntegration(outgoingId, this.userId); - - return API.v1.success({ - integration, - }); - case 'webhook-incoming': - check( - bodyParams, - Match.ObjectIncluding({ - integrationId: String, - }), - ); - + async function action() { + const { bodyParams } = this; + + let integration: IIntegration | null = null; + switch (bodyParams.type) { + case 'webhook-outgoing': + if (!bodyParams.target_url && !bodyParams.integrationId) { + return API.v1.failure('An integrationId or target_url needs to be provided.'); + } + + if (bodyParams.target_url) { + integration = await Integrations.findOne({ urls: bodyParams.target_url }); + } else if (bodyParams.integrationId) { integration = await Integrations.findOne({ _id: bodyParams.integrationId }); - - if (!integration) { - return API.v1.failure('No integration found.'); - } - - const incomingId = integration._id; - await deleteIncomingIntegration(incomingId, this.userId); - - return API.v1.success({ - integration, - }); - default: - return API.v1.failure('Invalid integration type.'); - } - }, + } + + if (!integration) { + return API.v1.failure('No integration found.'); + } + + const outgoingId = integration._id; + + await deleteOutgoingIntegration(outgoingId, this.userId); + + return API.v1.success({ + integration, + }); + case 'webhook-incoming': + check( + bodyParams, + Match.ObjectIncluding({ + integrationId: String, + }), + ); + + integration = await Integrations.findOne({ _id: bodyParams.integrationId }); + + if (!integration) { + return API.v1.failure('No integration found.'); + } + + const incomingId = integration._id; + await deleteIncomingIntegration(incomingId, this.userId); + + return API.v1.success({ + integration, + }); + default: + return API.v1.failure('Invalid integration type.'); + } }, ); -API.v1.addRoute( +API.v1.get( 'integrations.get', - { authRequired: true, validateParams: isIntegrationsGetProps }, { - async get() { - const { integrationId, createdBy } = this.queryParams; - if (!integrationId) { - return API.v1.failure('The query parameter "integrationId" is required.'); - } - - return API.v1.success({ - integration: await findOneIntegration({ - userId: this.userId, - integrationId, - createdBy, - }), - }); + authRequired: true, + query: isIntegrationsGetProps, + response: { + 200: ajv.compile<{ integration: IIntegration | null }>({ + type: 'object', + properties: { + integration: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['integration', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { integrationId, createdBy } = this.queryParams; + if (!integrationId) { + return API.v1.failure('The query parameter "integrationId" is required.'); + } + + return API.v1.success({ + integration: await findOneIntegration({ + userId: this.userId, + integrationId, + createdBy, + }), + }); + }, ); -API.v1.addRoute( +API.v1.put( 'integrations.update', - { authRequired: true, validateParams: isIntegrationsUpdateProps }, { - async put() { - const { bodyParams } = this; - - let integration; - switch (bodyParams.type) { - case 'webhook-outgoing': - if (bodyParams.target_url) { - integration = await Integrations.findOne({ urls: bodyParams.target_url }); - } else if (bodyParams.integrationId) { - integration = await Integrations.findOne({ _id: bodyParams.integrationId }); - } - - if (!integration) { - return API.v1.failure('No integration found.'); - } - - await updateOutgoingIntegration(this.userId, integration._id, bodyParams); - - return API.v1.success({ - integration: await Integrations.findOne({ _id: integration._id }), - }); - case 'webhook-incoming': - integration = await Integrations.findOne({ _id: bodyParams.integrationId }); - - if (!integration) { - return API.v1.failure('No integration found.'); - } - - await updateIncomingIntegration(this.userId, integration._id, bodyParams); - - return API.v1.success({ - integration: await Integrations.findOne({ _id: integration._id }), - }); - default: - return API.v1.failure('Invalid integration type.'); - } + authRequired: true, + body: isIntegrationsUpdateProps, + response: { + 200: integrationSuccessSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { bodyParams } = this; + + let integration: IIntegration | null = null; + switch (bodyParams.type) { + case 'webhook-outgoing': + if (bodyParams.target_url) { + integration = await Integrations.findOne({ urls: bodyParams.target_url }); + } else if (bodyParams.integrationId) { + integration = await Integrations.findOne({ _id: bodyParams.integrationId }); + } + + if (!integration) { + return API.v1.failure('No integration found.'); + } + + await updateOutgoingIntegration(this.userId, integration._id, bodyParams as Parameters[2]); + + return API.v1.success({ + integration: await Integrations.findOne({ _id: integration._id }), + }); + case 'webhook-incoming': + integration = await Integrations.findOne({ _id: bodyParams.integrationId }); + + if (!integration) { + return API.v1.failure('No integration found.'); + } + + await updateIncomingIntegration( + this.userId, + integration._id, + bodyParams as unknown as Parameters[2], + ); + + return API.v1.success({ + integration: await Integrations.findOne({ _id: integration._id }), + }); + default: + return API.v1.failure('Invalid integration type.'); + } + }, ); diff --git a/apps/meteor/app/api/server/v1/invites.ts b/apps/meteor/app/api/server/v1/invites.ts index 637da81892c03..1c4b6ed4433ee 100644 --- a/apps/meteor/app/api/server/v1/invites.ts +++ b/apps/meteor/app/api/server/v1/invites.ts @@ -5,6 +5,7 @@ import { isUseInviteTokenProps, isValidateInviteTokenProps, isSendInvitationEmailParams, + validateBadRequestErrorResponse, } from '@rocket.chat/rest-typings'; import { findOrCreateInvite } from '../../../invites/server/functions/findOrCreateInvite'; @@ -170,43 +171,89 @@ const invites = API.v1 return API.v1.success((await findOrCreateInvite(this.userId, { rid, days, maxUses })) as IInvite); }, - ); - -API.v1.addRoute( - 'removeInvite/:_id', - { authRequired: true }, - { - async delete() { + ) + .delete( + 'removeInvite/:_id', + { + authRequired: true, + response: { + 200: ajv.compile({ + type: 'boolean', + enum: [true], + }), + 400: validateBadRequestErrorResponse, + 401: ajv.compile({ + type: 'object', + properties: { error: { type: 'string' }, success: { type: 'boolean', enum: [false] } }, + required: ['success', 'error'], + additionalProperties: false, + }), + }, + }, + async function action() { const { _id } = this.urlParams; return API.v1.success(await removeInvite(this.userId, { _id })); }, - }, -); - -API.v1.addRoute( - 'useInviteToken', - { - authRequired: true, - validateParams: isUseInviteTokenProps, - }, - { - async post() { + ) + .post( + 'useInviteToken', + { + 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, + }), + 400: validateBadRequestErrorResponse, + 401: ajv.compile({ + type: 'object', + properties: { error: { type: 'string' }, success: { type: 'boolean', enum: [false] } }, + required: ['success', 'error'], + additionalProperties: false, + }), + }, + }, + async function action() { const { token } = this.bodyParams; - // eslint-disable-next-line react-hooks/rules-of-hooks return API.v1.success(await useInviteToken(this.userId, token)); }, - }, -); - -API.v1.addRoute( - 'validateInviteToken', - { - authRequired: false, - validateParams: isValidateInviteTokenProps, - }, - { - async post() { + ) + .post( + 'validateInviteToken', + { + authRequired: false, + body: isValidateInviteTokenProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + valid: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['valid', 'success'], + additionalProperties: false, + }), + }, + }, + async function action() { const { token } = this.bodyParams; try { return API.v1.success({ valid: Boolean(await validateInviteToken(token)) }); @@ -214,26 +261,42 @@ API.v1.addRoute( return API.v1.success({ valid: false }); } }, - }, -); - -API.v1.addRoute( - 'sendInvitationEmail', - { - authRequired: true, - validateParams: isSendInvitationEmailParams, - }, - { - async post() { + ) + .post( + 'sendInvitationEmail', + { + 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, + }), + }, + }, + async function action() { const { emails } = this.bodyParams; try { return API.v1.success({ success: Boolean(await sendInvitationEmail(this.userId, emails)) }); - } catch (e: any) { - return API.v1.failure({ error: e.message }); + } catch (e: unknown) { + return API.v1.failure({ error: e instanceof Error ? e.message : String(e) }); } }, - }, -); + ); type InvitesEndpoints = ExtractRoutesFromAPI; diff --git a/apps/meteor/app/api/server/v1/ldap.ts b/apps/meteor/app/api/server/v1/ldap.ts index 3f9a2c29deded..efbf9a898d02c 100644 --- a/apps/meteor/app/api/server/v1/ldap.ts +++ b/apps/meteor/app/api/server/v1/ldap.ts @@ -1,62 +1,86 @@ import { LDAP } from '@rocket.chat/core-services'; -import { Match, check } from 'meteor/check'; +import { ajv, isLdapTestSearch, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse } from '@rocket.chat/rest-typings'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { settings } from '../../../settings/server'; import { API } from '../api'; -API.v1.addRoute( +const messageResponseSchema = { + type: 'object' as const, + properties: { + message: { type: 'string' as const }, + success: { + type: 'boolean' as const, + enum: [true] as const, + }, + }, + required: ['message', 'success'] as const, + additionalProperties: false, +}; + +API.v1.post( 'ldap.testConnection', - { authRequired: true, permissionsRequired: ['test-admin-options'] }, { - async post() { - if (!this.userId) { - throw new Error('error-invalid-user'); - } - - if (settings.get('LDAP_Enable') !== true) { - throw new Error('LDAP_disabled'); - } - - try { - await LDAP.testConnection(); - } catch (err) { - SystemLogger.error({ err }); - throw new Error('Connection_failed'); - } - - return API.v1.success({ - message: 'LDAP_Connection_successful' as const, - }); + authRequired: true, + permissionsRequired: ['test-admin-options'], + response: { + 200: ajv.compile<{ message: string; success: true }>(messageResponseSchema), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + if (!this.userId) { + throw new Error('error-invalid-user'); + } + + if (settings.get('LDAP_Enable') !== true) { + throw new Error('LDAP_disabled'); + } + + try { + await LDAP.testConnection(); + } catch (err) { + SystemLogger.error({ err }); + throw new Error('Connection_failed'); + } + + return API.v1.success({ + message: 'LDAP_Connection_successful' as const, + }); + }, ); -API.v1.addRoute( +API.v1.post( 'ldap.testSearch', - { authRequired: true, permissionsRequired: ['test-admin-options'] }, { - async post() { - check( - this.bodyParams, - Match.ObjectIncluding({ - username: String, - }), - ); - - if (!this.userId) { - throw new Error('error-invalid-user'); - } - - if (settings.get('LDAP_Enable') !== true) { - throw new Error('LDAP_disabled'); - } + authRequired: true, + permissionsRequired: ['test-admin-options'], + body: isLdapTestSearch, + response: { + 200: ajv.compile<{ message: string; success: true }>(messageResponseSchema), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + if (!this.userId) { + throw new Error('error-invalid-user'); + } + if (settings.get('LDAP_Enable') !== true) { + throw new Error('LDAP_disabled'); + } + + try { await LDAP.testSearch(this.bodyParams.username); + } catch (err) { + SystemLogger.error({ err }); + throw new Error('LDAP_search_failed'); + } - return API.v1.success({ - message: 'LDAP_User_Found' as const, - }); - }, + return API.v1.success({ + message: 'LDAP_User_Found' as const, + }); }, ); diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index c679a42582924..1b24c8cde425f 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -1,15 +1,16 @@ import crypto from 'crypto'; -import type { IUser } from '@rocket.chat/core-typings'; +import type { IDirectoryChannelResult, IDirectoryUserResult, IRoom, IUser } from '@rocket.chat/core-typings'; import { Settings, Users, WorkspaceCredentials } from '@rocket.chat/models'; import { + ajv, isShieldSvgProps, isSpotlightProps, isDirectoryProps, - isMethodCallProps, - isMethodCallAnonProps, isFingerprintProps, isMeteorCall, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, } from '@rocket.chat/rest-typings'; import { escapeHTML } from '@rocket.chat/string-helpers'; import EJSON from 'ejson'; @@ -29,7 +30,6 @@ import { getBaseUserFields } from '../../../utils/server/functions/getBaseUserFi import { isSMTPConfigured } from '../../../utils/server/functions/isSMTPConfigured'; import { getURL } from '../../../utils/server/getURL'; import { API } from '../api'; -import { getLoggedInUser } from '../helpers/getLoggedInUser'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { getUserFromParams } from '../helpers/getUserFromParams'; import { getUserInfo } from '../helpers/getUserInfo'; @@ -170,17 +170,27 @@ import { getUserInfo } from '../helpers/getUserInfo'; * schema: * $ref: '#/components/schemas/ApiFailureV1' */ -API.v1.addRoute( +const meResponseSchema = ajv.compile>({ + type: 'object', + additionalProperties: true, +}); + +API.v1.get( 'me', - { authRequired: true }, { - async get() { - const userFields = { ...getBaseUserFields(), services: 1 }; - const user = (await Users.findOneById(this.userId, { projection: userFields })) as IUser; - - return API.v1.success(await getUserInfo(user)); + authRequired: true, + userWithoutUsername: true, + response: { + 200: meResponseSchema, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + 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); + }, ); let onlineCache = 0; @@ -244,7 +254,7 @@ API.v1.addRoute( text = `#${channel}`; break; case 'user': - if (settings.get('API_Shield_user_require_auth') && !(await getLoggedInUser(this.request))) { + 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); @@ -320,83 +330,164 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +const spotlightResponseSchema = ajv.compile<{ + users: Pick[]; + rooms: Pick[]; +}>({ + type: 'object', + properties: { + users: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + name: { type: 'string' }, + username: { type: 'string' }, + status: { type: 'string' }, + statusText: { type: 'string' }, + avatarETag: { type: 'string' }, + }, + required: ['_id', 'name', 'username', 'status'], + additionalProperties: true, + }, + }, + rooms: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + t: { type: 'string' }, + name: { type: 'string' }, + lastMessage: { type: 'object' }, + }, + required: ['_id', 't', 'name'], + additionalProperties: true, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['users', 'rooms', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'spotlight', { authRequired: true, - validateParams: isSpotlightProps, + query: isSpotlightProps, + response: { + 200: spotlightResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async get() { - const { query } = this.queryParams; + async function action() { + const { query } = this.queryParams; - const result = await spotlightMethod({ text: query, userId: this.userId }); + const result = await spotlightMethod({ text: query, userId: this.userId }); - return API.v1.success(result); - }, + return API.v1.success(result); }, ); -API.v1.addRoute( +const directoryResponseSchema = ajv.compile<{ + result: (IDirectoryUserResult | IDirectoryChannelResult)[]; + count: number; + offset: number; + total: number; +}>({ + type: 'object', + properties: { + result: { + type: 'array', + items: { + oneOf: [{ $ref: '#/components/schemas/IDirectoryUserResult' }, { $ref: '#/components/schemas/IDirectoryChannelResult' }], + }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['result', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'directory', { authRequired: true, - validateParams: isDirectoryProps, + query: isDirectoryProps, + response: { + 200: directoryResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, query } = await this.parseJsonQuery(); - const { text, type, workspace = 'local' } = this.queryParams; - - const filter = { - ...(query ? { ...query } : {}), - ...(text ? { text } : {}), - ...(type ? { type } : {}), - ...(workspace ? { workspace } : {}), - }; - - if (sort && Object.keys(sort).length > 1) { - return API.v1.failure('This method support only one "sort" parameter'); - } - const sortBy = sort ? Object.keys(sort)[0] : undefined; - const sortDirection = sort && Object.values(sort)[0] === 1 ? 'asc' : 'desc'; - - const user = await Users.findOneById(this.userId, { projection: { __rooms: 1 } }); - const result = await browseChannelsMethod( - { - ...filter, - sortBy, - sortDirection, - offset: Math.max(0, offset), - limit: Math.max(0, count), - }, - user, - ); + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, query } = await this.parseJsonQuery(); + const { text, type, workspace = 'local' } = this.queryParams; + + const filter = { + ...(query ? { ...query } : {}), + ...(text ? { text } : {}), + ...(type ? { type } : {}), + ...(workspace ? { workspace } : {}), + }; - if (!result) { - return API.v1.failure('Please verify the parameters'); - } - return API.v1.success({ - result: result.results, - count: result.results.length, - offset, - total: result.total, - }); - }, + if (sort && Object.keys(sort).length > 1) { + return API.v1.failure('This method support only one "sort" parameter'); + } + const sortBy = sort ? Object.keys(sort)[0] : undefined; + const sortDirection = sort && Object.values(sort)[0] === 1 ? 'asc' : 'desc'; + + const user = await Users.findOneById(this.userId, { projection: { __rooms: 1 } }); + const result = await browseChannelsMethod( + { + ...filter, + sortBy, + sortDirection, + offset: Math.max(0, offset), + limit: Math.max(0, count), + }, + user, + ); + + if (!result) { + return API.v1.failure('Please verify the parameters'); + } + return API.v1.success({ + result: result.results as (IDirectoryUserResult | IDirectoryChannelResult)[], + count: result.results.length, + offset, + total: result.total, + }); }, ); -API.v1.addRoute( +const pwGetPolicyResponseSchema = ajv.compile<{ enabled: boolean; policy: [string, Record?][] }>({ + type: 'object', + properties: { + enabled: { type: 'boolean' }, + policy: { type: 'array', items: { type: 'array' } }, + }, + additionalProperties: true, +}); + +API.v1.get( 'pw.getPolicy', { authRequired: false, - }, - { - get() { - return API.v1.success(passwordPolicy.getPasswordPolicy()); + response: { + 200: pwGetPolicyResponseSchema, }, }, + function action() { + return API.v1.success(passwordPolicy.getPasswordPolicy()); + }, ); /** @@ -448,6 +539,38 @@ declare module '@rocket.chat/rest-typings' { } } +const methodCallResponseSchema = ajv.compile<{ message: string }>({ + type: 'object', + properties: { message: { type: 'string' }, success: { type: 'boolean', enum: [true] } }, + required: ['message'], + additionalProperties: false, +}); + +const methodCallErrorResponseSchema = ajv.compile<{ message: string }>({ + type: 'object', + oneOf: [ + { + properties: { + message: { type: 'string' }, + success: { type: 'boolean', enum: [false] }, + }, + required: ['message', 'success'], + additionalProperties: false, + }, + { + properties: { + success: { type: 'boolean', enum: [false] }, + error: { type: 'string' }, + errorType: { type: 'string' }, + stack: { type: 'string' }, + details: { anyOf: [{ type: 'string' }, { type: 'object' }] }, + }, + required: ['success'], + additionalProperties: false, + }, + ], +}); + const mountResult = ({ id, error, @@ -469,134 +592,165 @@ const mountResult = ({ // had to create two different endpoints for authenticated and non-authenticated calls // because restivus does not provide 'this.userId' if 'authRequired: false' -API.v1.addRoute( +API.v1.post( 'method.call/:method', { authRequired: true, + userWithoutUsername: true, rateLimiterOptions: false, - validateParams: isMeteorCall, + body: isMeteorCall, applyMeteorContext: true, + response: { + 200: methodCallResponseSchema, + 400: methodCallErrorResponseSchema, + 401: validateUnauthorizedErrorResponse, + 429: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [false] }, error: { type: 'string' } }, + required: ['success'], + additionalProperties: true, + }), + }, }, - { - async post() { - check(this.bodyParams, { - message: String, - }); + async function action() { + check(this.bodyParams, { + message: String, + }); + + const data = EJSON.parse(this.bodyParams.message); + + const { method, params, id } = data; + + const connectionId = + this.token || + crypto + .createHash('md5') + .update((this.requestIp ?? '') + this.user._id) + .digest('hex'); + + const rateLimiterInput = { + userId: this.userId, + clientAddress: this.requestIp, + type: 'method', + name: method, + connectionId, + }; - const data = EJSON.parse(this.bodyParams.message); + try { + DDPRateLimiter._increment(rateLimiterInput); + const rateLimitResult = DDPRateLimiter._check(rateLimiterInput); + if (!rateLimitResult.allowed) { + throw new Meteor.Error('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult), { + timeToReset: rateLimitResult.timeToReset, + }); + } - if (!isMethodCallProps(data)) { - return API.v1.failure('Invalid method call'); + return API.v1.success(mountResult({ id, result: await Meteor.callAsync(method, ...params) })); + } catch (err) { + if (!(err as any).isClientSafe && !(err as any).meteorError) { + SystemLogger.error({ msg: 'Exception while invoking method', err, method }); } - const { method, params, id } = data; - - const connectionId = - this.token || - crypto - .createHash('md5') - .update(this.requestIp + this.user._id) - .digest('hex'); - - const rateLimiterInput = { - userId: this.userId, - clientAddress: this.requestIp, - type: 'method', - name: method, - connectionId, - }; - - try { - DDPRateLimiter._increment(rateLimiterInput); - const rateLimitResult = DDPRateLimiter._check(rateLimiterInput); - if (!rateLimitResult.allowed) { - throw new Meteor.Error('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult), { - timeToReset: rateLimitResult.timeToReset, - }); - } - - return API.v1.success(mountResult({ id, result: await Meteor.callAsync(method, ...params) })); - } catch (err) { - if (!(err as any).isClientSafe && !(err as any).meteorError) { - SystemLogger.error({ msg: 'Exception while invoking method', err, method }); - } - - if (settings.get('Log_Level') === '2') { - Meteor._debug(`Exception while invoking method ${method}`, err); - } - - return API.v1.failure(mountResult({ id, error: err })); + if (settings.get('Log_Level') === '2') { + Meteor._debug(`Exception while invoking method ${method}`, err); } - }, + + return API.v1.failure(mountResult({ id, error: err })); + } }, ); -API.v1.addRoute( +API.v1.post( 'method.callAnon/:method', { authRequired: false, + userWithoutUsername: true, rateLimiterOptions: false, - validateParams: isMeteorCall, + body: isMeteorCall, applyMeteorContext: true, + response: { + 200: methodCallResponseSchema, + 400: methodCallErrorResponseSchema, + }, }, - { - async post() { - check(this.bodyParams, { - message: String, - }); + async function action() { + check(this.bodyParams, { + message: String, + }); + + const data = EJSON.parse(this.bodyParams.message); + + const { method, params, id } = data; + + const connectionId = + this.token || + crypto + .createHash('md5') + .update(this.requestIp ?? '') + .digest('hex'); + + const rateLimiterInput = { + userId: this.userId || undefined, + clientAddress: this.requestIp, + type: 'method', + name: method, + connectionId, + }; - const data = EJSON.parse(this.bodyParams.message); + try { + DDPRateLimiter._increment(rateLimiterInput); - if (!isMethodCallAnonProps(data)) { - return API.v1.failure('Invalid method call'); + const rateLimitResult = DDPRateLimiter._check(rateLimiterInput); + if (!rateLimitResult.allowed) { + throw new Meteor.Error('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult), { + timeToReset: rateLimitResult.timeToReset, + }); } - const { method, params, id } = data; - - const connectionId = this.token || crypto.createHash('md5').update(this.requestIp).digest('hex'); - - const rateLimiterInput = { - userId: this.userId || undefined, - clientAddress: this.requestIp, - type: 'method', - name: method, - connectionId, - }; - - try { - DDPRateLimiter._increment(rateLimiterInput); - - const rateLimitResult = DDPRateLimiter._check(rateLimiterInput); - if (!rateLimitResult.allowed) { - throw new Meteor.Error('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult), { - timeToReset: rateLimitResult.timeToReset, - }); - } - - return API.v1.success(mountResult({ id, result: await Meteor.callAsync(method, ...params) })); - } catch (err) { - if (!(err as any).isClientSafe && !(err as any).meteorError) { - SystemLogger.error({ msg: 'Exception while invoking method', err, method }); - } - if (settings.get('Log_Level') === '2') { - Meteor._debug(`Exception while invoking method ${method}`, err); - } - return API.v1.failure(mountResult({ id, error: err })); + return API.v1.success(mountResult({ id, result: await Meteor.callAsync(method, ...params) })); + } catch (err) { + if (!(err as any).isClientSafe && !(err as any).meteorError) { + SystemLogger.error({ msg: 'Exception while invoking method', err, method }); } - }, + if (settings.get('Log_Level') === '2') { + Meteor._debug(`Exception while invoking method ${method}`, err); + } + return API.v1.failure(mountResult({ id, error: err })); + } }, ); -API.v1.addRoute( +const smtpCheckResponseSchema = ajv.compile<{ isSMTPConfigured: boolean }>({ + type: 'object', + properties: { + isSMTPConfigured: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['isSMTPConfigured', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'smtp.check', - { authRequired: true }, { - async get() { - return API.v1.success({ isSMTPConfigured: isSMTPConfigured() }); + authRequired: true, + response: { + 200: smtpCheckResponseSchema, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + return API.v1.success({ isSMTPConfigured: isSMTPConfigured() }); + }, ); +const fingerprintResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + /** * @openapi * /api/v1/fingerprint: @@ -630,75 +784,78 @@ API.v1.addRoute( * schema: * $ref: '#/components/schemas/ApiFailureV1' */ -API.v1.addRoute( +API.v1.post( 'fingerprint', { authRequired: true, - validateParams: isFingerprintProps, + body: isFingerprintProps, + response: { + 200: fingerprintResponseSchema, + 401: validateUnauthorizedErrorResponse, + 400: validateBadRequestErrorResponse, + }, }, - { - async post() { - check(this.bodyParams, { - setDeploymentAs: String, - }); - - const settingsIds: string[] = []; - - if (this.bodyParams.setDeploymentAs === 'new-workspace') { - await WorkspaceCredentials.removeAllCredentials(); - - settingsIds.push( - 'Cloud_Service_Agree_PrivacyTerms', - 'Cloud_Workspace_Id', - 'Cloud_Workspace_Name', - 'Cloud_Workspace_Client_Id', - 'Cloud_Workspace_Client_Secret', - 'Cloud_Workspace_Client_Secret_Expires_At', - 'Cloud_Workspace_Registration_Client_Uri', - 'Cloud_Workspace_PublicKey', - 'Cloud_Workspace_License', - 'Cloud_Workspace_Had_Trial', - 'uniqueID', - ); + async function action() { + check(this.bodyParams, { + setDeploymentAs: String, + }); + + const settingsIds: string[] = []; + + if (this.bodyParams.setDeploymentAs === 'new-workspace') { + await WorkspaceCredentials.removeAllCredentials(); + + settingsIds.push( + 'Cloud_Service_Agree_PrivacyTerms', + 'Cloud_Workspace_Id', + 'Cloud_Workspace_Name', + 'Cloud_Workspace_Client_Id', + 'Cloud_Workspace_Client_Secret', + 'Cloud_Workspace_Client_Secret_Expires_At', + 'Cloud_Workspace_Registration_Client_Uri', + 'Cloud_Workspace_PublicKey', + 'Cloud_Workspace_License', + 'Cloud_Workspace_Had_Trial', + 'uniqueID', + ); + } + + settingsIds.push('Deployment_FingerPrint_Verified'); + + const auditSettingOperation = updateAuditedByUser({ + _id: this.userId, + username: this.user.username ?? '', + ip: this.requestIp ?? '', + useragent: this.request.headers.get('user-agent') ?? '', + }); + + const promises = settingsIds.map((settingId) => { + if (settingId === 'uniqueID') { + return auditSettingOperation(Settings.resetValueById, 'uniqueID', process.env.DEPLOYMENT_ID || crypto.randomUUID()); + } + + if (settingId === 'Cloud_Workspace_Access_Token_Expires_At') { + return auditSettingOperation(Settings.resetValueById, 'Cloud_Workspace_Access_Token_Expires_At', new Date(0)); } - settingsIds.push('Deployment_FingerPrint_Verified'); + if (settingId === 'Deployment_FingerPrint_Verified') { + return auditSettingOperation(Settings.updateValueById, 'Deployment_FingerPrint_Verified', true); + } - const auditSettingOperation = updateAuditedByUser({ + return resetAuditedSettingByUser({ _id: this.userId, - username: this.user.username!, - ip: this.requestIp, - useragent: this.request.headers.get('user-agent') || '', - }); - - const promises = settingsIds.map((settingId) => { - if (settingId === 'uniqueID') { - return auditSettingOperation(Settings.resetValueById, 'uniqueID', process.env.DEPLOYMENT_ID || crypto.randomUUID()); - } - - if (settingId === 'Cloud_Workspace_Access_Token_Expires_At') { - return auditSettingOperation(Settings.resetValueById, 'Cloud_Workspace_Access_Token_Expires_At', new Date(0)); - } - - if (settingId === 'Deployment_FingerPrint_Verified') { - return auditSettingOperation(Settings.updateValueById, 'Deployment_FingerPrint_Verified', true); - } - - return resetAuditedSettingByUser({ - _id: this.userId, - username: this.user.username!, - ip: this.requestIp, - useragent: this.request.headers.get('user-agent') || '', - })(Settings.resetValueById, settingId); - }); - - (await Promise.all(promises)).forEach((value, index) => { - if (value?.modifiedCount) { - void notifyOnSettingChangedById(settingsIds[index]); - } - }); - - return API.v1.success({}); - }, + username: this.user.username ?? '', + ip: this.requestIp ?? '', + useragent: this.request.headers.get('user-agent') ?? '', + })(Settings.resetValueById, settingId); + }); + + (await Promise.all(promises)).forEach((value, index) => { + if (value?.modifiedCount) { + void notifyOnSettingChangedById(settingsIds[index]); + } + }); + + return API.v1.success(); }, ); diff --git a/apps/meteor/app/api/server/v1/moderation.ts b/apps/meteor/app/api/server/v1/moderation.ts index d04259b123e22..5651d9ea983df 100644 --- a/apps/meteor/app/api/server/v1/moderation.ts +++ b/apps/meteor/app/api/server/v1/moderation.ts @@ -1,6 +1,7 @@ -import type { IModerationReport, IUser, IUserEmail } from '@rocket.chat/core-typings'; +import type { IModerationAudit, IModerationReport, IUser, IUserEmail, UserReport } from '@rocket.chat/core-typings'; import { ModerationReports, Users } from '@rocket.chat/models'; import { + ajv, isReportHistoryProps, isArchiveReportProps, isReportInfoParams, @@ -8,6 +9,9 @@ import { isModerationReportUserPost, isModerationDeleteMsgHistoryParams, isReportsByMsgIdParams, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateBadRequestErrorResponse, } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; @@ -17,395 +21,601 @@ import { getPaginationItems } from '../helpers/getPaginationItems'; type ReportMessage = Pick; -API.v1.addRoute( +// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema. +// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time") +// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1". +// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB +// 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 paginatedReportsResponseSchema = ajv.compile<{ reports: IModerationAudit[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + reports: { + type: 'array', + items: { + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + name: { type: 'string' }, + message: { type: 'string' }, + msgId: { type: 'string' }, + ts: { type: 'string' }, + rooms: { type: 'array', items: { type: 'object' } }, + roomIds: { type: 'array', items: { type: 'string' } }, + count: { type: 'number' }, + isUserDeleted: { type: 'boolean' }, + }, + required: ['userId', 'ts', 'rooms', 'roomIds', 'count', 'isUserDeleted'], + additionalProperties: false, + }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['reports', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema. +// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time") +// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1". +// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB +// 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 paginatedUserReportsResponseSchema = ajv.compile<{ + reports: (Pick & { count: number })[]; + count: number; + offset: number; + total: number; +}>({ + type: 'object', + properties: { + reports: { + type: 'array', + items: { + type: 'object', + properties: { + reportedUser: { type: 'object' }, + ts: { type: 'string' }, + count: { type: 'number' }, + }, + required: ['reportedUser', 'ts', 'count'], + additionalProperties: false, + }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['reports', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +const reportedMessagesResponseSchema = ajv.compile<{ + user: Pick | null; + messages: Pick[]; + count: number; + total: number; + offset: number; +}>({ + type: 'object', + properties: { + user: { type: ['object', 'null'] }, + messages: { type: 'array', items: { type: 'object' } }, + count: { type: 'number' }, + total: { type: 'number' }, + offset: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['user', 'messages', 'count', 'total', 'offset', 'success'], + additionalProperties: false, +}); + +// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema. +// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time") +// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1". +// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB +// 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; + reports: IModerationReport[]; + count: number; + total: number; + offset: number; +}>({ + type: 'object', + properties: { + user: { type: ['object', 'null'] }, + reports: { type: 'array', items: { type: 'object' } }, + count: { type: 'number' }, + total: { type: 'number' }, + offset: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['user', 'reports', 'count', 'total', 'offset', 'success'], + additionalProperties: false, +}); + +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema. +// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time") +// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1". +// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB +// 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 reportInfoResponseSchema = ajv.compile<{ report: IModerationReport | null }>({ + type: 'object', + properties: { + report: { type: ['object', 'null'] }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['report', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'moderation.reportsByUsers', { authRequired: true, - validateParams: isReportHistoryProps, permissionsRequired: ['view-moderation-console'], + query: isReportHistoryProps, + response: { + 200: paginatedReportsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async get() { - const { latest: _latest, oldest: _oldest, selector = '' } = this.queryParams; + async function action() { + const { latest: _latest, oldest: _oldest, selector = '' } = this.queryParams; - const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams); - const { sort } = await this.parseJsonQuery(); + const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); - const latest = _latest ? new Date(_latest) : new Date(); - const oldest = _oldest ? new Date(_oldest) : new Date(0); + const latest = _latest ? new Date(_latest) : new Date(); + const oldest = _oldest ? new Date(_oldest) : new Date(0); - const escapedSelector = escapeRegExp(selector); + const escapedSelector = escapeRegExp(selector); - const reports = await ModerationReports.findMessageReportsGroupedByUser(latest, oldest, escapedSelector, { - offset, - count, - sort, - }).toArray(); - - if (reports.length === 0) { - return API.v1.success({ - reports, - count: 0, - offset, - total: 0, - }); - } - - const total = await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapedSelector, true); + const reports = await ModerationReports.findMessageReportsGroupedByUser(latest, oldest, escapedSelector, { + offset, + count, + sort, + }).toArray(); + if (reports.length === 0) { return API.v1.success({ reports, - count: reports.length, + count: 0, offset, - total, + total: 0, }); - }, + } + + const total = await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapedSelector, true); + + return API.v1.success({ + reports, + count: reports.length, + offset, + total, + }); }, ); -API.v1.addRoute( +API.v1.get( 'moderation.userReports', { authRequired: true, - validateParams: isReportHistoryProps, permissionsRequired: ['view-moderation-console'], + query: isReportHistoryProps, + response: { + 200: paginatedUserReportsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async get() { - const { latest: _latest, oldest: _oldest, selector = '' } = this.queryParams; - - const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams); + async function action() { + const { latest: _latest, oldest: _oldest, selector = '' } = this.queryParams; - const { sort } = await this.parseJsonQuery(); + const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams); - const latest = _latest ? new Date(_latest) : new Date(); + const { sort } = await this.parseJsonQuery(); - const oldest = _oldest ? new Date(_oldest) : new Date(0); + const latest = _latest ? new Date(_latest) : new Date(); - const escapedSelector = escapeRegExp(selector); + const oldest = _oldest ? new Date(_oldest) : new Date(0); - const reports = await ModerationReports.findUserReports(latest, oldest, escapedSelector, { - offset, - count, - sort, - }).toArray(); - - if (reports.length === 0) { - return API.v1.success({ - reports, - count: 0, - offset, - total: 0, - }); - } + const escapedSelector = escapeRegExp(selector); - const total = await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapedSelector); + const reports = await ModerationReports.findUserReports(latest, oldest, escapedSelector, { + offset, + count, + sort, + }).toArray(); - const result = { + if (reports.length === 0) { + return API.v1.success({ reports, - count: reports.length, + count: 0, offset, - total, - }; - return API.v1.success(result); - }, + total: 0, + }); + } + + const total = await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapedSelector); + + return API.v1.success({ + reports, + count: reports.length, + offset, + total, + }); }, ); -API.v1.addRoute( +API.v1.get( 'moderation.user.reportedMessages', { authRequired: true, - validateParams: isGetUserReportsParams, permissionsRequired: ['view-moderation-console'], + query: isGetUserReportsParams, + response: { + 200: reportedMessagesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async get() { - const { userId, selector = '' } = this.queryParams; + async function action() { + const { userId, selector = '' } = this.queryParams; - const { sort } = await this.parseJsonQuery(); + const { sort } = await this.parseJsonQuery(); - const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams); + const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams); - const user = await Users.findOneById>(userId, { - projection: { _id: 1, username: 1, name: 1 }, - }); + const user = await Users.findOneById>(userId, { + projection: { _id: 1, username: 1, name: 1 }, + }); - const escapedSelector = escapeRegExp(selector); + const escapedSelector = escapeRegExp(selector); - const { cursor, totalCount } = ModerationReports.findReportedMessagesByReportedUserId(userId, escapedSelector, { - offset, - count, - sort, - }); + const { cursor, totalCount } = ModerationReports.findReportedMessagesByReportedUserId(userId, escapedSelector, { + offset, + count, + sort, + }); - const [reports, total] = await Promise.all([cursor.toArray(), totalCount]); + const [reports, total] = await Promise.all([cursor.toArray(), totalCount]); - const uniqueMessages: ReportMessage[] = []; - const visited = new Set(); - for (const report of reports) { - if (visited.has(report.message._id)) { - continue; - } - visited.add(report.message._id); - uniqueMessages.push(report); + const uniqueMessages: ReportMessage[] = []; + const visited = new Set(); + for (const report of reports) { + if (visited.has(report.message._id)) { + continue; } - - return API.v1.success({ - user, - messages: uniqueMessages, - count: reports.length, - total, - offset, - }); - }, + visited.add(report.message._id); + uniqueMessages.push(report); + } + + return API.v1.success({ + user, + messages: uniqueMessages, + count: reports.length, + total, + offset, + }); }, ); -API.v1.addRoute( +API.v1.get( 'moderation.user.reportsByUserId', { authRequired: true, - validateParams: isGetUserReportsParams, permissionsRequired: ['view-moderation-console'], + query: isGetUserReportsParams, + response: { + 200: reportsByUserIdResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async get() { - const { userId, selector = '' } = this.queryParams; - const { sort } = await this.parseJsonQuery(); - const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams); - - const user = await Users.findOneById(userId, { - projection: { - _id: 1, - username: 1, - name: 1, - avatarETag: 1, - active: 1, - roles: 1, - emails: 1, - createdAt: 1, - }, - }); - - const escapedSelector = escapeRegExp(selector); - const { cursor, totalCount } = ModerationReports.findUserReportsByReportedUserId(userId, escapedSelector, { - offset, - count, - sort, - }); - - const [reports, total] = await Promise.all([cursor.toArray(), totalCount]); - - const emailSet = new Map(); - - reports.forEach((report) => { - const email = report.reportedUser?.emails?.[0]; - if (email) { - emailSet.set(email.address, email); - } - }); - if (user) { - user.emails = Array.from(emailSet.values()); + async function action() { + const { userId, selector = '' } = this.queryParams; + const { sort } = await this.parseJsonQuery(); + const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams); + + const user = await Users.findOneById(userId, { + projection: { + _id: 1, + username: 1, + name: 1, + avatarETag: 1, + active: 1, + roles: 1, + emails: 1, + createdAt: 1, + }, + }); + + const escapedSelector = escapeRegExp(selector); + const { cursor, totalCount } = ModerationReports.findUserReportsByReportedUserId(userId, escapedSelector, { + offset, + count, + sort, + }); + + const [reports, total] = await Promise.all([cursor.toArray(), totalCount]); + + const emailSet = new Map(); + + reports.forEach((report) => { + const email = report.reportedUser?.emails?.[0]; + if (email) { + emailSet.set(email.address, email); } - - return API.v1.success({ - user, - reports, - count: reports.length, - total, - offset, - }); - }, + }); + if (user) { + user.emails = Array.from(emailSet.values()); + } + + return API.v1.success({ + user, + reports, + count: reports.length, + total, + offset, + }); }, ); -API.v1.addRoute( +API.v1.post( 'moderation.user.deleteReportedMessages', { authRequired: true, - validateParams: isModerationDeleteMsgHistoryParams, permissionsRequired: ['manage-moderation-actions'], + body: isModerationDeleteMsgHistoryParams, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - // TODO change complicated params - const { userId, reason } = this.bodyParams; + async function action() { + const { userId, reason } = this.bodyParams; - const sanitizedReason = reason?.trim() ? reason : 'No reason provided'; + const sanitizedReason = reason?.trim() ? reason : 'No reason provided'; - const { user: moderator } = this; + const { user: moderator } = this; - const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams); + const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams); - const { cursor, totalCount } = ModerationReports.findReportedMessagesByReportedUserId(userId, '', { - offset, - count, - sort: { ts: -1 }, - }); + const { cursor, totalCount } = ModerationReports.findReportedMessagesByReportedUserId(userId, '', { + offset, + count, + sort: { ts: -1 }, + }); - const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); - if (total === 0) { - return API.v1.failure('No reported messages found for this user.'); - } + if (total === 0) { + return API.v1.failure('No reported messages found for this user.'); + } - await deleteReportedMessages( - messages.map((message) => message.message), - moderator, - ); + await deleteReportedMessages( + messages.map((message) => message.message), + moderator, + ); - await ModerationReports.hideMessageReportsByUserId(userId, this.userId, sanitizedReason, 'DELETE Messages'); + await ModerationReports.hideMessageReportsByUserId(userId, this.userId, sanitizedReason, 'DELETE Messages'); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'moderation.dismissReports', { authRequired: true, - validateParams: isArchiveReportProps, permissionsRequired: ['manage-moderation-actions'], + body: isArchiveReportProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { userId, msgId, reason, action: actionParam } = this.bodyParams; - - if (userId) { - const report = await ModerationReports.findOne({ 'message.u._id': userId, '_hidden': { $ne: true } }, { projection: { _id: 1 } }); - if (!report) { - return API.v1.failure('no-reports-found'); - } + async function action() { + const { userId, msgId, reason, action: actionParam } = this.bodyParams; + + if (userId) { + const report = await ModerationReports.findOne({ 'message.u._id': userId, '_hidden': { $ne: true } }, { projection: { _id: 1 } }); + if (!report) { + return API.v1.failure('no-reports-found'); } + } - if (msgId) { - const report = await ModerationReports.findOne({ 'message._id': msgId, '_hidden': { $ne: true } }, { projection: { _id: 1 } }); - if (!report) { - return API.v1.failure('no-reports-found'); - } + if (msgId) { + const report = await ModerationReports.findOne({ 'message._id': msgId, '_hidden': { $ne: true } }, { projection: { _id: 1 } }); + if (!report) { + return API.v1.failure('no-reports-found'); } + } - const sanitizedReason: string = reason?.trim() ? reason : 'No reason provided'; - const action: string = actionParam ?? 'None'; + const sanitizedReason: string = reason?.trim() ? reason : 'No reason provided'; + const action: string = actionParam ?? 'None'; - const { userId: moderatorId } = this; + const { userId: moderatorId } = this; - if (userId) { - await ModerationReports.hideMessageReportsByUserId(userId, moderatorId, sanitizedReason, action); - } else { - await ModerationReports.hideMessageReportsByMessageId(msgId as string, moderatorId, sanitizedReason, action); - } + if (userId) { + await ModerationReports.hideMessageReportsByUserId(userId, moderatorId, sanitizedReason, action); + } else { + await ModerationReports.hideMessageReportsByMessageId(msgId as string, moderatorId, sanitizedReason, action); + } - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'moderation.dismissUserReports', { authRequired: true, - validateParams: isArchiveReportProps, permissionsRequired: ['manage-moderation-actions'], + body: isArchiveReportProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { userId, reason, action: actionParam } = this.bodyParams; + async function action() { + const { userId, reason, action: actionParam } = this.bodyParams; - if (!userId) { - return API.v1.failure('error-user-id-param-not-provided'); - } + if (!userId) { + return API.v1.failure('error-user-id-param-not-provided'); + } - const sanitizedReason: string = reason ?? 'No reason provided'; - const action: string = actionParam ?? 'None'; + const sanitizedReason: string = reason ?? 'No reason provided'; + const action: string = actionParam ?? 'None'; - const { userId: moderatorId } = this; + const { userId: moderatorId } = this; - await ModerationReports.hideUserReportsByUserId(userId, moderatorId, sanitizedReason, action); + await ModerationReports.hideUserReportsByUserId(userId, moderatorId, sanitizedReason, action); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema. +// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time") +// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1". +// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB +// 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 reportsByMsgIdResponseSchema = ajv.compile<{ + reports: Pick[]; + count: number; + offset: number; + total: number; +}>({ + type: 'object', + properties: { + reports: { type: 'array', items: { type: 'object' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['reports', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'moderation.reports', { authRequired: true, - validateParams: isReportsByMsgIdParams, permissionsRequired: ['view-moderation-console'], + query: isReportsByMsgIdParams, + response: { + 200: reportsByMsgIdResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async get() { - const { msgId } = this.queryParams; + async function action() { + const { msgId } = this.queryParams; - const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams); - const { sort } = await this.parseJsonQuery(); - const { selector = '' } = this.queryParams; + const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { selector = '' } = this.queryParams; - const escapedSelector = escapeRegExp(selector); + const escapedSelector = escapeRegExp(selector); - const { cursor, totalCount } = ModerationReports.findReportsByMessageId(msgId, escapedSelector, { count, sort, offset }); + const { cursor, totalCount } = ModerationReports.findReportsByMessageId(msgId, escapedSelector, { count, sort, offset }); - const [reports, total] = await Promise.all([cursor.toArray(), totalCount]); + const [reports, total] = await Promise.all([cursor.toArray(), totalCount]); - return API.v1.success({ - reports, - count: reports.length, - offset, - total, - }); - }, + return API.v1.success({ + reports, + count: reports.length, + offset, + total, + }); }, ); -API.v1.addRoute( +API.v1.get( 'moderation.reportInfo', { authRequired: true, permissionsRequired: ['view-moderation-console'], - validateParams: isReportInfoParams, + query: isReportInfoParams, + response: { + 200: reportInfoResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async get() { - const { reportId } = this.queryParams; + async function action() { + const { reportId } = this.queryParams; - const report = await ModerationReports.findOneById(reportId); + const report = await ModerationReports.findOneById(reportId); - if (!report) { - return API.v1.failure('error-report-not-found'); - } + if (!report) { + return API.v1.failure('error-report-not-found'); + } - return API.v1.success({ report }); - }, + return API.v1.success({ report }); }, ); -API.v1.addRoute( +API.v1.post( 'moderation.reportUser', { authRequired: true, - validateParams: isModerationReportUserPost, + body: isModerationReportUserPost, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const { userId, description } = this.bodyParams; + async function action() { + const { userId, description } = this.bodyParams; - const { - user: { _id, name, username, createdAt }, - } = this; + const { + user: { _id, name, username, createdAt }, + } = this; - const reportedUser = await Users.findOneById(userId, { projection: { _id: 1, name: 1, username: 1, emails: 1, createdAt: 1 } }); + const reportedUser = await Users.findOneById(userId, { + projection: { _id: 1, name: 1, username: 1, emails: 1, createdAt: 1 }, + }); - if (!reportedUser) { - return API.v1.failure('Invalid user id provided.'); - } + if (!reportedUser) { + return API.v1.failure('Invalid user id provided.'); + } - await ModerationReports.createWithDescriptionAndUser(reportedUser, description, { _id, name, username, createdAt }); + await ModerationReports.createWithDescriptionAndUser(reportedUser, description, { _id, name, username, createdAt }); - return API.v1.success(); - }, + return API.v1.success(); }, ); diff --git a/apps/meteor/app/api/server/v1/oauthapps.ts b/apps/meteor/app/api/server/v1/oauthapps.ts index 26a5bd0cfd482..593947da77528 100644 --- a/apps/meteor/app/api/server/v1/oauthapps.ts +++ b/apps/meteor/app/api/server/v1/oauthapps.ts @@ -2,6 +2,7 @@ import type { IOAuthApps } from '@rocket.chat/core-typings'; import { OAuthApps } from '@rocket.chat/models'; import { ajv, + ajvQuery, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse, validateForbiddenErrorResponse, @@ -114,14 +115,14 @@ const oauthAppsGetParamsSchema = { ], }; -const isOauthAppsGetParams = ajv.compile(oauthAppsGetParamsSchema); +const isOauthAppsGetParams = ajvQuery.compile(oauthAppsGetParamsSchema); const oauthAppsEndpoints = API.v1 .get( 'oauth-apps.list', { authRequired: true, - query: ajv.compile<{ uid?: string }>({ + query: ajvQuery.compile<{ uid?: string }>({ type: 'object', properties: { uid: { diff --git a/apps/meteor/app/api/server/v1/permissions.ts b/apps/meteor/app/api/server/v1/permissions.ts index 750ebe7cb2e43..38c63b1f22a4c 100644 --- a/apps/meteor/app/api/server/v1/permissions.ts +++ b/apps/meteor/app/api/server/v1/permissions.ts @@ -2,6 +2,7 @@ import type { IPermission } from '@rocket.chat/core-typings'; import { Permissions, Roles } from '@rocket.chat/models'; import { ajv, + ajvQuery, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse, validateForbiddenErrorResponse, @@ -57,7 +58,7 @@ const permissionUpdatePropsSchema = { additionalProperties: false, }; -const isPermissionsListAll = ajv.compile(permissionListAllSchema); +const isPermissionsListAll = ajvQuery.compile(permissionListAllSchema); const isBodyParamsValidPermissionUpdate = ajv.compile(permissionUpdatePropsSchema); @@ -173,7 +174,7 @@ const permissionsEndpoints = API.v1 return API.v1.failure('Invalid role', 'error-invalid-role'); } - for await (const permission of bodyParams.permissions) { + for (const permission of bodyParams.permissions) { await Permissions.setRoles(permission._id, permission.roles); void notifyOnPermissionChangedById(permission._id); } diff --git a/apps/meteor/app/api/server/v1/presence.ts b/apps/meteor/app/api/server/v1/presence.ts index 019137569f610..28e83ecb68f80 100644 --- a/apps/meteor/app/api/server/v1/presence.ts +++ b/apps/meteor/app/api/server/v1/presence.ts @@ -1,27 +1,63 @@ import { Presence } from '@rocket.chat/core-services'; +import { ajv, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse } from '@rocket.chat/rest-typings'; import { API } from '../api'; -API.v1.addRoute( +API.v1.get( 'presence.getConnections', - { authRequired: true, permissionsRequired: ['manage-user-status'] }, { - async get() { - const result = await Presence.getConnectionCount(); - - return API.v1.success(result); + authRequired: true, + permissionsRequired: ['manage-user-status'], + response: { + 200: ajv.compile<{ current: number; max: number; success: true }>({ + type: 'object', + properties: { + current: { type: 'number' }, + max: { type: 'number' }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['current', 'max', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const result = await Presence.getConnectionCount(); + + return API.v1.success(result); + }, ); -API.v1.addRoute( +API.v1.post( 'presence.enableBroadcast', - { authRequired: true, permissionsRequired: ['manage-user-status'], twoFactorRequired: true }, { - async post() { - await Presence.toggleBroadcast(true); - - return API.v1.success(); + authRequired: true, + permissionsRequired: ['manage-user-status'], + twoFactorRequired: true, + response: { + 200: ajv.compile<{ success: true }>({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + await Presence.toggleBroadcast(true); + + return API.v1.success({}); + }, ); diff --git a/apps/meteor/app/api/server/v1/push.ts b/apps/meteor/app/api/server/v1/push.ts index f84076d83d51e..2c72d37d7e5b3 100644 --- a/apps/meteor/app/api/server/v1/push.ts +++ b/apps/meteor/app/api/server/v1/push.ts @@ -1,85 +1,226 @@ -import type { IAppsTokens } from '@rocket.chat/core-typings'; -import { Messages, AppsTokens, Users, Rooms, Settings } from '@rocket.chat/models'; -import { Random } from '@rocket.chat/random'; +import { Push } from '@rocket.chat/core-services'; +import type { IPushToken, IPushTokenTypes } from '@rocket.chat/core-typings'; +import { Messages, PushToken, Users, Rooms, Settings } from '@rocket.chat/models'; +import { + ajv, + validateNotFoundErrorResponse, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, +} 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'; import { canAccessRoomAsync } from '../../../authorization/server/functions/canAccessRoom'; -import { pushUpdate } from '../../../push/server/methods'; import PushNotification from '../../../push-notifications/server/lib/PushNotification'; import { settings } from '../../../settings/server'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; +import type { SuccessResult } from '../definition'; -API.v1.addRoute( - 'push.token', - { authRequired: true }, - { - async post() { - const { id, type, value, appName } = this.bodyParams; +type PushTokenPOST = { + id?: string; + type: IPushTokenTypes; + value: string; + appName: string; + voipToken?: string; +}; - if (id && typeof id !== 'string') { - throw new Meteor.Error('error-id-param-not-valid', 'The required "id" body param is invalid.'); - } +const PushTokenPOSTSchema: JSONSchemaType = { + type: 'object', + properties: { + id: { + type: 'string', + nullable: true, + }, + type: { + type: 'string', + enum: ['apn', 'gcm'], + }, + value: { + type: 'string', + minLength: 1, + }, + appName: { + type: 'string', + minLength: 1, + }, + voipToken: { + type: 'string', + nullable: true, + }, + }, + required: ['type', 'value', 'appName'], + additionalProperties: false, +}; - const deviceId = id || Random.id(); +export const isPushTokenPOSTProps = ajv.compile(PushTokenPOSTSchema); - if (!type || (type !== 'apn' && type !== 'gcm')) { - throw new Meteor.Error('error-type-param-not-valid', 'The required "type" body param is missing or invalid.'); - } +type PushTokenDELETE = { + token: string; +}; - if (!value || typeof value !== 'string') { - throw new Meteor.Error('error-token-param-not-valid', 'The required "value" body param is missing or invalid.'); - } +const PushTokenDELETESchema: JSONSchemaType = { + type: 'object', + properties: { + token: { + type: 'string', + minLength: 1, + }, + }, + required: ['token'], + additionalProperties: false, +}; + +export const isPushTokenDELETEProps = ajv.compile(PushTokenDELETESchema); + +type PushTokenResult = Pick; + +/** + * Pick only the attributes we actually want to return on the endpoint, ensuring nothing from older schemas get mixed in + */ +function cleanTokenResult(result: Omit): PushTokenResult { + const { _id, token, appName, userId, enabled, createdAt, _updatedAt, voipToken } = result; - if (!appName || typeof appName !== 'string') { - throw new Meteor.Error('error-appName-param-not-valid', 'The required "appName" body param is missing or invalid.'); + return { + _id, + token, + appName, + userId, + enabled, + createdAt, + _updatedAt, + voipToken, + }; +} + +const pushTokenEndpoints = API.v1 + .post( + 'push.token', + { + response: { + 200: ajv.compile['body']>({ + additionalProperties: false, + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + result: { + type: 'object', + description: 'The updated token data for this device', + properties: { + _id: { + type: 'string', + }, + token: { + type: 'object', + properties: { + apn: { + type: 'string', + }, + gcm: { + type: 'string', + }, + }, + required: [], + additionalProperties: false, + }, + appName: { + type: 'string', + }, + userId: { + type: 'string', + nullable: true, + }, + enabled: { + type: 'boolean', + }, + createdAt: { + type: 'string', + }, + _updatedAt: { + type: 'string', + }, + voipToken: { + type: 'string', + }, + }, + additionalProperties: false, + }, + }, + required: ['success', 'result'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + body: isPushTokenPOSTProps, + authRequired: true, + }, + async function action() { + const { id, type, value, appName, voipToken } = this.bodyParams; + + if (voipToken && !id) { + return API.v1.failure('voip-tokens-must-specify-device-id'); } - const authToken = this.request.headers.get('x-auth-token'); - if (!authToken) { + const rawToken = this.request.headers.get('x-auth-token'); + if (!rawToken) { throw new Meteor.Error('error-authToken-param-not-valid', 'The required "authToken" header param is missing or invalid.'); } + const authToken = Accounts._hashLoginToken(rawToken); - const result = await pushUpdate({ - id: deviceId, - token: { [type]: value } as IAppsTokens['token'], + const result = await Push.registerPushToken({ + ...(id && { _id: id }), + token: { [type]: value } as IPushToken['token'], authToken, appName, userId: this.userId, + ...(voipToken && { voipToken }), }); - return API.v1.success({ result }); + return API.v1.success({ result: cleanTokenResult(result) }); + }, + ) + .delete( + 'push.token', + { + response: { + 200: ajv.compile({ + additionalProperties: false, + type: 'object', + properties: { + success: { + type: 'boolean', + }, + }, + required: ['success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + body: isPushTokenDELETEProps, + authRequired: true, }, - async delete() { + async function action() { const { token } = this.bodyParams; - if (!token || typeof token !== 'string') { - throw new Meteor.Error('error-token-param-not-valid', 'The required "token" body param is missing or invalid.'); - } + const removeResult = await PushToken.removeAllByTokenStringAndUserId(token, this.userId); - const affectedRecords = ( - await AppsTokens.deleteMany({ - $or: [ - { - 'token.apn': token, - }, - { - 'token.gcm': token, - }, - ], - userId: this.userId, - }) - ).deletedCount; - - if (affectedRecords === 0) { + if (removeResult.deletedCount === 0) { return API.v1.notFound(); } return API.v1.success(); }, - }, -); + ); API.v1.addRoute( 'push.get', @@ -135,7 +276,7 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +const pushTestEndpoints = API.v1.post( 'push.test', { authRequired: true, @@ -144,17 +285,44 @@ API.v1.addRoute( intervalTimeInMS: 1000, }, permissionsRequired: ['test-push-notifications'], + body: ajv.compile({ type: 'object', additionalProperties: false }), + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ tokensCount: number }>({ + type: 'object', + properties: { + tokensCount: { type: 'integer' }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['tokensCount', 'success'], + additionalProperties: false, + }), + }, }, - { - async post() { - if (settings.get('Push_enable') !== true) { - throw new Meteor.Error('error-push-disabled', 'Push is disabled', { - method: 'push_test', - }); - } - const tokensCount = await executePushTest(this.userId, this.user.username); - return API.v1.success({ tokensCount }); - }, + async function action() { + if (settings.get('Push_enable') !== true) { + throw new Meteor.Error('error-push-disabled', 'Push is disabled', { + method: 'push_test', + }); + } + + const tokensCount = await executePushTest(this.userId, this.user.username); + return API.v1.success({ tokensCount }); }, ); + +type PushTestEndpoints = ExtractRoutesFromAPI; + +type PushTokenEndpoints = ExtractRoutesFromAPI; + +type PushEndpoints = PushTestEndpoints & PushTokenEndpoints; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends PushEndpoints {} +} diff --git a/apps/meteor/app/api/server/v1/roles.ts b/apps/meteor/app/api/server/v1/roles.ts index 8985e48c0aff5..3972a97ee906a 100644 --- a/apps/meteor/app/api/server/v1/roles.ts +++ b/apps/meteor/app/api/server/v1/roles.ts @@ -1,8 +1,17 @@ import { api, Authorization } from '@rocket.chat/core-services'; -import type { IRole } from '@rocket.chat/core-typings'; +import type { IRole, IUserInRole } from '@rocket.chat/core-typings'; import { Roles, Users } from '@rocket.chat/models'; -import { ajv, isRoleAddUserToRoleProps, isRoleDeleteProps, isRoleRemoveUserFromRoleProps } from '@rocket.chat/rest-typings'; -import { check, Match } from 'meteor/check'; +import { + ajv, + ajvQuery, + isRoleAddUserToRoleProps, + isRoleDeleteProps, + isRoleRemoveUserFromRoleProps, + isRolesGetUsersInRoleProps, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import { removeUserFromRolesAsync } from '../../../../server/lib/roles/removeUserFromRoles'; @@ -17,51 +26,94 @@ import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { getUserFromParams } from '../helpers/getUserFromParams'; -API.v1.addRoute( - 'roles.list', - { authRequired: true }, - { - async get() { +const rolesSyncQuerySchema = ajvQuery.compile<{ updatedSince?: string }>({ + type: 'object', + properties: { updatedSince: { type: 'string' } }, + additionalProperties: false, +}); + +const rolesRoutes = API.v1 + .get( + 'roles.list', + { + authRequired: true, + response: { + 200: ajv.compile<{ roles: IRole[] }>({ + type: 'object', + properties: { + roles: { type: 'array', items: { $ref: '#/components/schemas/IRole' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['roles', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const roles = await Roles.find({}, { projection: { _updatedAt: 0 } }).toArray(); return API.v1.success({ roles }); }, - }, -); - -API.v1.addRoute( - 'roles.sync', - { authRequired: true }, - { - async get() { - check( - this.queryParams, - Match.ObjectIncluding({ - updatedSince: Match.Where((value: unknown): value is string => typeof value === 'string' && !Number.isNaN(Date.parse(value))), + ) + .get( + 'roles.sync', + { + authRequired: true, + query: rolesSyncQuerySchema, + response: { + 200: ajv.compile<{ roles: { update: IRole[]; remove: IRole[] } }>({ + type: 'object', + properties: { + roles: { + type: 'object', + properties: { + update: { type: 'array', items: { $ref: '#/components/schemas/IRole' } }, + remove: { type: 'array', items: { $ref: '#/components/schemas/IRole' } }, + }, + required: ['update', 'remove'], + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['roles', 'success'], + additionalProperties: false, }), - ); - + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { updatedSince } = this.queryParams; + if (updatedSince && Number.isNaN(Date.parse(updatedSince))) { + throw new Meteor.Error('error-invalid-param', 'updatedSince must be a valid date string'); + } + return API.v1.success({ roles: { - update: await Roles.findByUpdatedDate(new Date(updatedSince)).toArray(), - remove: await Roles.trashFindDeletedAfter(new Date(updatedSince)).toArray(), + update: await Roles.findByUpdatedDate(new Date(updatedSince || 0)).toArray(), + remove: await Roles.trashFindDeletedAfter(new Date(updatedSince || 0)).toArray(), }, }); }, - }, -); - -API.v1.addRoute( - 'roles.addUserToRole', - { authRequired: true }, - { - async post() { - if (!isRoleAddUserToRoleProps(this.bodyParams)) { - throw new Meteor.Error('error-invalid-role-properties', isRoleAddUserToRoleProps.errors?.map((error) => error.message).join('\n')); - } - + ) + .post( + 'roles.addUserToRole', + { + authRequired: true, + body: isRoleAddUserToRoleProps, + response: { + 200: ajv.compile<{ role: IRole }>({ + type: 'object', + properties: { role: { $ref: '#/components/schemas/IRole' }, success: { type: 'boolean', enum: [true] } }, + required: ['role', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const user = await getUserFromParams(this.bodyParams); const { roleId, roomId } = this.bodyParams; @@ -84,14 +136,30 @@ API.v1.addRoute( role, }); }, - }, -); - -API.v1.addRoute( - 'roles.getUsersInRole', - { authRequired: true, permissionsRequired: ['access-permissions'] }, - { - async get() { + ) + .get( + 'roles.getUsersInRole', + { + authRequired: true, + permissionsRequired: ['access-permissions'], + query: isRolesGetUsersInRoleProps, + response: { + 200: ajv.compile<{ users: IUserInRole[]; total: number }>({ + type: 'object', + properties: { + users: { type: 'array', items: { type: 'object' } }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['users', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { const { roomId, role } = this.queryParams; const { offset, count = 50 } = await getPaginationItems(this.queryParams); @@ -129,18 +197,27 @@ API.v1.addRoute( return API.v1.success({ users, total }); }, - }, -); - -API.v1.addRoute( - 'roles.delete', - { authRequired: true, permissionsRequired: ['access-permissions'] }, - { - async post() { + ) + .post( + 'roles.delete', + { + authRequired: true, + permissionsRequired: ['access-permissions'], + body: isRoleDeleteProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { const { bodyParams } = this; - if (!isRoleDeleteProps(bodyParams)) { - throw new Meteor.Error('error-invalid-role-properties', 'The role properties are invalid.'); - } const role = await Roles.findOneByIdOrName(bodyParams.roleId); @@ -162,18 +239,27 @@ API.v1.addRoute( return API.v1.success(); }, - }, -); - -API.v1.addRoute( - 'roles.removeUserFromRole', - { authRequired: true, permissionsRequired: ['access-permissions'] }, - { - async post() { + ) + .post( + 'roles.removeUserFromRole', + { + authRequired: true, + permissionsRequired: ['access-permissions'], + body: isRoleRemoveUserFromRoleProps, + response: { + 200: ajv.compile<{ role: IRole }>({ + type: 'object', + properties: { role: { $ref: '#/components/schemas/IRole' }, success: { type: 'boolean', enum: [true] } }, + required: ['role', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { const { bodyParams } = this; - if (!isRoleRemoveUserFromRoleProps(bodyParams)) { - throw new Meteor.Error('error-invalid-role-properties', 'The role properties are invalid.'); - } const { roleId, username, scope } = bodyParams; @@ -222,41 +308,45 @@ API.v1.addRoute( role, }); }, - }, -); - -const rolesRoutes = API.v1.get( - 'roles.getUsersInPublicRoles', - { - authRequired: true, - response: { - 200: ajv.compile<{ - users: { - _id: string; - username: string; - roles: string[]; - }[]; - }>({ - type: 'object', - properties: { + ) + .get( + 'roles.getUsersInPublicRoles', + { + authRequired: true, + response: { + 200: ajv.compile<{ users: { - type: 'array', - items: { - type: 'object', - properties: { _id: { type: 'string' }, username: { type: 'string' }, roles: { type: 'array', items: { type: 'string' } } }, + _id: string; + username: string; + roles: string[]; + }[]; + }>({ + type: 'object', + properties: { + users: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + username: { type: 'string' }, + roles: { type: 'array', items: { type: 'string' } }, + }, + }, }, + success: { type: 'boolean', enum: [true] }, }, - }, - }), + required: ['users', 'success'], + additionalProperties: false, + }), + }, + }, + async () => { + return API.v1.success({ + users: await Authorization.getUsersFromPublicRoles(), + }); }, - }, - - async () => { - return API.v1.success({ - users: await Authorization.getUsersFromPublicRoles(), - }); - }, -); + ); type RolesEndpoints = ExtractRoutesFromAPI; diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index 686f76a7476c6..ae02a578ab05c 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -1,13 +1,16 @@ import { FederationMatrix, MeteorError, Team } from '@rocket.chat/core-services'; -import type { IRoom, IUpload } from '@rocket.chat/core-typings'; -import { isPrivateRoom, isPublicRoom } from '@rocket.chat/core-typings'; +import { type IRoom, type IUpload, type RequiredField, 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 { ajv, + ajvQuery, isGETRoomsNameExists, isRoomsImagesProps, isRoomsMuteUnmuteUserProps, + isRoomsBanUserProps, + isRoomsUnbanUserProps, + isRoomsBannedUsersProps, isRoomsExportProps, isRoomsIsMemberProps, isRoomsCleanHistoryProps, @@ -18,17 +21,20 @@ import { isRoomsInviteProps, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, } from '@rocket.chat/rest-typings'; import { isTruthy } from '@rocket.chat/tools'; import { Meteor } from 'meteor/meteor'; import { adminFields } from '../../../../lib/rooms/adminFields'; import { omit } from '../../../../lib/utils/omit'; +import { banUserFromRoomMethod } from '../../../../server/lib/banUserFromRoom'; import * as dataExport from '../../../../server/lib/dataExport'; import { eraseRoom } from '../../../../server/lib/eraseRoom'; import { findUsersOfRoomOrderedByRole } from '../../../../server/lib/findUsersOfRoomOrderedByRole'; import { openRoom } from '../../../../server/lib/openRoom'; import type { RoomRoles } from '../../../../server/lib/roles/getRoomRoles'; +import { unbanUserFromRoom } from '../../../../server/lib/unbanUserFromRoom'; import { hideRoomMethod } from '../../../../server/methods/hideRoom'; import { muteUserInRoom } from '../../../../server/methods/muteUserInRoom'; import { toggleFavoriteMethod } from '../../../../server/methods/toggleFavorite'; @@ -121,37 +127,58 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +const roomDeleteEndpoint = API.v1.post( 'rooms.delete', { authRequired: true, + body: ajv.compile<{ roomId: string }>({ + type: 'object', + properties: { + roomId: { + type: 'string', + description: 'The ID of the room to delete.', + }, + }, + required: ['roomId'], + additionalProperties: false, + }), + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + description: 'Indicates if the request was successful.', + }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const { roomId } = this.bodyParams; - - if (!roomId) { - return API.v1.failure("The 'roomId' param is required"); - } + async function action() { + const { roomId } = this.bodyParams; - const room = await Rooms.findOneById(roomId); + const room = await Rooms.findOneById(roomId); - if (!room) { - throw new MeteorError('error-invalid-room', 'Invalid room', { - method: 'eraseRoom', - }); - } + if (!room) { + throw new MeteorError('error-invalid-room', 'Invalid room', { + method: 'eraseRoom', + }); + } - if (room.teamMain) { - throw new Meteor.Error('error-cannot-delete-team-channel', 'Cannot delete a team channel', { - method: 'eraseRoom', - }); - } + if (room.teamMain) { + throw new Meteor.Error('error-cannot-delete-team-channel', 'Cannot delete a team channel', { + method: 'eraseRoom', + }); + } - await eraseRoom(room, this.user); + await eraseRoom(room, this.user); - return API.v1.success(); - }, + return API.v1.success(); }, ); @@ -257,7 +284,7 @@ API.v1.addRoute( return API.v1.forbidden(); } - const file = await Uploads.findOneById(this.urlParams.fileId); + const file = await Uploads.findOneByIdAndUserIdAndRoomId(this.urlParams.fileId, this.userId, this.urlParams.rid); if (!file) { throw new Meteor.Error('invalid-file'); @@ -270,6 +297,16 @@ API.v1.addRoute( file.description = this.bodyParams.description; delete this.bodyParams.description; + if (this.bodyParams.fileName) { + file.name = this.bodyParams.fileName; + delete this.bodyParams.fileName; + } + + if (this.bodyParams.fileContent) { + file.content = this.bodyParams.fileContent; + delete this.bodyParams.fileContent; + } + await applyAirGappedRestrictionsValidation(() => sendFileMessage(this.userId, { roomId: this.urlParams.rid, file, msgData: this.bodyParams }), ); @@ -285,49 +322,53 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'rooms.saveNotification', - { authRequired: true }, - { - async post() { - const { roomId, notifications } = this.bodyParams; - - if (!roomId) { - return API.v1.failure("The 'roomId' param is required"); - } - - if (!notifications || Object.keys(notifications).length === 0) { - return API.v1.failure("The 'notifications' param is required"); - } - - await Promise.all( - Object.entries(notifications as Notifications).map(async ([notificationKey, notificationValue]) => - saveNotificationSettingsMethod(this.userId, roomId, notificationKey as NotificationFieldType, notificationValue), - ), - ); - - return API.v1.success(); +const saveNotificationBodySchema = ajv.compile<{ + roomId: string; + notifications: Record; +}>({ + type: 'object', + properties: { + roomId: { type: 'string', minLength: 1 }, + notifications: { + type: 'object', + minProperties: 1, + additionalProperties: { type: 'string' }, }, }, -); - -API.v1.addRoute( - 'rooms.favorite', - { authRequired: true }, - { - async post() { - const { favorite } = this.bodyParams; + required: ['roomId', 'notifications'], + additionalProperties: false, +}); - if (!this.bodyParams.hasOwnProperty('favorite')) { - return API.v1.failure("The 'favorite' param is required"); - } +const saveNotificationResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); - const room = await findRoomByIdOrName({ params: this.bodyParams }); +const roomsSaveNotificationEndpoint = API.v1.post( + 'rooms.saveNotification', + { + authRequired: true, + body: saveNotificationBodySchema, + response: { + 200: saveNotificationResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId, notifications } = this.bodyParams; - await toggleFavoriteMethod(this.userId, room._id, favorite); + await Promise.all( + Object.entries(notifications as Notifications).map(async ([notificationKey, notificationValue]) => + saveNotificationSettingsMethod(this.userId, roomId, notificationKey as NotificationFieldType, notificationValue), + ), + ); - return API.v1.success(); - }, + return API.v1.success({ success: true }); }, ); @@ -410,23 +451,6 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'rooms.leave', - { authRequired: true }, - { - async post() { - const room = await findRoomByIdOrName({ params: this.bodyParams }); - const user = await Users.findOneById(this.userId); - if (!user) { - return API.v1.failure('Invalid user'); - } - await leaveRoomMethod(user, room._id); - - return API.v1.success(); - }, - }, -); - /* TO-DO: 8.0.0 should use the ajv validation which will change this endpoint's @@ -437,7 +461,6 @@ API.v1.addRoute( { authRequired: true /* , validateParams: isRoomsCreateDiscussionProps */ }, { async post() { - // eslint-disable-next-line @typescript-eslint/naming-convention const { prid, pmid, reply, t_name, users, encrypted, topic } = this.bodyParams; if (!prid) { return API.v1.failure('Body parameter "prid" is required.'); @@ -532,7 +555,7 @@ API.v1.addRoute( 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 as IUpload)._id)) { + if (initialImage && !files.find(({ _id }) => _id === initialImage._id)) { files.splice(0, 0, initialImage); } @@ -749,7 +772,7 @@ API.v1.addRoute( void dataExport.sendFile( { rid, - format: format as 'html' | 'json', + format, dateFrom: convertedDateFrom, dateTo: convertedDateTo, }, @@ -797,7 +820,7 @@ API.v1.addRoute( const [room, user] = await Promise.all([ findRoomByIdOrName({ params: { roomId }, - }) as Promise, + }), Users.findOneByIdOrUsername(userId || username), ]); @@ -945,6 +968,24 @@ API.v1.addRoute( }, ); +type RoomsFavorite = + | { + roomId: string; + favorite: boolean; + } + | { + roomName: string; + favorite: boolean; + }; + +type RoomsLeave = + | { + roomId: string; + } + | { + roomName: string; + }; + const isRoomGetRolesPropsSchema = { type: 'object', properties: { @@ -953,12 +994,90 @@ const isRoomGetRolesPropsSchema = { additionalProperties: false, required: ['rid'], }; + +const RoomsFavoriteSchema = { + anyOf: [ + { + type: 'object', + properties: { + favorite: { type: 'boolean' }, + roomName: { type: 'string' }, + }, + required: ['roomName', 'favorite'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + favorite: { type: 'boolean' }, + roomId: { type: 'string' }, + }, + required: ['roomId', 'favorite'], + additionalProperties: false, + }, + ], +}; + +const isRoomsLeavePropsSchema = { + anyOf: [ + { + type: 'object', + properties: { + roomId: { type: 'string' }, + }, + required: ['roomId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + roomName: { type: 'string' }, + }, + required: ['roomName'], + additionalProperties: false, + }, + ], +}; + +const isRoomsFavoriteProps = ajv.compile(RoomsFavoriteSchema); +const isRoomsLeaveProps = ajv.compile(isRoomsLeavePropsSchema); +const roomsBannedUsersResponseSchema = ajv.compile<{ + success: true; + bannedUsers: RequiredField, '_id' | 'username'>[]; + count: number; + offset: number; + total: number; +}>({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + bannedUsers: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + username: { type: 'string' }, + name: { type: 'string' }, + }, + required: ['_id', 'username'], + additionalProperties: false, + }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + }, + required: ['success', 'bannedUsers', 'count', 'offset', 'total'], + additionalProperties: false, +}); + export const roomEndpoints = API.v1 .get( 'rooms.roles', { authRequired: true, - query: ajv.compile<{ + query: ajvQuery.compile<{ rid: string; }>(isRoomGetRolesPropsSchema), response: { @@ -986,11 +1105,14 @@ export const roomEndpoints = API.v1 }, required: ['roles'], }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, async function () { const { rid } = this.queryParams; - const roles = await executeGetRoomRoles(rid, this.userId); + const roles = await executeGetRoomRoles(rid, this.user); return API.v1.success({ roles, @@ -1002,7 +1124,7 @@ export const roomEndpoints = API.v1 { authRequired: true, permissionsRequired: ['view-room-administration'], - query: ajv.compile<{ + query: ajvQuery.compile<{ filter?: string; offset?: number; count?: number; @@ -1066,39 +1188,196 @@ export const roomEndpoints = API.v1 total, }); }, - ); + ) + .post( + 'rooms.invite', + { + authRequired: true, + body: isRoomsInviteProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const { roomId, action } = this.bodyParams; -const roomInviteEndpoints = API.v1.post( - 'rooms.invite', - { - authRequired: true, - body: isRoomsInviteProps, - response: { - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - 200: ajv.compile({ - type: 'object', - properties: { - success: { type: 'boolean', enum: [true] }, - }, - required: ['success'], - additionalProperties: false, - }), + try { + await FederationMatrix.handleInvite(roomId, this.userId, action); + return API.v1.success(); + } catch (error) { + return API.v1.failure({ error: `Failed to handle invite: ${error instanceof Error ? error.message : String(error)}` }); + } }, - }, - async function action() { - const { roomId, action } = this.bodyParams; + ) + .post( + 'rooms.favorite', + { + authRequired: true, + body: isRoomsFavoriteProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + description: 'Indicates if the request was successful.', + }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { favorite } = this.bodyParams; + + const room = await findRoomByIdOrName({ params: this.bodyParams }); + + await toggleFavoriteMethod(this.userId, room._id, favorite); - try { - await FederationMatrix.handleInvite(roomId, this.userId, action); return API.v1.success(); - } catch (error) { - return API.v1.failure({ error: `Failed to handle invite: ${error instanceof Error ? error.message : String(error)}` }); - } - }, -); + }, + ) + .post( + 'rooms.leave', + { + authRequired: true, + body: isRoomsLeaveProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const room = await findRoomByIdOrName({ params: this.bodyParams }); + + const user = await Users.findOneById(this.userId); + + if (!user) { + return API.v1.failure('error-invalid-user'); + } + + await leaveRoomMethod(user, room._id); -type RoomEndpoints = ExtractRoutesFromAPI & ExtractRoutesFromAPI; + return API.v1.success(); + }, + ) + .post( + 'rooms.banUser', + { + authRequired: true, + body: isRoomsBanUserProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const user = await getUserFromParams(this.bodyParams); + + if (!user.username) { + return API.v1.failure('Invalid user'); + } + + await banUserFromRoomMethod(this.userId, { rid: this.bodyParams.roomId, username: user.username }); + + return API.v1.success(); + }, + ) + .post( + 'rooms.unbanUser', + { + authRequired: true, + body: isRoomsUnbanUserProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const user = await getUserFromParams(this.bodyParams, true); + + if (!user.username) { + return API.v1.failure('Invalid user'); + } + + await unbanUserFromRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username }); + + return API.v1.success(); + }, + ) + .get( + 'rooms.bannedUsers', + { + authRequired: true, + query: isRoomsBannedUsersProps, + response: { + 200: roomsBannedUsersResponseSchema, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId } = this.queryParams; + + if (!(await canAccessRoomIdAsync(roomId, this.userId))) { + return API.v1.unauthorized(); + } + + const { offset, count } = await getPaginationItems(this.queryParams); + + const { cursor, totalCount } = Subscriptions.findPaginated({ rid: roomId, status: 'BANNED' as const }, { offset, count }); + + const [bannedSubs, total] = await Promise.all([cursor.toArray(), totalCount]); + + const userIds = bannedSubs.map((sub) => sub.u._id); + const users = await Users.find, '_id' | 'username'>>( + { _id: { $in: userIds } }, + { projection: { username: 1, name: 1 } }, + ).toArray(); + + return API.v1.success({ + bannedUsers: users, + count: users.length, + offset, + total, + }); + }, + ); +type RoomEndpoints = ExtractRoutesFromAPI & + ExtractRoutesFromAPI & + ExtractRoutesFromAPI; declare module '@rocket.chat/rest-typings' { // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface diff --git a/apps/meteor/app/api/server/v1/settings.ts b/apps/meteor/app/api/server/v1/settings.ts index 637c2b654cac1..5300db2c79c80 100644 --- a/apps/meteor/app/api/server/v1/settings.ts +++ b/apps/meteor/app/api/server/v1/settings.ts @@ -2,17 +2,21 @@ import type { FacebookOAuthConfiguration, ISetting, ISettingColor, + LoginServiceConfiguration, TwitterOAuthConfiguration, OAuthConfiguration, } from '@rocket.chat/core-typings'; import { isSettingAction, isSettingColor } from '@rocket.chat/core-typings'; import { LoginServiceConfiguration as LoginServiceConfigurationModel, Settings } from '@rocket.chat/models'; import { + ajv, isSettingsUpdatePropDefault, isSettingsUpdatePropsActions, isSettingsUpdatePropsColor, isSettingsPublicWithPaginationProps, isSettingsGetParams, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import type { FindOptions } from 'mongodb'; @@ -27,7 +31,6 @@ import { addOAuthServiceMethod } from '../../../lib/server/methods/addOAuthServi import { SettingsEvents, settings } from '../../../settings/server'; import { setValue } from '../../../settings/server/raw'; import { API } from '../api'; -import type { ResultFor } from '../definition'; import { getPaginationItems } from '../helpers/getPaginationItems'; async function fetchSettings( @@ -44,232 +47,369 @@ async function fetchSettings( projection: { _id: 1, value: 1, enterprise: 1, invalidValue: 1, modules: 1, ...fields }, }); - const [settings, total] = await Promise.all([cursor.toArray(), totalCount]); + const [settingsList, total] = await Promise.all([cursor.toArray(), totalCount]); - SettingsEvents.emit('fetch-settings', settings); - return { settings, totalCount: total }; + SettingsEvents.emit('fetch-settings', settingsList); + return { settings: settingsList, totalCount: total }; } -// settings endpoints -API.v1.addRoute( +const settingsPublicResponseSchema = ajv.compile<{ settings: ISetting[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + settings: { type: 'array', items: { type: 'object' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['settings', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +const settingsOAuthResponseSchema = ajv.compile<{ services: Partial[] }>({ + type: 'object', + properties: { + services: { type: 'array', items: { type: 'object' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['services', 'success'], + additionalProperties: false, +}); + +const addCustomOAuthBodySchema = ajv.compile<{ name: string }>({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, +}); + +const settingsListResponseSchema = ajv.compile<{ settings: ISetting[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + settings: { type: 'array', items: { type: 'object' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['settings', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +const settingByIdGetResponseSchema = ajv.compile>({ + type: 'object', + properties: { + _id: { type: 'string' }, + value: {}, + success: { type: 'boolean', enum: [true] }, + }, + required: ['_id', 'value', 'success'], + additionalProperties: false, +}); + +const settingByIdPostResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +const settingsUpdateBodySchema = ajv.compile<{ value?: unknown; execute?: boolean; editor?: string }>({ + type: 'object', + properties: { + value: {}, + execute: { type: 'boolean' }, + editor: { type: 'string' }, + }, + additionalProperties: true, +}); + +const serviceConfigurationsResponseSchema = ajv.compile<{ configurations: LoginServiceConfiguration[] }>({ + type: 'object', + properties: { + configurations: { type: 'array', items: { type: 'object' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['configurations', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'settings.public', - { authRequired: false, validateParams: isSettingsPublicWithPaginationProps }, { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); - const { _id } = this.queryParams; - - const parsedQueryId = typeof _id === 'string' && _id ? { _id: { $in: _id.split(',').map((id) => id.trim()) } } : {}; - - const ourQuery = { - ...query, - ...parsedQueryId, - hidden: { $ne: true }, - public: true, - }; - - const { settings, totalCount: total } = await fetchSettings(ourQuery, sort, offset, count, fields); - - return API.v1.success({ - settings, - count: settings.length, - offset, - total, - }); + authRequired: false, + query: isSettingsPublicWithPaginationProps, + response: { + 200: settingsPublicResponseSchema, }, }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + const { _id } = this.queryParams; + + const parsedQueryId = typeof _id === 'string' && _id ? { _id: { $in: _id.split(',').map((id) => id.trim()) } } : {}; + + const ourQuery = { + ...query, + ...parsedQueryId, + hidden: { $ne: true }, + public: true, + }; + + const { settings: settingsList, totalCount: total } = await fetchSettings(ourQuery, sort, offset, count, fields); + + return API.v1.success({ + settings: settingsList, + count: settingsList.length, + offset, + total, + }); + }, ); -API.v1.addRoute( +API.v1.get( 'settings.oauth', - { authRequired: false }, { - async get() { - const oAuthServicesEnabled = await LoginServiceConfigurationModel.find({}, { projection: { secret: 0 } }).toArray(); - - return API.v1.success({ - services: oAuthServicesEnabled.map((service) => { - if (!service) { - return service; - } - - if ((service as OAuthConfiguration).custom || (service.service && ['saml', 'cas', 'wordpress'].includes(service.service))) { - return { ...service }; - } - - return { - _id: service._id, - name: service.service, - clientId: - (service as FacebookOAuthConfiguration).appId || - (service as OAuthConfiguration).clientId || - (service as TwitterOAuthConfiguration).consumerKey, - buttonLabelText: service.buttonLabelText || '', - buttonColor: service.buttonColor || '', - buttonLabelColor: service.buttonLabelColor || '', - custom: false, - }; - }), - }); + authRequired: false, + response: { + 200: settingsOAuthResponseSchema, }, }, + async function action() { + const oAuthServicesEnabled = await LoginServiceConfigurationModel.find({}, { projection: { secret: 0 } }).toArray(); + + return API.v1.success({ + services: oAuthServicesEnabled.map((service) => { + if (!service) { + return service; + } + + if ((service as OAuthConfiguration).custom || (service.service && ['saml', 'cas', 'wordpress'].includes(service.service))) { + return { ...service }; + } + + return { + _id: service._id, + name: service.service, + clientId: + (service as FacebookOAuthConfiguration).appId || + (service as OAuthConfiguration).clientId || + (service as TwitterOAuthConfiguration).consumerKey, + buttonLabelText: service.buttonLabelText || '', + buttonColor: service.buttonColor || '', + buttonLabelColor: service.buttonLabelColor || '', + custom: false, + }; + }), + }); + }, ); -API.v1.addRoute( +API.v1.post( 'settings.addCustomOAuth', - { authRequired: true, twoFactorRequired: true }, { - async post() { - if (!this.bodyParams.name?.trim()) { - throw new Meteor.Error('error-name-param-not-provided', 'The parameter "name" is required'); - } + authRequired: true, + twoFactorRequired: true, + body: addCustomOAuthBodySchema, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + 400: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [false] }, error: { type: 'string' } }, + required: ['success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { name } = this.bodyParams; + if (!name?.trim()) { + throw new Meteor.Error('error-name-param-not-provided', 'The parameter "name" is required'); + } - await addOAuthServiceMethod(this.userId, this.bodyParams.name); + await addOAuthServiceMethod(this.userId, name); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.get( 'settings', - { authRequired: true, validateParams: isSettingsGetParams }, { - async get() { - const { includeDefaults } = this.queryParams; - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); + authRequired: true, + query: isSettingsGetParams, + response: { + 200: settingsListResponseSchema, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { includeDefaults } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); - let ourQuery: Parameters[0] = { - hidden: { $ne: true }, - }; + let ourQuery: Parameters[0] = { + hidden: { $ne: true }, + }; - if (!(await hasPermissionAsync(this.userId, 'view-privileged-setting'))) { - ourQuery.public = true; - } + if (!(await hasPermissionAsync(this.userId, 'view-privileged-setting'))) { + ourQuery.public = true; + } - ourQuery = Object.assign({}, query, ourQuery); + ourQuery = Object.assign({}, query, ourQuery); - // Note: change this when `fields` gets removed - if (includeDefaults) { - fields.packageValue = 1; - } + if (includeDefaults) { + fields.packageValue = 1; + } - const { settings, totalCount: total } = await fetchSettings(ourQuery, sort, offset, count, fields); + const { settings: settingsList, totalCount: total } = await fetchSettings(ourQuery, sort, offset, count, fields); - return API.v1.success({ - settings, - count: settings.length, - offset, - total, - }); - }, + return API.v1.success({ + settings: settingsList, + count: settingsList.length, + offset, + total, + }); }, ); -API.v1.addRoute( +API.v1.get( 'settings/:_id', { authRequired: true, permissionsRequired: { GET: { permissions: ['view-privileged-setting'], operation: 'hasAll' }, - POST: { permissions: ['edit-privileged-setting'], operation: 'hasAll' }, }, + response: { + 200: settingByIdGetResponseSchema, + 400: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [false] } }, + required: ['success'], + additionalProperties: true, + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { _id } = this.urlParams; + const setting = await Settings.findOneNotHiddenById(_id); + if (!setting) { + return API.v1.failure(); + } + return API.v1.success(_.pick(setting, '_id', 'value')); }, +); + +API.v1.post( + 'settings/:_id', { - async get() { - const setting = await Settings.findOneNotHiddenById(this.urlParams._id); - if (!setting) { - return API.v1.failure(); - } - return API.v1.success(_.pick(setting, '_id', 'value')); + authRequired: true, + permissionsRequired: { + POST: { permissions: ['edit-privileged-setting'], operation: 'hasAll' }, }, - post: { - twoFactorRequired: true, - async action(): Promise> { - if (typeof this.urlParams._id !== 'string') { - throw new Meteor.Error('error-id-param-not-provided', 'The parameter "id" is required'); - } + twoFactorRequired: true, + body: settingsUpdateBodySchema, + response: { + 200: settingByIdPostResponseSchema, + 400: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [false] } }, + required: ['success'], + additionalProperties: true, + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { _id } = this.urlParams; + if (typeof _id !== 'string') { + throw new Meteor.Error('error-id-param-not-provided', 'The parameter "id" is required'); + } - // Disable custom scripts in cloud trials to prevent phishing campaigns - if (disableCustomScripts() && /^Custom_Script_/.test(this.urlParams._id)) { - return API.v1.forbidden(); - } + if (disableCustomScripts() && /^Custom_Script_/.test(_id)) { + return API.v1.forbidden('Custom scripts are disabled'); + } - // allow special handling of particular setting types - const setting = await Settings.findOneNotHiddenById(this.urlParams._id); + const setting = await Settings.findOneNotHiddenById(_id); - if (!setting) { - return API.v1.failure(); - } + if (!setting) { + return API.v1.failure(); + } - if (isSettingAction(setting) && isSettingsUpdatePropsActions(this.bodyParams) && this.bodyParams.execute) { - // execute the configured method - await Meteor.callAsync(setting.value); - return API.v1.success(); - } + const { bodyParams } = this; - const auditSettingOperation = updateAuditedByUser({ - _id: this.userId, - username: this.user.username!, - ip: this.requestIp, - useragent: this.request.headers.get('user-agent') || '', - }); + if (isSettingAction(setting) && isSettingsUpdatePropsActions(bodyParams) && bodyParams.execute) { + await Meteor.callAsync(setting.value); + return API.v1.success(); + } - if (isSettingColor(setting) && isSettingsUpdatePropsColor(this.bodyParams)) { - const updateOptionsPromise = Settings.updateOptionsById(this.urlParams._id, { editor: this.bodyParams.editor }); - const updateValuePromise = auditSettingOperation(Settings.updateValueNotHiddenById, this.urlParams._id, this.bodyParams.value); + const auditSettingOperation = updateAuditedByUser({ + _id: this.userId, + username: this.user.username, + ip: this.requestIp ?? '', + useragent: this.request.headers.get('user-agent') ?? '', + }); - const [updateOptionsResult, updateValueResult] = await Promise.all([updateOptionsPromise, updateValuePromise]); + if (isSettingColor(setting) && isSettingsUpdatePropsColor(bodyParams)) { + const updateOptionsPromise = Settings.updateOptionsById(_id, { editor: bodyParams.editor }); + const updateValuePromise = auditSettingOperation(Settings.updateValueNotHiddenById, _id, bodyParams.value); - if (updateOptionsResult.modifiedCount || updateValueResult.modifiedCount) { - await notifyOnSettingChangedById(this.urlParams._id); - } + const [updateOptionsResult, updateValueResult] = await Promise.all([updateOptionsPromise, updateValuePromise]); - return API.v1.success(); - } + if (updateOptionsResult.modifiedCount || updateValueResult.modifiedCount) { + await notifyOnSettingChangedById(_id); + } - if (isSettingsUpdatePropDefault(this.bodyParams)) { - checkSettingValueBounds(setting, this.bodyParams.value); + return API.v1.success(); + } - const { matchedCount } = await auditSettingOperation( - Settings.updateValueNotHiddenById, - this.urlParams._id, - this.bodyParams.value, - ); + if (isSettingsUpdatePropDefault(bodyParams)) { + checkSettingValueBounds(setting, bodyParams.value); - if (!matchedCount) { - return API.v1.failure(); - } + const { matchedCount } = await auditSettingOperation(Settings.updateValueNotHiddenById, _id, bodyParams.value); + + if (!matchedCount) { + return API.v1.failure(); + } - const s = await Settings.findOneNotHiddenById(this.urlParams._id); - if (!s) { - return API.v1.failure(); - } + const s = await Settings.findOneNotHiddenById(_id); + if (!s) { + return API.v1.failure(); + } - settings.set(s); - setValue(this.urlParams._id, this.bodyParams.value); + settings.set(s); + setValue(_id, bodyParams.value); - await notifyOnSettingChanged(s); + await notifyOnSettingChanged(s); - return API.v1.success(); - } + return API.v1.success(); + } - return API.v1.failure(); - }, - }, + return API.v1.failure(); }, ); -API.v1.addRoute( +API.v1.get( 'service.configurations', - { authRequired: false }, { - async get() { - return API.v1.success({ - configurations: await LoginServiceConfigurationModel.find({}, { projection: { secret: 0 } }).toArray(), - }); + authRequired: false, + response: { + 200: serviceConfigurationsResponseSchema, }, }, + async function action() { + return API.v1.success({ + configurations: await LoginServiceConfigurationModel.find({}, { projection: { secret: 0 } }).toArray(), + }); + }, ); diff --git a/apps/meteor/app/api/server/v1/subscriptions.ts b/apps/meteor/app/api/server/v1/subscriptions.ts index b6e406bbfc969..375107c016cd6 100644 --- a/apps/meteor/app/api/server/v1/subscriptions.ts +++ b/apps/meteor/app/api/server/v1/subscriptions.ts @@ -1,9 +1,12 @@ import { Rooms, Subscriptions } from '@rocket.chat/models'; import { + ajv, isSubscriptionsGetProps, isSubscriptionsGetOneProps, isSubscriptionsReadProps, isSubscriptionsUnreadProps, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; @@ -12,56 +15,86 @@ import { getSubscriptions } from '../../../../server/publications/subscription'; import { unreadMessages } from '../../../message-mark-as-unread/server/unreadMessages'; import { API } from '../api'; -API.v1.addRoute( +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: true, +}); + +API.v1.get( 'subscriptions.get', { authRequired: true, - validateParams: isSubscriptionsGetProps, + 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, + }), + 401: validateUnauthorizedErrorResponse, + }, }, - { - async get() { - const { updatedSince } = this.queryParams; - - let updatedSinceDate: Date | undefined; - if (updatedSince) { - if (isNaN(Date.parse(updatedSince as string))) { - throw new Meteor.Error('error-roomId-param-invalid', 'The "lastUpdate" query parameter must be a valid date.'); - } - updatedSinceDate = new Date(updatedSince as string); + async function action() { + const { updatedSince } = this.queryParams; + + let updatedSinceDate: Date | undefined; + if (updatedSince) { + const updatedSinceStr = String(updatedSince); + if (isNaN(Date.parse(updatedSinceStr))) { + throw new Meteor.Error('error-roomId-param-invalid', 'The "lastUpdate" query parameter must be a valid date.'); } + updatedSinceDate = new Date(updatedSinceStr); + } - const result = await getSubscriptions(this.userId, updatedSinceDate); + const result = await getSubscriptions(this.userId, updatedSinceDate); - return API.v1.success( - Array.isArray(result) - ? { - update: result, - remove: [], - } - : result, - ); - }, + return API.v1.success( + Array.isArray(result) + ? { + update: result, + remove: [], + } + : result, + ); }, ); -API.v1.addRoute( +API.v1.get( 'subscriptions.getOne', { authRequired: true, - validateParams: isSubscriptionsGetOneProps, + query: isSubscriptionsGetOneProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + subscription: { type: 'object', nullable: true }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['subscription', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async get() { - const { roomId } = this.queryParams; + async function action() { + const { roomId } = this.queryParams; - if (!roomId) { - return API.v1.failure("The 'roomId' param is required"); - } + if (!roomId) { + return API.v1.failure("The 'roomId' param is required"); + } - return API.v1.success({ - subscription: await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId), - }); - }, + return API.v1.success({ + subscription: await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId), + }); }, ); @@ -74,44 +107,50 @@ API.v1.addRoute( - rid: The rid of the room to be marked as read. - roomId: Alternative for rid. */ -API.v1.addRoute( +API.v1.post( 'subscriptions.read', { authRequired: true, - validateParams: isSubscriptionsReadProps, + body: isSubscriptionsReadProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const { readThreads = false } = this.bodyParams; - const roomId = 'rid' in this.bodyParams ? this.bodyParams.rid : this.bodyParams.roomId; + async function action() { + const { readThreads = false } = this.bodyParams; + const roomId = 'rid' in this.bodyParams ? this.bodyParams.rid : this.bodyParams.roomId; - const room = await Rooms.findOneById(roomId); - if (!room) { - throw new Error('error-invalid-subscription'); - } + const room = await Rooms.findOneById(roomId); + if (!room) { + throw new Error('error-invalid-subscription'); + } - await readMessages(room, this.userId, readThreads); + await readMessages(room, this.userId, readThreads); - return API.v1.success(); - }, + return API.v1.success({}); }, ); -API.v1.addRoute( +API.v1.post( 'subscriptions.unread', { authRequired: true, - validateParams: isSubscriptionsUnreadProps, - }, - { - async post() { - await unreadMessages( - this.userId, - 'firstUnreadMessage' in this.bodyParams ? this.bodyParams.firstUnreadMessage : undefined, - 'roomId' in this.bodyParams ? this.bodyParams.roomId : undefined, - ); - - return API.v1.success(); + body: isSubscriptionsUnreadProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + await unreadMessages( + this.userId, + 'firstUnreadMessage' in this.bodyParams ? this.bodyParams.firstUnreadMessage : undefined, + 'roomId' in this.bodyParams ? this.bodyParams.roomId : undefined, + ); + + 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 8c741e7dd9b13..0281d0f7a8080 100644 --- a/apps/meteor/app/api/server/v1/teams.ts +++ b/apps/meteor/app/api/server/v1/teams.ts @@ -132,7 +132,7 @@ API.v1.addRoute( const rooms = await Team.getMatchingTeamRooms(team._id, roomsToRemove); if (rooms.length) { - for await (const room of rooms) { + for (const room of rooms) { await eraseRoom(room, this.user); } } diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 88b6b67e3b442..50c65abcd8d12 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -19,6 +19,8 @@ import { isUsersCheckUsernameAvailabilityParamsGET, isUsersSendConfirmationEmailParamsPOST, ajv, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; @@ -97,20 +99,6 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'users.getAvatarSuggestion', - { - authRequired: true, - }, - { - async get() { - const suggestions = await getAvatarSuggestionForUser(this.user); - - return API.v1.success({ suggestions }); - }, - }, -); - API.v1.addRoute( 'users.update', { authRequired: true, twoFactorRequired: true, validateParams: isUsersUpdateParamsPOST }, @@ -125,7 +113,7 @@ API.v1.addRoute( _id: this.user._id, ip: this.requestIp, useragent: this.request.headers.get('user-agent') || '', - username: this.user.username || '', + username: this.user.username, }); await saveUser(this.userId, userData, { auditStore }); @@ -155,6 +143,7 @@ API.v1.addRoute( 'users.updateOwnBasicInfo', { authRequired: true, + userWithoutUsername: true, validateParams: isUsersUpdateOwnBasicInfoParamsPOST, rateLimiterOptions: { numRequestsAllowed: 1, @@ -764,72 +753,132 @@ API.v1.addRoute( }, ); -const usersEndpoints = API.v1.post( - 'users.createToken', - { - authRequired: true, - body: ajv.compile<{ userId: string; secret: string }>({ - type: 'object', - properties: { - userId: { - type: 'string', - minLength: 1, - }, - secret: { - type: 'string', - minLength: 1, - }, - }, - required: ['userId', 'secret'], - additionalProperties: false, - }), - response: { - 200: ajv.compile<{ data: { userId: string; authToken: string } }>({ +const usersEndpoints = API.v1 + .post( + 'users.createToken', + { + authRequired: true, + body: ajv.compile<{ userId: string; secret: string }>({ type: 'object', properties: { - data: { - type: 'object', - properties: { - userId: { - type: 'string', - minLength: 1, - }, - authToken: { - type: 'string', - minLength: 1, - }, - }, - required: ['userId'], - additionalProperties: false, + userId: { + type: 'string', + minLength: 1, }, - success: { - type: 'boolean', - enum: [true], + secret: { + type: 'string', + minLength: 1, }, }, - required: ['data', 'success'], - additionalProperties: false, - }), - 400: ajv.compile({ - type: 'object', - properties: { - success: { type: 'boolean', enum: [false] }, - error: { type: 'string' }, - errorType: { type: 'string' }, - }, - required: ['success'], + required: ['userId', 'secret'], additionalProperties: false, }), + response: { + 200: ajv.compile<{ data: { userId: string; authToken: string } }>({ + type: 'object', + properties: { + data: { + type: 'object', + properties: { + userId: { + type: 'string', + minLength: 1, + }, + authToken: { + type: 'string', + minLength: 1, + }, + }, + required: ['userId'], + additionalProperties: false, + }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['data', 'success'], + additionalProperties: false, + }), + 400: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [false] }, + error: { type: 'string' }, + errorType: { type: 'string' }, + }, + required: ['success'], + additionalProperties: false, + }), + }, }, - }, - async function action() { - const user = await getUserFromParams(this.bodyParams); + async function action() { + const user = await getUserFromParams(this.bodyParams); - const data = await generateAccessToken(user._id, this.bodyParams.secret); + const data = await generateAccessToken(user._id, this.bodyParams.secret); - return API.v1.success({ data }); - }, -); + return API.v1.success({ data }); + }, + ) + .get( + 'users.getAvatarSuggestion', + { + authRequired: true, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ + suggestions: Record< + string, + { + blob: string; + contentType: string; + service: string; + url: string; + } + >; + }>({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + suggestions: { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + blob: { + type: 'string', + }, + contentType: { + type: 'string', + }, + service: { + type: 'string', + }, + url: { + type: 'string', + format: 'uri', + }, + }, + required: ['blob', 'contentType', 'service', 'url'], + additionalProperties: false, + }, + }, + }, + required: ['success', 'suggestions'], + additionalProperties: false, + }), + }, + }, + async function action() { + const suggestions = await getAvatarSuggestionForUser(this.user); + + return API.v1.success({ suggestions }); + }, + ); API.v1.addRoute( 'users.getPreferences', @@ -874,7 +923,7 @@ API.v1.addRoute( API.v1.addRoute( 'users.getUsernameSuggestion', - { authRequired: true }, + { authRequired: true, userWithoutUsername: true }, { async get() { const result = await generateUsernameSuggestion(this.user); diff --git a/apps/meteor/app/api/server/v1/videoConference.ts b/apps/meteor/app/api/server/v1/videoConference.ts index 5036eed09cc2d..7975053eb73ad 100644 --- a/apps/meteor/app/api/server/v1/videoConference.ts +++ b/apps/meteor/app/api/server/v1/videoConference.ts @@ -1,11 +1,15 @@ import { VideoConf } from '@rocket.chat/core-services'; -import type { VideoConference } from '@rocket.chat/core-typings'; +import type { VideoConference, VideoConferenceCapabilities, VideoConferenceInstructions } from '@rocket.chat/core-typings'; import { + ajv, isVideoConfStartProps, isVideoConfJoinProps, isVideoConfCancelProps, isVideoConfInfoProps, isVideoConfListProps, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateBadRequestErrorResponse, } from '@rocket.chat/rest-typings'; import { availabilityErrors } from '../../../../lib/videoConference/constants'; @@ -16,179 +20,326 @@ import { hasPermissionAsync } from '../../../authorization/server/functions/hasP import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; -API.v1.addRoute( +const startResponseSchema = ajv.compile<{ data: VideoConferenceInstructions & { providerName: string } }>({ + type: 'object', + properties: { + data: { + allOf: [ + { + oneOf: [ + { $ref: '#/components/schemas/DirectCallInstructions' }, + { $ref: '#/components/schemas/ConferenceInstructions' }, + { $ref: '#/components/schemas/LivechatInstructions' }, + ], + }, + { type: 'object', properties: { providerName: { type: 'string' } }, required: ['providerName'] }, + ], + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['data', 'success'], + additionalProperties: false, +}); + +const joinResponseSchema = ajv.compile<{ url: string; providerName: string }>({ + type: 'object', + properties: { + url: { type: 'string' }, + providerName: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['url', 'providerName', 'success'], + additionalProperties: false, +}); + +const cancelResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +const infoResponseSchema = ajv.compile({ + type: 'object', + properties: { + capabilities: { $ref: '#/components/schemas/VideoConferenceCapabilities' }, + }, + additionalProperties: true, +}); + +const listResponseSchema = ajv.compile<{ data: VideoConference[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + data: { + type: 'array', + items: { + oneOf: [ + { $ref: '#/components/schemas/IDirectVideoConference' }, + { $ref: '#/components/schemas/IGroupVideoConference' }, + { $ref: '#/components/schemas/ILivechatVideoConference' }, + { $ref: '#/components/schemas/IVoIPVideoConference' }, + ], + }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['data', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +const providersResponseSchema = ajv.compile<{ data: { key: string; label: string }[] }>({ + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { key: { type: 'string' }, label: { type: 'string' } }, + required: ['key', 'label'], + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['data', 'success'], + additionalProperties: false, +}); + +const capabilitiesResponseSchema = ajv.compile<{ providerName: string; capabilities: VideoConferenceCapabilities }>({ + type: 'object', + properties: { + providerName: { type: 'string' }, + capabilities: { $ref: '#/components/schemas/VideoConferenceCapabilities' }, + }, + additionalProperties: true, +}); + +API.v1.post( 'video-conference.start', - { authRequired: true, validateParams: isVideoConfStartProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60000 } }, { - async post() { - const { roomId, title, allowRinging: requestRinging } = this.bodyParams; - const { userId } = this; + authRequired: true, + body: isVideoConfStartProps, + rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60000 }, + response: { + 200: startResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { roomId, title, allowRinging: requestRinging } = this.bodyParams; + const { userId } = this; + + if (!(await hasPermissionAsync(userId, 'call-management', roomId))) { + return API.v1.forbidden('Not allowed'); + } + + try { + await canSendMessageAsync(roomId, { + uid: userId, + username: this.user.username, + type: this.user.type ?? 'user', + }); + } catch { + return API.v1.forbidden('Not allowed'); + } - if (!(await hasPermissionAsync(userId, 'call-management', roomId))) { - return API.v1.forbidden(); - } + try { + const providerName = videoConfProviders.getActiveProvider(); - try { - await canSendMessageAsync(roomId, { uid: userId, username: this.user.username!, type: this.user.type! }); - } catch (error) { - return API.v1.forbidden(); + if (!providerName) { + throw new Error(availabilityErrors.NOT_ACTIVE); } - try { - const providerName = videoConfProviders.getActiveProvider(); - - if (!providerName) { - throw new Error(availabilityErrors.NOT_ACTIVE); - } + const allowRinging = Boolean(requestRinging) && (await hasPermissionAsync(userId, 'videoconf-ring-users')); - const allowRinging = Boolean(requestRinging) && (await hasPermissionAsync(userId, 'videoconf-ring-users')); - - return API.v1.success({ - data: { - ...(await VideoConf.start(userId, roomId, { title, allowRinging })), - providerName, - }, - }); - } catch (e) { - return API.v1.failure(await VideoConf.diagnoseProvider(userId, roomId)); - } - }, + return API.v1.success({ + data: { + ...(await VideoConf.start(userId, roomId, { title, allowRinging })), + providerName, + }, + }); + } catch (e) { + return API.v1.failure(await VideoConf.diagnoseProvider(userId, roomId)); + } }, ); -API.v1.addRoute( +API.v1.post( 'video-conference.join', - { authOrAnonRequired: true, validateParams: isVideoConfJoinProps, rateLimiterOptions: { numRequestsAllowed: 2, intervalTimeInMS: 5000 } }, { - async post() { - const { callId, state } = this.bodyParams; - const { userId } = this; + authOrAnonRequired: true, + body: isVideoConfJoinProps, + rateLimiterOptions: { numRequestsAllowed: 2, intervalTimeInMS: 5000 }, + response: { + 200: joinResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { callId, state } = this.bodyParams; + const { userId } = this; - const call = await VideoConf.get(callId); - if (!call) { - return API.v1.failure('invalid-params'); - } + const call = await VideoConf.get(callId); + if (!call) { + return API.v1.failure('invalid-params'); + } - if (!(await canAccessRoomIdAsync(call.rid, userId))) { - return API.v1.failure('invalid-params'); - } + if (!(await canAccessRoomIdAsync(call.rid, userId))) { + return API.v1.failure('invalid-params'); + } - let url: string | undefined; - - try { - url = await VideoConf.join(userId, callId, { - ...(state?.cam !== undefined ? { cam: state.cam } : {}), - ...(state?.mic !== undefined ? { mic: state.mic } : {}), - }); - } catch (e) { - if (userId) { - return API.v1.failure(await VideoConf.diagnoseProvider(userId, call.rid, call.providerName)); - } - } + let url: string | undefined; - if (!url) { - return API.v1.failure('failed-to-get-url'); + try { + url = await VideoConf.join(userId, callId, { + ...(state?.cam !== undefined ? { cam: state.cam } : {}), + ...(state?.mic !== undefined ? { mic: state.mic } : {}), + }); + } catch (e) { + if (userId) { + return API.v1.failure(await VideoConf.diagnoseProvider(userId, call.rid, call.providerName)); } + } - return API.v1.success({ - url, - providerName: call.providerName, - }); - }, + if (!url) { + return API.v1.failure('failed-to-get-url'); + } + + return API.v1.success({ + url, + providerName: call.providerName, + }); }, ); -API.v1.addRoute( +API.v1.post( 'video-conference.cancel', - { authRequired: true, validateParams: isVideoConfCancelProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60000 } }, { - async post() { - const { callId } = this.bodyParams; - const { userId } = this; + authRequired: true, + body: isVideoConfCancelProps, + rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60000 }, + response: { + 200: cancelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { callId } = this.bodyParams; + const { userId } = this; - const call = await VideoConf.get(callId); - if (!call) { - return API.v1.failure('invalid-params'); - } + const call = await VideoConf.get(callId); + if (!call) { + return API.v1.failure('invalid-params'); + } - if (!userId || !(await canAccessRoomIdAsync(call.rid, userId))) { - return API.v1.failure('invalid-params'); - } + if (!userId || !(await canAccessRoomIdAsync(call.rid, userId))) { + return API.v1.failure('invalid-params'); + } - await VideoConf.cancel(userId, callId); - return API.v1.success(); - }, + await VideoConf.cancel(userId, callId); + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.get( 'video-conference.info', - { authRequired: true, validateParams: isVideoConfInfoProps, rateLimiterOptions: { numRequestsAllowed: 15, intervalTimeInMS: 3000 } }, { - async get() { - const { callId } = this.queryParams; - const { userId } = this; + authRequired: true, + query: isVideoConfInfoProps, + rateLimiterOptions: { numRequestsAllowed: 15, intervalTimeInMS: 3000 }, + response: { + 200: infoResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { callId } = this.queryParams; + const { userId } = this; - const call = await VideoConf.get(callId); - if (!call) { - return API.v1.failure('invalid-params'); - } + const call = await VideoConf.get(callId); + if (!call) { + return API.v1.failure('invalid-params'); + } - if (!userId || !(await canAccessRoomIdAsync(call.rid, userId))) { - return API.v1.failure('invalid-params'); - } + if (!userId || !(await canAccessRoomIdAsync(call.rid, userId))) { + return API.v1.failure('invalid-params'); + } - const capabilities = await VideoConf.listProviderCapabilities(call.providerName); + const capabilities = await VideoConf.listProviderCapabilities(call.providerName); - return API.v1.success({ - ...(call as VideoConference), - capabilities, - }); - }, + return API.v1.success({ + ...(call as VideoConference), + capabilities, + }); }, ); -API.v1.addRoute( +API.v1.get( 'video-conference.list', - { authRequired: true, validateParams: isVideoConfListProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 } }, { - async get() { - const { roomId } = this.queryParams; - const { userId } = this; + authRequired: true, + query: isVideoConfListProps, + rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 }, + response: { + 200: listResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId } = this.queryParams; + const { userId } = this; - const { offset, count } = await getPaginationItems(this.queryParams); + const { offset, count } = await getPaginationItems(this.queryParams); - if (!userId || !(await canAccessRoomIdAsync(roomId, userId))) { - return API.v1.failure('invalid-params'); - } + if (!userId || !(await canAccessRoomIdAsync(roomId, userId))) { + return API.v1.failure('invalid-params'); + } - const data = await VideoConf.list(roomId, { offset, count }); + const data = await VideoConf.list(roomId, { offset, count }); - return API.v1.success(data); - }, + return API.v1.success(data); }, ); -API.v1.addRoute( +API.v1.get( 'video-conference.providers', - { authRequired: true, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 } }, { - async get() { - const data = await VideoConf.listProviders(); - - return API.v1.success({ data }); + authRequired: true, + rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 }, + response: { + 200: providersResponseSchema, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const data = await VideoConf.listProviders(); + + return API.v1.success({ data }); + }, ); -API.v1.addRoute( +API.v1.get( 'video-conference.capabilities', - { authRequired: true, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 } }, { - async get() { - const data = await VideoConf.listCapabilities(); - - return API.v1.success(data); + authRequired: true, + rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 }, + response: { + 200: capabilitiesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const data = await VideoConf.listCapabilities(); + + return API.v1.success(data); + }, ); diff --git a/apps/meteor/app/apps/server/bridges/rooms.ts b/apps/meteor/app/apps/server/bridges/rooms.ts index ec0f6c8b3bc77..473614edfe25c 100644 --- a/apps/meteor/app/apps/server/bridges/rooms.ts +++ b/apps/meteor/app/apps/server/bridges/rooms.ts @@ -237,7 +237,7 @@ export class AppRoomBridge extends RoomBridge { throw new Error('Room id not found'); } - for await (const username of members) { + for (const username of members) { const member = await Users.findOneByUsername(username, {}); if (!member) { diff --git a/apps/meteor/app/apps/server/bridges/videoConferences.ts b/apps/meteor/app/apps/server/bridges/videoConferences.ts index efab0f201f873..8986c15a3c6dd 100644 --- a/apps/meteor/app/apps/server/bridges/videoConferences.ts +++ b/apps/meteor/app/apps/server/bridges/videoConferences.ts @@ -42,7 +42,7 @@ export class AppVideoConferenceBridge extends VideoConferenceBridge { const data = (this.orch.getConverters()?.get('videoConferences') as AppVideoConferencesConverter).convertAppVideoConference(call); await VideoConf.setProviderData(call._id, data.providerData); - for await (const { _id, ts } of data.users) { + for (const { _id, ts } of data.users) { if (oldData.users.find((user) => user._id === _id)) { continue; } diff --git a/apps/meteor/app/apps/server/converters/transformMappedData.ts b/apps/meteor/app/apps/server/converters/transformMappedData.ts index f18a89df11ee5..99b9d2d666e16 100644 --- a/apps/meteor/app/apps/server/converters/transformMappedData.ts +++ b/apps/meteor/app/apps/server/converters/transformMappedData.ts @@ -105,7 +105,7 @@ export const transformMappedData = async < const originalData: DataType = structuredClone(data); const transformedData: Record = {}; - for await (const [to, from] of Object.entries(map)) { + for (const [to, from] of Object.entries(map)) { if (typeof from === 'function') { const result = await from(originalData); diff --git a/apps/meteor/app/assets/server/assets.ts b/apps/meteor/app/assets/server/assets.ts index 34c274564085b..9266f21cc0218 100644 --- a/apps/meteor/app/assets/server/assets.ts +++ b/apps/meteor/app/assets/server/assets.ts @@ -390,7 +390,7 @@ export async function addAssetToSetting(asset: string, value: IRocketChatAsset, } void (async () => { - for await (const key of Object.keys(assets)) { + for (const key of Object.keys(assets)) { const { wizard, settingOptions, ...value } = getAssetByKey(key); await addAssetToSetting(key, value, { ...settingOptions, wizard }); } diff --git a/apps/meteor/app/authentication/server/startup/index.js b/apps/meteor/app/authentication/server/startup/index.js index 6c23092761b07..3e25537c38ed2 100644 --- a/apps/meteor/app/authentication/server/startup/index.js +++ b/apps/meteor/app/authentication/server/startup/index.js @@ -41,6 +41,19 @@ Accounts.config({ */ Object.assign(Accounts._defaultPublishFields.projection, (({ status, ...rest }) => rest)(getBaseUserFields(true))); +// Override Meteor's _expireTokens to ensure the correct login expiration is used. +// If loginExpirationInDays is not set (e.g., startup was disrupted before settings watcher fired), +// read the setting directly from MongoDB before proceeding with token cleanup. +// This prevents tokens from being incorrectly deleted using Meteor's 90-day default. +const { _expireTokens } = Accounts; +Accounts._expireTokens = async function (oldestValidDate, userId) { + if (!Accounts._options.loginExpirationInDays) { + const loginExpiration = await Settings.getValueById('Accounts_LoginExpiration'); + Accounts._options.loginExpirationInDays = getLoginExpirationInDays(loginExpiration); + } + return _expireTokens.call(Accounts, oldestValidDate, userId); +}; + Meteor.startup(() => { settings.watchMultiple(['Accounts_LoginExpiration', 'Site_Name', 'From_Email'], () => { Accounts._options.loginExpirationInDays = getLoginExpirationInDays(settings.get('Accounts_LoginExpiration')); @@ -293,6 +306,11 @@ Accounts.insertUserDoc = async function (options, user) { delete user.globalRoles; + // for some reason, the name is not being set in the user object but is being set in the options object + if (options.name && typeof options.name === 'string') { + user.name = options.name; + } + if (user.services && !user.services.password && !options.skipAuthServiceDefaultRoles) { const defaultAuthServiceRoles = parseCSV(settings.get('Accounts_Registration_AuthenticationServices_Default_Roles') || ''); @@ -367,7 +385,7 @@ Accounts.insertUserDoc = async function (options, user) { } if (!options.skipDefaultAvatar && settings.get('Accounts_SetDefaultAvatar') === true) { const avatarSuggestions = await getAvatarSuggestionForUser(user); - for await (const service of Object.keys(avatarSuggestions)) { + for (const service of Object.keys(avatarSuggestions)) { const avatarData = avatarSuggestions[service]; if (service !== 'gravatar') { await setAvatarFromServiceWithValidation(_id, avatarData.blob, '', service); diff --git a/apps/meteor/app/authorization/server/constant/permissions.ts b/apps/meteor/app/authorization/server/constant/permissions.ts index 600bf15793cc9..c54f84c03fac2 100644 --- a/apps/meteor/app/authorization/server/constant/permissions.ts +++ b/apps/meteor/app/authorization/server/constant/permissions.ts @@ -19,16 +19,16 @@ export const permissions = [ { _id: 'ban-user', roles: ['admin', 'owner', 'moderator'] }, { _id: 'bulk-register-user', roles: ['admin'] }, { _id: 'change-livechat-room-visitor', roles: ['admin', 'livechat-manager', 'livechat-agent'] }, - { _id: 'create-c', roles: ['admin', 'user', 'bot', 'app'] }, - { _id: 'create-d', roles: ['admin', 'user', 'bot', 'app'] }, - { _id: 'create-p', roles: ['admin', 'user', 'bot', 'app'] }, + { _id: 'create-c', roles: ['admin', 'user', 'federated-external', 'bot', 'app'] }, + { _id: 'create-d', roles: ['admin', 'user', 'federated-external', 'bot', 'app'] }, + { _id: 'create-p', roles: ['admin', 'user', 'federated-external', 'bot', 'app'] }, { _id: 'create-personal-access-tokens', roles: ['admin', 'user'] }, { _id: 'create-user', roles: ['admin'] }, { _id: 'clean-channel-history', roles: ['admin'] }, { _id: 'delete-c', roles: ['admin', 'owner'] }, { _id: 'delete-d', roles: ['admin'] }, { _id: 'delete-message', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'delete-own-message', roles: ['admin', 'user'] }, + { _id: 'delete-own-message', roles: ['admin', 'user', 'federated-external'] }, { _id: 'delete-p', roles: ['admin', 'owner'] }, { _id: 'delete-user', roles: ['admin'] }, { _id: 'edit-message', roles: ['admin', 'owner', 'moderator'] }, @@ -44,8 +44,8 @@ export const permissions = [ { _id: 'edit-room-retention-policy', roles: ['admin'] }, { _id: 'force-delete-message', roles: ['admin', 'owner'] }, { _id: 'join-without-join-code', roles: ['admin', 'bot', 'app'] }, - { _id: 'leave-c', roles: ['admin', 'user', 'bot', 'anonymous', 'app'] }, - { _id: 'leave-p', roles: ['admin', 'user', 'bot', 'anonymous', 'app'] }, + { _id: 'leave-c', roles: ['admin', 'user', 'federated-external', 'bot', 'anonymous', 'app'] }, + { _id: 'leave-p', roles: ['admin', 'user', 'federated-external', 'bot', 'anonymous', 'app'] }, { _id: 'logout-other-user', roles: ['admin'] }, { _id: 'manage-assets', roles: ['admin'] }, { _id: 'manage-email-inbox', roles: ['admin'] }, @@ -57,8 +57,8 @@ export const permissions = [ { _id: 'manage-own-incoming-integrations', roles: ['admin'] }, { _id: 'manage-oauth-apps', roles: ['admin'] }, { _id: 'manage-selected-settings', roles: ['admin'] }, - { _id: 'mention-all', roles: ['admin', 'owner', 'moderator', 'user'] }, - { _id: 'mention-here', roles: ['admin', 'owner', 'moderator', 'user'] }, + { _id: 'mention-all', roles: ['admin', 'owner', 'moderator', 'user', 'federated-external'] }, + { _id: 'mention-here', roles: ['admin', 'owner', 'moderator', 'user', 'federated-external'] }, { _id: 'mute-user', roles: ['admin', 'owner', 'moderator'] }, { _id: 'remove-user', roles: ['admin', 'owner', 'moderator'] }, { _id: 'run-import', roles: ['admin'] }, @@ -67,12 +67,12 @@ export const permissions = [ { _id: 'set-owner', roles: ['admin', 'owner'] }, { _id: 'send-many-messages', roles: ['admin', 'bot', 'app'] }, { _id: 'set-leader', roles: ['admin', 'owner'] }, - { _id: 'start-discussion', roles: ['admin', 'user', 'guest', 'app'] }, - { _id: 'start-discussion-other-user', roles: ['admin', 'user', 'owner', 'app'] }, + { _id: 'start-discussion', roles: ['admin', 'user', 'federated-external', 'guest', 'app'] }, + { _id: 'start-discussion-other-user', roles: ['admin', 'user', 'federated-external', 'owner', 'app'] }, { _id: 'unarchive-room', roles: ['admin'] }, - { _id: 'view-c-room', roles: ['admin', 'user', 'bot', 'app', 'anonymous'] }, + { _id: 'view-c-room', roles: ['admin', 'user', 'federated-external', 'bot', 'app', 'anonymous'] }, { _id: 'user-generate-access-token', roles: ['admin'] }, - { _id: 'view-d-room', roles: ['admin', 'user', 'bot', 'app', 'guest'] }, + { _id: 'view-d-room', roles: ['admin', 'user', 'federated-external', 'bot', 'app', 'guest'] }, { _id: 'view-device-management', roles: ['admin'] }, { _id: 'view-engagement-dashboard', roles: ['admin'] }, { _id: 'view-full-other-user-info', roles: ['admin'] }, @@ -80,13 +80,13 @@ export const permissions = [ { _id: 'view-join-code', roles: ['admin'] }, { _id: 'view-logs', roles: ['admin'] }, { _id: 'view-other-user-channels', roles: ['admin'] }, - { _id: 'view-p-room', roles: ['admin', 'user', 'anonymous', 'guest'] }, + { _id: 'view-p-room', roles: ['admin', 'user', 'federated-external', 'anonymous', 'guest'] }, { _id: 'view-privileged-setting', roles: ['admin'] }, { _id: 'view-room-administration', roles: ['admin'] }, { _id: 'view-statistics', roles: ['admin'] }, { _id: 'view-user-administration', roles: ['admin'] }, - { _id: 'preview-c-room', roles: ['admin', 'user', 'anonymous'] }, - { _id: 'view-outside-room', roles: ['admin', 'owner', 'moderator', 'user'] }, + { _id: 'preview-c-room', roles: ['admin', 'user', 'federated-external', 'anonymous'] }, + { _id: 'view-outside-room', roles: ['admin', 'owner', 'moderator', 'user', 'federated-external'] }, { _id: 'view-broadcast-member-list', roles: ['admin', 'owner', 'moderator'] }, { _id: 'call-management', roles: ['admin', 'owner', 'moderator', 'user'] }, { _id: 'create-invite-links', roles: ['admin', 'owner', 'moderator'] }, @@ -220,10 +220,10 @@ export const permissions = [ { _id: 'manage-sounds', roles: ['admin'] }, { _id: 'access-mailer', roles: ['admin'] }, { _id: 'pin-message', roles: ['owner', 'moderator', 'admin'] }, - { _id: 'mobile-upload-file', roles: ['user', 'admin'] }, + { _id: 'mobile-upload-file', roles: ['user', 'federated-external', 'admin'] }, { _id: 'send-mail', roles: ['admin'] }, { _id: 'view-federation-data', roles: ['admin'] }, - { _id: 'access-federation', roles: ['admin', 'user'] }, + { _id: 'access-federation', roles: ['admin', 'user', 'federated-external'] }, { _id: 'add-all-to-room', roles: ['admin'] }, { _id: 'get-server-info', roles: ['admin'] }, { _id: 'register-on-cloud', roles: ['admin'] }, diff --git a/apps/meteor/app/authorization/server/functions/canSendMessage.ts b/apps/meteor/app/authorization/server/functions/canSendMessage.ts index b9d6b740c2ddd..5f5c97fda453d 100644 --- a/apps/meteor/app/authorization/server/functions/canSendMessage.ts +++ b/apps/meteor/app/authorization/server/functions/canSendMessage.ts @@ -13,9 +13,10 @@ const subscriptionOptions = { }, }; +// TODO: remove option uid and username and type export async function validateRoomMessagePermissionsAsync( room: IRoom | null, - { uid, username, type }: { uid: IUser['_id']; username: IUser['username']; type: IUser['type'] }, + args: { uid: IUser['_id']; username: IUser['username']; type: IUser['type'] } | IUser, extraData?: Record, ): Promise { if (!room) { @@ -25,33 +26,34 @@ export async function validateRoomMessagePermissionsAsync( if (room.archived) { throw new Error('room_is_archived'); } - - if (type !== 'app' && !(await canAccessRoomAsync(room, { _id: uid }, extraData))) { + if (args.type !== 'app' && !(await canAccessRoomAsync(room, 'uid' in args ? { _id: args.uid } : args, extraData))) { throw new Error('error-not-allowed'); } - if (await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.BLOCK, uid)) { - const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, uid, subscriptionOptions); + if ( + await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.BLOCK, 'uid' in args ? args.uid : args._id) + ) { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, 'uid' in args ? args.uid : args._id, subscriptionOptions); if (subscription && (subscription.blocked || subscription.blocker)) { throw new Error('room_is_blocked'); } } - if (room.ro === true && !(await hasPermissionAsync(uid, 'post-readonly', room._id))) { + if (room.ro === true && !(await hasPermissionAsync('uid' in args ? args.uid : args._id, 'post-readonly', room._id))) { // Unless the user was manually unmuted - if (username && !(room.unmuted || []).includes(username)) { + if (args.username && !(room.unmuted || []).includes(args.username)) { throw new Error("You can't send messages because the room is readonly."); } } - if (username && room?.muted?.includes(username)) { + if (args.username && room?.muted?.includes(args.username)) { throw new Error('You_have_been_muted'); } } - +// TODO: remove option uid and username and type export async function canSendMessageAsync( rid: IRoom['_id'], - { uid, username, type }: { uid: IUser['_id']; username: IUser['username']; type: IUser['type'] }, + user: { uid: IUser['_id']; username: IUser['username']; type: IUser['type'] } | IUser, extraData?: Record, ): Promise { const room = await Rooms.findOneById(rid); @@ -59,6 +61,6 @@ export async function canSendMessageAsync( throw new Error('error-invalid-room'); } - await validateRoomMessagePermissionsAsync(room, { uid, username, type }, extraData); + await validateRoomMessagePermissionsAsync(room, user, extraData); return room; } diff --git a/apps/meteor/app/authorization/server/functions/upsertPermissions.ts b/apps/meteor/app/authorization/server/functions/upsertPermissions.ts index bb908916c885c..015f14189f46c 100644 --- a/apps/meteor/app/authorization/server/functions/upsertPermissions.ts +++ b/apps/meteor/app/authorization/server/functions/upsertPermissions.ts @@ -8,7 +8,7 @@ import { getSettingPermissionId, CONSTANTS } from '../../lib'; import { permissions } from '../constant/permissions'; export const upsertPermissions = async (): Promise => { - for await (const permission of permissions) { + for (const permission of permissions) { await Permissions.create(permission._id, permission.roles); } @@ -18,6 +18,7 @@ export const upsertPermissions = async (): Promise => { { name: 'leader', scope: 'Subscriptions', description: 'Leader' }, { name: 'owner', scope: 'Subscriptions', description: 'Owner' }, { name: 'user', scope: 'Users', description: '' }, + { name: 'federated-external', scope: 'Users', description: '' }, { name: 'bot', scope: 'Users', description: '' }, { name: 'app', scope: 'Users', description: '' }, { name: 'guest', scope: 'Users', description: '' }, @@ -26,7 +27,7 @@ export const upsertPermissions = async (): Promise => { { name: 'livechat-manager', scope: 'Users', description: 'Livechat Manager' }, ] as const; - for await (const role of defaultRoles) { + for (const role of defaultRoles) { await createOrUpdateProtectedRoleAsync(role.name, role); } @@ -52,8 +53,9 @@ export const upsertPermissions = async (): Promise => { level: CONSTANTS.SETTINGS_LEVEL as 'settings' | undefined, // copy those setting-properties which are needed to properly publish the setting-based permissions settingId: setting._id, - group: setting.group, - section: setting.section ?? undefined, + // TODO: migrate settings with group and section with null to undefined + ...(setting.group && { group: setting.group }), + ...(setting.section && { section: setting.section }), sorter: setting.sorter, roles: [], }; @@ -100,7 +102,7 @@ export const upsertPermissions = async (): Promise => { } // remove permissions for non-existent settings - for await (const obsoletePermission of Object.keys(previousSettingPermissions)) { + for (const obsoletePermission of Object.keys(previousSettingPermissions)) { if (previousSettingPermissions.hasOwnProperty(obsoletePermission)) { await Permissions.deleteOne({ _id: obsoletePermission }); } diff --git a/apps/meteor/app/autotranslate/server/autotranslate.ts b/apps/meteor/app/autotranslate/server/autotranslate.ts index 1414fd0bebf8c..2f91e02463d58 100644 --- a/apps/meteor/app/autotranslate/server/autotranslate.ts +++ b/apps/meteor/app/autotranslate/server/autotranslate.ts @@ -319,7 +319,7 @@ export abstract class AutoTranslate { if (message.attachments && message.attachments.length > 0) { setImmediate(async () => { - for await (const [index, attachment] of message.attachments?.entries() ?? []) { + for (const [index, attachment] of message.attachments?.entries() ?? []) { if (attachment.description || attachment.text) { // Removes the initial link `[ ](quoterl)` from quote message before translation const translatedText = attachment?.text?.replace(/\[(.*?)\]\(.*?\)/g, '$1') || attachment?.text; diff --git a/apps/meteor/app/autotranslate/server/deeplTranslate.ts b/apps/meteor/app/autotranslate/server/deeplTranslate.ts index 5976f7a3e48e3..d76a7ea2e4901 100644 --- a/apps/meteor/app/autotranslate/server/deeplTranslate.ts +++ b/apps/meteor/app/autotranslate/server/deeplTranslate.ts @@ -137,7 +137,7 @@ class DeeplAutoTranslate extends AutoTranslate { const translations: { [k: string]: string } = {}; const msgs = message.msg.split('\n'); const supportedLanguages = await this.getSupportedLanguages('en'); - for await (let language of targetLanguages) { + for (let language of targetLanguages) { if (language.indexOf('-') !== -1 && !_.findWhere(supportedLanguages, { language })) { language = language.substr(0, 2); } @@ -185,7 +185,7 @@ class DeeplAutoTranslate extends AutoTranslate { async _translateAttachmentDescriptions(attachment: MessageAttachment, targetLanguages: string[]): Promise { const translations: { [k: string]: string } = {}; const supportedLanguages = await this.getSupportedLanguages('en'); - for await (let language of targetLanguages) { + for (let language of targetLanguages) { if (language.indexOf('-') !== -1 && !_.findWhere(supportedLanguages, { language })) { language = language.substr(0, 2); } diff --git a/apps/meteor/app/autotranslate/server/functions/translateMessage.ts b/apps/meteor/app/autotranslate/server/functions/translateMessage.ts index c3c9b0d8709df..8c0545369dcca 100644 --- a/apps/meteor/app/autotranslate/server/functions/translateMessage.ts +++ b/apps/meteor/app/autotranslate/server/functions/translateMessage.ts @@ -12,7 +12,15 @@ export const translateMessage = async (targetLanguage?: string, message?: IMessa } const room = await Rooms.findOneById(message?.rid); + let translatedMessage; + if (message && room) { - await TranslationProviderRegistry.translateMessage(message, room, targetLanguage); + translatedMessage = await TranslationProviderRegistry.translateMessage(message, room, targetLanguage); } + + if (!translatedMessage) { + return; + } + + return translatedMessage; }; diff --git a/apps/meteor/app/autotranslate/server/googleTranslate.ts b/apps/meteor/app/autotranslate/server/googleTranslate.ts index 4ffa557406154..9667ae53c967a 100644 --- a/apps/meteor/app/autotranslate/server/googleTranslate.ts +++ b/apps/meteor/app/autotranslate/server/googleTranslate.ts @@ -134,7 +134,7 @@ class GoogleAutoTranslate extends AutoTranslate { const supportedLanguages = await this.getSupportedLanguages('en'); - for await (let language of targetLanguages) { + for (let language of targetLanguages) { if (language.indexOf('-') !== -1 && !_.findWhere(supportedLanguages, { language })) { language = language.slice(0, 2); } @@ -157,8 +157,7 @@ class GoogleAutoTranslate extends AutoTranslate { if ( result.status === 200 && - body.data && - body.data.translations && + body.data?.translations && Array.isArray(body.data.translations) && body.data.translations.length > 0 ) { @@ -183,7 +182,7 @@ class GoogleAutoTranslate extends AutoTranslate { const translations: { [k: string]: string } = {}; const supportedLanguages = await this.getSupportedLanguages('en'); - for await (let language of targetLanguages) { + for (let language of targetLanguages) { if (language.indexOf('-') !== -1 && !_.findWhere(supportedLanguages, { language })) { language = language.slice(0, 2); } @@ -206,8 +205,7 @@ class GoogleAutoTranslate extends AutoTranslate { if ( result.status === 200 && - body.data && - body.data.translations && + body.data?.translations && Array.isArray(body.data.translations) && body.data.translations.length > 0 ) { diff --git a/apps/meteor/app/autotranslate/server/methods/translateMessage.ts b/apps/meteor/app/autotranslate/server/methods/translateMessage.ts index 7c8741782647c..c9d2cc43996be 100644 --- a/apps/meteor/app/autotranslate/server/methods/translateMessage.ts +++ b/apps/meteor/app/autotranslate/server/methods/translateMessage.ts @@ -7,7 +7,7 @@ import { translateMessage } from '../functions/translateMessage'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { - 'autoTranslate.translateMessage'(message: IMessage | undefined, targetLanguage: string): Promise; + 'autoTranslate.translateMessage'(message: IMessage | undefined, targetLanguage: string): Promise; } } diff --git a/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts b/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts index 56dbef2d1cdb1..0a9b282160619 100644 --- a/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts +++ b/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts @@ -104,14 +104,14 @@ const validators: RoomSettingsValidators = { return; } - if (value === 'c' && !room.teamId && !(await hasPermissionAsync(userId, 'create-c'))) { + if (value === 'c' && (!room.teamId || room.teamMain) && !(await hasPermissionAsync(userId, 'create-c'))) { throw new Meteor.Error('error-action-not-allowed', 'Changing a private group to a public channel is not allowed', { method: 'saveRoomSettings', action: 'Change_Room_Type', }); } - if (value === 'p' && !room.teamId && !(await hasPermissionAsync(userId, 'create-p'))) { + if (value === 'p' && (!room.teamId || room.teamMain) && !(await hasPermissionAsync(userId, 'create-p'))) { throw new Meteor.Error('error-action-not-allowed', 'Changing a public channel to a private room is not allowed', { method: 'saveRoomSettings', action: 'Change_Room_Type', @@ -125,7 +125,7 @@ const validators: RoomSettingsValidators = { }); } - if (!room.teamId) { + if (!room.teamId || room.teamMain) { return; } const team = await Team.getInfoById(room.teamId); @@ -489,7 +489,7 @@ export async function saveRoomSettings( } // validations - for await (const setting of Object.keys(settings) as (keyof RoomSettings)[]) { + for (const setting of Object.keys(settings) as (keyof RoomSettings)[]) { await validate(setting, { userId, value: settings[setting], @@ -506,7 +506,7 @@ export async function saveRoomSettings( } // saving data - for await (const setting of Object.keys(settings) as (keyof RoomSettings)[]) { + for (const setting of Object.keys(settings) as (keyof RoomSettings)[]) { await save(setting, { userId, user: user as RequiredField, diff --git a/apps/meteor/app/cloud/server/functions/saveRegistrationData.ts b/apps/meteor/app/cloud/server/functions/saveRegistrationData.ts index c820411775d37..acd1429cdb669 100644 --- a/apps/meteor/app/cloud/server/functions/saveRegistrationData.ts +++ b/apps/meteor/app/cloud/server/functions/saveRegistrationData.ts @@ -78,7 +78,7 @@ async function saveRegistrationDataBase({ // Answer: we use cache that requires a 'roundtrip' through the db and the application // we need to make sure that the cache is updated before we continue the procedures // we don't actually need to wait a whole second for this, but look this is just a retry mechanism it doesn't mean that actually takes all this time - for await (const retry of Array.from({ length: 10 })) { + for (const retry of Array.from({ length: 10 })) { const isSettingsUpdated = settings.get('Register_Server') === true && settings.get('Cloud_Workspace_Id') === workspaceId && diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/handleCommsSync.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/handleCommsSync.ts index 75ca93965c9fa..4354be6d5bba0 100644 --- a/apps/meteor/app/cloud/server/functions/syncWorkspace/handleCommsSync.ts +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/handleCommsSync.ts @@ -24,7 +24,7 @@ export const handleNpsOnWorkspaceSync = async (nps: Cloud.NpsSurveyAnnouncement) }; export const handleBannerOnWorkspaceSync = async (banners: IBanner[]) => { - for await (const banner of banners) { + for (const banner of banners) { await Banner.create(banner); } }; diff --git a/apps/meteor/app/cloud/server/index.ts b/apps/meteor/app/cloud/server/index.ts index 7bc7696d5b0dc..78485ccab1fa7 100644 --- a/apps/meteor/app/cloud/server/index.ts +++ b/apps/meteor/app/cloud/server/index.ts @@ -22,7 +22,7 @@ Meteor.startup(async () => { throw new Error("Couldn't register with token. Please make sure token is valid or hasn't already been used"); } - console.log('Successfully registered with token provided by REG_TOKEN!'); + SystemLogger.info('Successfully registered with token provided by REG_TOKEN!'); } catch (err: any) { SystemLogger.error({ msg: 'An error occurred registering with token.', err }); } diff --git a/apps/meteor/app/crowd/server/crowd.ts b/apps/meteor/app/crowd/server/crowd.ts index 54cefce69ad48..6039aaceb09de 100644 --- a/apps/meteor/app/crowd/server/crowd.ts +++ b/apps/meteor/app/crowd/server/crowd.ts @@ -239,7 +239,7 @@ export class CROWD { logger.info('Sync started...'); - for await (const user of users) { + for (const user of users) { let crowdUsername = user.hasOwnProperty('crowd_username') ? user.crowd_username : user.username; logger.info({ msg: 'Syncing user', crowdUsername }); if (!crowdUsername) { @@ -378,7 +378,7 @@ Accounts.registerLoginHandler('crowd', async function (this: typeof Accounts, lo const crowd = new CROWD(); const user = await crowd.authenticate(loginRequest.username, loginRequest.crowdPassword); - if (user && user.crowd === false) { + if (user?.crowd === false) { logger.debug({ msg: 'User is not a valid crowd user, falling back', username: loginRequest.username }); return fallbackDefaultAccountSystem(this, loginRequest.username, loginRequest.crowdPassword); } diff --git a/apps/meteor/app/custom-oauth/server/custom_oauth_server.js b/apps/meteor/app/custom-oauth/server/custom_oauth_server.js index 0e565d7038b73..40495449e8b22 100644 --- a/apps/meteor/app/custom-oauth/server/custom_oauth_server.js +++ b/apps/meteor/app/custom-oauth/server/custom_oauth_server.js @@ -2,6 +2,7 @@ import { LDAP } from '@rocket.chat/core-services'; import { Logger } from '@rocket.chat/logger'; import { Users } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { isAbsoluteURL } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -10,7 +11,6 @@ import { ServiceConfiguration } from 'meteor/service-configuration'; import _ from 'underscore'; import { normalizers, fromTemplate, renameInvalidProperties } from './transform_helpers'; -import { isURL } from '../../../lib/utils/isURL'; import { client } from '../../../server/database/utils'; import { callbacks } from '../../../server/lib/callbacks'; import { saveUserIdentity } from '../../lib/server/functions/saveUserIdentity'; @@ -76,6 +76,7 @@ export class CustomOAuth { this.serverURL = options.serverURL; this.tokenPath = options.tokenPath; this.identityPath = options.identityPath; + this.emailPath = options.emailPath; this.tokenSentVia = options.tokenSentVia; this.identityTokenSentVia = options.identityTokenSentVia; this.keyField = options.keyField; @@ -93,14 +94,18 @@ export class CustomOAuth { this.identityTokenSentVia = this.tokenSentVia; } - if (!isURL(this.tokenPath)) { + if (!isAbsoluteURL(this.tokenPath)) { this.tokenPath = this.serverURL + this.tokenPath; } - if (!isURL(this.identityPath)) { + if (!isAbsoluteURL(this.identityPath)) { this.identityPath = this.serverURL + this.identityPath; } + if (this.emailPath && !isAbsoluteURL(this.emailPath)) { + this.emailPath = this.serverURL + this.emailPath; + } + if (Match.test(options.addAutopublishFields, Object)) { Accounts.addAutopublishFields(options.addAutopublishFields); } @@ -187,7 +192,7 @@ export class CustomOAuth { logger.debug({ msg: 'Identity response', response }); - return this.normalizeIdentity(response); + return this.normalizeIdentity(response, accessToken); } catch (err) { const error = new Error(`Failed to fetch identity from ${this.name} at ${this.identityPath}. ${err.message}`); throw _.extend(error, { response: err.response }); @@ -230,7 +235,7 @@ export class CustomOAuth { }); } - normalizeIdentity(identity) { + async normalizeIdentity(identity, accessToken) { if (identity) { for (const normalizer of Object.values(normalizers)) { const result = normalizer(identity); @@ -248,6 +253,10 @@ export class CustomOAuth { identity.email = this.getEmail(identity); } + if (!identity.email && this.emailPath) { + identity.email = await this.getEmailFromPath(accessToken); + } + if (this.avatarField) { identity.avatarUrl = this.getAvatarUrl(identity); } @@ -261,6 +270,40 @@ export class CustomOAuth { return renameInvalidProperties(identity); } + async getEmailFromPath(accessToken) { + if (!this.emailPath) { + throw new Meteor.Error('CustomOAuth: emailPath is required'); + } + + const params = {}; + const headers = { + 'User-Agent': this.userAgent, + 'Accept': 'application/json', + }; + + if (this.identityTokenSentVia === 'header') { + headers.Authorization = `Bearer ${accessToken}`; + } else { + params[this.accessTokenParam] = accessToken; + } + + try { + // SECURITY: URL can only be configured by users with enough privileges. It's ok to disable this check here. + const request = await fetch(`${this.emailPath}`, { method: 'GET', headers, params, ignoreSsrfValidation: true }); + + if (!request.ok) { + throw new Error(request.statusText); + } + + const response = await request.json(); + + return response.find((email) => email.primary === true)?.email; + } catch (err) { + const error = new Error(`Failed to fetch emails from ${this.name} at ${this.emailPath}. ${err.message}`); + throw _.extend(error, { response: err.response }); + } + } + retrieveCredential(credentialToken, credentialSecret) { return OAuth.retrieveCredential(credentialToken, credentialSecret); } @@ -482,7 +525,7 @@ export class CustomOAuth { const { updateOrCreateUserFromExternalService } = Accounts; Accounts.updateOrCreateUserFromExternalService = async function (...args /* serviceName, serviceData, options*/) { - for await (const hook of BeforeUpdateOrCreateUserFromExternalService) { + for (const hook of BeforeUpdateOrCreateUserFromExternalService) { await hook.apply(this, args); } diff --git a/apps/meteor/app/custom-sounds/server/startup/custom-sounds.js b/apps/meteor/app/custom-sounds/server/startup/custom-sounds.js index 117a7d3c9e759..601157a884de6 100644 --- a/apps/meteor/app/custom-sounds/server/startup/custom-sounds.js +++ b/apps/meteor/app/custom-sounds/server/startup/custom-sounds.js @@ -1,3 +1,4 @@ +import { CustomSounds } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { WebApp } from 'meteor/webapp'; @@ -7,7 +8,7 @@ import { settings } from '../../../settings/server'; export let RocketChatFileCustomSoundsInstance; -Meteor.startup(() => { +const initializeCustomSoundsStorage = () => { let storeType = 'GridFS'; if (settings.get('CustomSounds_Storage_Type')) { @@ -36,7 +37,10 @@ Meteor.startup(() => { name: 'custom_sounds', absolutePath: path, }); +}; +Meteor.startup(() => { + initializeCustomSoundsStorage(); return WebApp.connectHandlers.use('/custom-sounds/', async (req, res /* , next*/) => { const fileId = decodeURIComponent(req.url.replace(/^\//, '').replace(/\?.*$/, '')); @@ -47,7 +51,17 @@ Meteor.startup(() => { return; } + const sound = await CustomSounds.findOneById(fileId.split('.')[0], { projection: { _id: 1 } }); + + if (!sound) { + res.writeHead(404); + res.write('Not found'); + res.end(); + return; + } + const file = await RocketChatFileCustomSoundsInstance.getFileWithReadStream(fileId); + if (!file) { res.writeHead(404); res.write('Not found'); @@ -86,3 +100,5 @@ Meteor.startup(() => { file.readStream.pipe(res); }); }); + +settings.watchMultiple(['CustomSounds_Storage_Type', 'CustomSounds_FileSystemPath'], initializeCustomSoundsStorage); diff --git a/apps/meteor/app/discussion/server/permissions.ts b/apps/meteor/app/discussion/server/permissions.ts index db5fe785a975c..341e8aed6373d 100644 --- a/apps/meteor/app/discussion/server/permissions.ts +++ b/apps/meteor/app/discussion/server/permissions.ts @@ -8,7 +8,7 @@ Meteor.startup(async () => { { _id: 'start-discussion-other-user', roles: ['admin', 'user', 'owner', 'app'] }, ]; - for await (const permission of permissions) { + for (const permission of permissions) { await Permissions.create(permission._id, permission.roles); } }); diff --git a/apps/meteor/app/e2e/server/functions/provideUsersSuggestedGroupKeys.ts b/apps/meteor/app/e2e/server/functions/provideUsersSuggestedGroupKeys.ts index 44563daa2cc37..4afabf66eb177 100644 --- a/apps/meteor/app/e2e/server/functions/provideUsersSuggestedGroupKeys.ts +++ b/apps/meteor/app/e2e/server/functions/provideUsersSuggestedGroupKeys.ts @@ -15,13 +15,13 @@ export const provideUsersSuggestedGroupKeys = async ( } // Process should try to process all rooms i have access instead of dying if one is not - for await (const roomId of roomIds) { + for (const roomId of roomIds) { if (!(await canAccessRoomIdAsync(roomId, userId))) { continue; } const usersWithSuggestedKeys = []; - for await (const user of usersSuggestedGroupKeys[roomId]) { + for (const user of usersSuggestedGroupKeys[roomId]) { const value = await Subscriptions.setGroupE2ESuggestedKeyAndOldRoomKeys(user._id, roomId, user.key, parseOldKeysDates(user.oldKeys)); if (!value) { continue; diff --git a/apps/meteor/app/emoji-custom/server/lib/insertOrUpdateEmoji.ts b/apps/meteor/app/emoji-custom/server/lib/insertOrUpdateEmoji.ts index 85a9648cf6d98..0158c1d5fc3f6 100644 --- a/apps/meteor/app/emoji-custom/server/lib/insertOrUpdateEmoji.ts +++ b/apps/meteor/app/emoji-custom/server/lib/insertOrUpdateEmoji.ts @@ -72,12 +72,12 @@ export async function insertOrUpdateEmoji(userId: string | null, emojiData: Emoj if (emojiData._id) { matchingResults = await EmojiCustom.findByNameOrAliasExceptID(emojiData.name, emojiData._id).toArray(); - for await (const alias of aliases) { + for (const alias of aliases) { matchingResults = matchingResults.concat(await EmojiCustom.findByNameOrAliasExceptID(alias, emojiData._id).toArray()); } } else { matchingResults = await EmojiCustom.findByNameOrAlias(emojiData.name).toArray(); - for await (const alias of aliases) { + for (const alias of aliases) { matchingResults = matchingResults.concat(await EmojiCustom.findByNameOrAlias(alias).toArray()); } } diff --git a/apps/meteor/app/emoji-custom/server/startup/emoji-custom.js b/apps/meteor/app/emoji-custom/server/startup/emoji-custom.js index fed5123b115f4..d784630013a57 100644 --- a/apps/meteor/app/emoji-custom/server/startup/emoji-custom.js +++ b/apps/meteor/app/emoji-custom/server/startup/emoji-custom.js @@ -1,3 +1,4 @@ +import { EmojiCustom } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { WebApp } from 'meteor/webapp'; import _ from 'underscore'; @@ -8,7 +9,36 @@ import { settings } from '../../../settings/server'; export let RocketChatFileEmojiCustomInstance; -Meteor.startup(() => { +const writeSvgFallback = (res, req) => { + res.setHeader('Content-Type', 'image/svg+xml'); + res.setHeader('Cache-Control', 'public, max-age=0'); + res.setHeader('Expires', '-1'); + res.setHeader('Last-Modified', 'Thu, 01 Jan 2015 00:00:00 GMT'); + + const reqModifiedHeader = req.headers['if-modified-since']; + if (reqModifiedHeader != null) { + if (reqModifiedHeader === 'Thu, 01 Jan 2015 00:00:00 GMT') { + res.writeHead(304); + res.end(); + return; + } + } + + const color = '#000'; + const initials = '?'; + + const svg = ` + + + ${initials} + +`; + + res.write(svg); + res.end(); +}; + +const initializeEmojiCustomStorage = () => { let storeType = 'GridFS'; if (settings.get('EmojiUpload_Storage_Type')) { @@ -37,7 +67,10 @@ Meteor.startup(() => { name: 'custom_emoji', absolutePath: path, }); +}; +Meteor.startup(() => { + initializeEmojiCustomStorage(); return WebApp.connectHandlers.use('/emoji-custom/', async (req, res /* , next*/) => { const params = { emoji: decodeURIComponent(req.url.replace(/^\//, '').replace(/\?.*$/, '')) }; @@ -48,39 +81,19 @@ Meteor.startup(() => { return; } - const file = await RocketChatFileEmojiCustomInstance.getFileWithReadStream(encodeURIComponent(params.emoji)); - res.setHeader('Content-Disposition', 'inline'); + const emoji = await EmojiCustom.findOneByName(params.emoji.split('.')[0], { projection: { _id: 1 } }); + + if (!emoji) { + return writeSvgFallback(res, req); + } + + const file = await RocketChatFileEmojiCustomInstance.getFileWithReadStream(encodeURIComponent(params.emoji)); + if (!file) { // use code from username initials renderer until file upload is complete - res.setHeader('Content-Type', 'image/svg+xml'); - res.setHeader('Cache-Control', 'public, max-age=0'); - res.setHeader('Expires', '-1'); - res.setHeader('Last-Modified', 'Thu, 01 Jan 2015 00:00:00 GMT'); - - const reqModifiedHeader = req.headers['if-modified-since']; - if (reqModifiedHeader != null) { - if (reqModifiedHeader === 'Thu, 01 Jan 2015 00:00:00 GMT') { - res.writeHead(304); - res.end(); - return; - } - } - - const color = '#000'; - const initials = '?'; - - const svg = ` - - - ${initials} - -`; - - res.write(svg); - res.end(); - return; + return writeSvgFallback(res, req); } const fileUploadDate = file.uploadDate != null ? file.uploadDate.toUTCString() : undefined; @@ -108,3 +121,5 @@ Meteor.startup(() => { file.readStream.pipe(res); }); }); + +settings.watchMultiple(['EmojiUpload_Storage_Type', 'EmojiUpload_FileSystemPath'], initializeEmojiCustomStorage); diff --git a/apps/meteor/app/emoji/client/index.ts b/apps/meteor/app/emoji/client/index.ts index 65e3b79cd5a6f..a78b3f8ff4c17 100644 --- a/apps/meteor/app/emoji/client/index.ts +++ b/apps/meteor/app/emoji/client/index.ts @@ -1,3 +1,3 @@ export * from './helpers'; -export * from './types'; +export type * from './types'; export { emoji, emojiEmitter } from './lib'; diff --git a/apps/meteor/app/file-upload/server/config/AmazonS3.ts b/apps/meteor/app/file-upload/server/config/AmazonS3.ts index 48b7a9b850a99..36da7e5feeee9 100644 --- a/apps/meteor/app/file-upload/server/config/AmazonS3.ts +++ b/apps/meteor/app/file-upload/server/config/AmazonS3.ts @@ -70,7 +70,6 @@ const configure = _.debounce(() => { const AWSSecretAccessKey = settings.get('FileUpload_S3_AWSSecretAccessKey'); const URLExpiryTimeSpan = settings.get('FileUpload_S3_URLExpiryTimeSpan'); const Region = settings.get('FileUpload_S3_Region'); - const SignatureVersion = settings.get('FileUpload_S3_SignatureVersion'); const ForcePathStyle = settings.get('FileUpload_S3_ForcePathStyle'); // const CDN = RocketChat.settings.get('FileUpload_S3_CDN'); const BucketURL = settings.get('FileUpload_S3_BucketURL'); @@ -81,23 +80,25 @@ const configure = _.debounce(() => { const config: Omit = { connection: { - signatureVersion: SignatureVersion, - s3ForcePathStyle: ForcePathStyle, - params: { - Bucket, - ACL: Acl, - }, - region: Region, + forcePathStyle: ForcePathStyle, + followRegionRedirects: true, + }, + params: { + Bucket, + ACL: Acl, }, URLExpiryTimeSpan, }; - if (AWSAccessKeyId) { - config.connection.accessKeyId = AWSAccessKeyId; + if (Region) { + config.connection.region = Region; } - if (AWSSecretAccessKey) { - config.connection.secretAccessKey = AWSSecretAccessKey; + if (AWSAccessKeyId && AWSSecretAccessKey) { + config.connection.credentials = { + accessKeyId: AWSAccessKeyId, + secretAccessKey: AWSSecretAccessKey, + }; } if (BucketURL) { diff --git a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts index 15fcba1875388..e105d1962d895 100644 --- a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts +++ b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts @@ -38,6 +38,13 @@ export const parseFileIntoMessageAttachments = async ( ): Promise => { validateFileRequiredFields(file); + const upload = await Uploads.findOneByIdAndUserIdAndRoomId(file._id, user._id, roomId, { projection: { _id: 1 } }); + if (!upload) { + throw new Meteor.Error('error-invalid-file', 'Invalid file', { + method: 'sendFileMessage', + }); + } + await Uploads.updateFileComplete(file._id, user._id, omit(file, '_id')); const fileUrl = FileUpload.getPath(`${file._id}/${encodeURI(file.name || '')}`); diff --git a/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts b/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts index 9e00e4ea497f3..b223d879c78bb 100644 --- a/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts +++ b/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts @@ -1,8 +1,17 @@ import stream from 'stream'; +import { + DeleteObjectCommand, + GetObjectCommand, + S3Client, + type GetObjectCommandInput, + type PutObjectCommandInput, + type S3ClientConfig, +} from '@aws-sdk/client-s3'; +import { Upload } from '@aws-sdk/lib-storage'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import type { IUpload } from '@rocket.chat/core-typings'; import { Random } from '@rocket.chat/random'; -import S3 from 'aws-sdk/clients/s3'; import { check } from 'meteor/check'; import type { OptionalId } from 'mongodb'; import _ from 'underscore'; @@ -12,17 +21,10 @@ import { UploadFS } from '../../../../server/ufs'; import type { StoreOptions } from '../../../../server/ufs/ufs-store'; export type S3Options = StoreOptions & { - connection: { - accessKeyId?: string; - secretAccessKey?: string; - endpoint?: string; - signatureVersion: string; - s3ForcePathStyle?: boolean; - params: { - Bucket: string; - ACL: string; - }; - region: string; + connection: S3ClientConfig; + params: { + Bucket: string; + ACL: string; }; URLExpiryTimeSpan: number; getPath: (file: OptionalId) => string; @@ -54,9 +56,9 @@ class AmazonS3Store extends UploadFS.Store { const customUserAgent = process.env.FILE_STORAGE_CUSTOM_USER_AGENT?.trim(); - const s3 = new S3({ - ...(customUserAgent && { customUserAgent }), + const s3 = new S3Client({ ...options.connection, + ...(customUserAgent && { customUserAgent }), }); options.getPath = @@ -79,20 +81,21 @@ class AmazonS3Store extends UploadFS.Store { }; this.getRedirectURL = async (file, forceDownload = false) => { - const params = { - Key: this.getPath(file), - Expires: classOptions.URLExpiryTimeSpan, - ResponseContentDisposition: `${forceDownload ? 'attachment' : 'inline'}; filename="${encodeURI(file.name || '')}"`, - }; - - return s3.getSignedUrlPromise('getObject', params); + return getSignedUrl( + s3, + new GetObjectCommand({ + Key: this.getPath(file), + ResponseContentDisposition: `${forceDownload ? 'attachment' : 'inline'}; filename="${encodeURI(file.name || '')}"`, + Bucket: classOptions.params.Bucket, + }), + { + expiresIn: classOptions.URLExpiryTimeSpan, // seconds + }, + ); }; /** * Creates the file in the collection - * @param file - * @param callback - * @return {string} */ this.create = async (file) => { check(file, Object); @@ -112,61 +115,57 @@ class AmazonS3Store extends UploadFS.Store { /** * Removes the file - * @param fileId - * @param callback */ this.delete = async function (fileId) { const file = await this.getCollection().findOne({ _id: fileId }); if (!file) { throw new Error('File not found'); } - const params = { - Key: this.getPath(file), - Bucket: classOptions.connection.params.Bucket, - }; try { - return s3.deleteObject(params).promise(); - } catch (err: any) { - SystemLogger.error({ err }); + return await s3.send( + new DeleteObjectCommand({ + Key: this.getPath(file), + Bucket: classOptions.params.Bucket, + }), + ); + } catch (error) { + SystemLogger.error({ error, key: this.getPath(file), bucket: classOptions.params.Bucket }); + throw error; } }; /** * Returns the file read stream - * @param fileId - * @param file - * @param options - * @return {*} */ this.getReadStream = async function (_fileId, file, options = {}) { - const params: { - Key: string; - Bucket: string; - Range?: string; - } = { + const params: GetObjectCommandInput = { Key: this.getPath(file), - Bucket: classOptions.connection.params.Bucket, + Bucket: classOptions.params.Bucket, }; - if (options.start && options.end) { - params.Range = `${options.start} - ${options.end}`; + if (options.start != null && options.end != null) { + params.Range = `bytes=${options.start}-${options.end}`; } - return s3.getObject(params).createReadStream(); + const response = await s3.send(new GetObjectCommand(params)); + + if (!response.Body) { + throw new Error('File not found'); + } + + if (!('readable' in response.Body)) { + throw new Error('Response body is not a readable stream'); + } + + return response.Body; }; /** * Returns the file write stream - * @param fileId - * @param file - * @param options - * @return {*} */ this.getWriteStream = async function (_fileId, file /* , options*/) { const writeStream = new stream.PassThrough(); - // TS does not allow but S3 requires a length property; - (writeStream as unknown as any).length = file.size; writeStream.on('newListener', (event, listener) => { if (event === 'finish') { @@ -177,27 +176,35 @@ class AmazonS3Store extends UploadFS.Store { } }); - s3.putObject( - { - Key: this.getPath(file), - Body: writeStream, - ContentType: file.type, - Bucket: classOptions.connection.params.Bucket, - }, - (err) => { - if (err) { - SystemLogger.error({ err }); - } + const uploadParams: PutObjectCommandInput = { + Key: this.getPath(file), + Body: writeStream, + Bucket: classOptions.params.Bucket, + ...(file.type && { ContentType: file.type }), + ...(file.size != null && { ContentLength: file.size }), + ...(classOptions.params.ACL && { ACL: classOptions.params.ACL as PutObjectCommandInput['ACL'] }), + }; + + const upload = new Upload({ + client: s3, + params: uploadParams, + }); + upload + .done() + .then(() => { writeStream.emit('real_finish'); - }, - ); + }) + .catch((error) => { + SystemLogger.error({ err: error }); + writeStream.emit('error', error); + }); return writeStream; }; this.getUrlExpiryTimeSpan = async () => { - return options.URLExpiryTimeSpan || null; + return classOptions.URLExpiryTimeSpan || null; }; } } diff --git a/apps/meteor/app/github/server/lib.ts b/apps/meteor/app/github/server/lib.ts index abdc87419956a..5409f7a809803 100644 --- a/apps/meteor/app/github/server/lib.ts +++ b/apps/meteor/app/github/server/lib.ts @@ -6,6 +6,7 @@ const config: OauthConfig = { serverURL: 'https://github.com', identityPath: 'https://api.github.com/user', tokenPath: 'https://github.com/login/oauth/access_token', + emailPath: 'https://api.github.com/user/emails', scope: 'user:email', mergeUsers: false, addAutopublishFields: { diff --git a/apps/meteor/app/importer-csv/server/CsvImporter.ts b/apps/meteor/app/importer-csv/server/CsvImporter.ts index db36210271bb4..7a76677f1662f 100644 --- a/apps/meteor/app/importer-csv/server/CsvImporter.ts +++ b/apps/meteor/app/importer-csv/server/CsvImporter.ts @@ -65,7 +65,7 @@ export class CsvImporter extends Importer { return roomId; }; - for await (const entry of zip.getEntries()) { + for (const entry of zip.getEntries()) { this.logger.debug({ msg: 'Entry', entryName: entry.entryName }); // Ignore anything that has `__MACOSX` in it's name, as sadly these things seem to mess everything up @@ -88,7 +88,7 @@ export class CsvImporter extends Importer { const parsedChannels = this.csvParser(entry.getData().toString()); channelsCount = parsedChannels.length; - for await (const c of parsedChannels) { + for (const c of parsedChannels) { const name = c[0].trim(); const id = getRoomId(name); const creator = c[1].trim(); @@ -121,7 +121,7 @@ export class CsvImporter extends Importer { const parsedUsers = this.csvParser(entry.getData().toString()); usersCount = parsedUsers.length; - for await (const u of parsedUsers) { + for (const u of parsedUsers) { const username = u[0].trim(); availableUsernames.add(username); @@ -196,7 +196,7 @@ export class CsvImporter extends Importer { await super.updateRecord({ messagesstatus: channelName }); if (isDirect) { - for await (const msg of data) { + for (const msg of data) { if (!msg.otherUsername) { continue; } @@ -229,7 +229,7 @@ export class CsvImporter extends Importer { } else { const rid = getRoomId(folderName); - for await (const msg of data) { + for (const msg of data) { const newMessage = { rid, u: { @@ -258,7 +258,7 @@ export class CsvImporter extends Importer { } // Check if any of the message usernames was not in the imported list of users - for await (const username of usedUsernames) { + for (const username of usedUsernames) { if (availableUsernames.has(username)) { continue; } diff --git a/apps/meteor/app/importer-omnichannel-contacts/server/addParsedContacts.ts b/apps/meteor/app/importer-omnichannel-contacts/server/addParsedContacts.ts index cc00e7ed1f9b4..ee36150544eb6 100644 --- a/apps/meteor/app/importer-omnichannel-contacts/server/addParsedContacts.ts +++ b/apps/meteor/app/importer-omnichannel-contacts/server/addParsedContacts.ts @@ -6,7 +6,7 @@ export async function addParsedContacts(this: ImportDataConverter, parsedContact const columnNames = parsedContacts.shift(); let addedContacts = 0; - for await (const parsedData of parsedContacts) { + for (const parsedData of parsedContacts) { const contactData = parsedData.reduce( (acc, value, index) => { const columnName = columnNames && index < columnNames.length ? columnNames[index] : `column${index}`; diff --git a/apps/meteor/app/importer-slack-users/server/SlackUsersImporter.ts b/apps/meteor/app/importer-slack-users/server/SlackUsersImporter.ts index aa51f56daa47f..581172a617b96 100644 --- a/apps/meteor/app/importer-slack-users/server/SlackUsersImporter.ts +++ b/apps/meteor/app/importer-slack-users/server/SlackUsersImporter.ts @@ -47,7 +47,7 @@ export class SlackUsersImporter extends Importer { const parsed = this.csvParser(buf.toString()); let userCount = 0; - for await (const [index, user] of parsed.entries()) { + for (const [index, user] of parsed.entries()) { // Ignore the first column if (index === 0) { continue; diff --git a/apps/meteor/app/importer-slack/server/SlackImporter.ts b/apps/meteor/app/importer-slack/server/SlackImporter.ts index 30c710df09b7a..d7ccfbdec6c49 100644 --- a/apps/meteor/app/importer-slack/server/SlackImporter.ts +++ b/apps/meteor/app/importer-slack/server/SlackImporter.ts @@ -129,7 +129,7 @@ export class SlackImporter extends Importer { await this.addCountToTotal(data.length); - for await (const channel of data) { + for (const channel of data) { await this.converter.addChannel({ _id: channel.is_general ? 'general' : undefined, u: { @@ -159,7 +159,7 @@ export class SlackImporter extends Importer { await this.addCountToTotal(data.length); - for await (const channel of data) { + for (const channel of data) { await this.converter.addChannel({ u: { _id: this._replaceSlackUserId(channel.creator), @@ -190,7 +190,7 @@ export class SlackImporter extends Importer { const maxUsers = settings.get('DirectMesssage_maxUsers') || 1; - for await (const channel of data) { + for (const channel of data) { await this.converter.addChannel({ u: { _id: this._replaceSlackUserId(channel.creator), @@ -216,7 +216,7 @@ export class SlackImporter extends Importer { this.logger.debug({ msg: 'loaded dms', count: data.length }); await this.addCountToTotal(data.length); - for await (const channel of data) { + for (const channel of data) { await this.converter.addChannel({ importIds: [channel.id], users: this._replaceSlackUserIds(channel.members), @@ -238,7 +238,7 @@ export class SlackImporter extends Importer { await this.updateRecord({ 'count.users': data.length }); await this.addCountToTotal(data.length); - for await (const user of data) { + for (const user of data) { const newUser: IImportUser = { emails: [], importIds: [user.id], @@ -297,7 +297,7 @@ export class SlackImporter extends Importer { try { // we need to iterate the zip file twice so that all channels are loaded before the messages - for await (const entry of zip.getEntries()) { + for (const entry of zip.getEntries()) { try { if (entry.entryName === 'channels.json') { channelCount += await this.prepareChannelsFile(entry); @@ -348,7 +348,7 @@ export class SlackImporter extends Importer { // If we have no slack message yet, then we can insert them instead of upserting this._useUpsert = !(await Messages.findOne({ _id: /slack\-.*/ })); - for await (const entry of zip.getEntries()) { + for (const entry of zip.getEntries()) { try { if (entry.entryName.includes('__MACOSX') || entry.entryName.includes('.DS_Store')) { count++; @@ -380,7 +380,7 @@ export class SlackImporter extends Importer { const slackChannelId = await ImportData.findChannelImportIdByNameOrImportId(channel); if (slackChannelId) { - for await (const message of tempMessages) { + for (const message of tempMessages) { await this.prepareMessageObject(message, missedTypes, slackChannelId); } } diff --git a/apps/meteor/app/importer/server/classes/converters/MessageConverter.ts b/apps/meteor/app/importer/server/classes/converters/MessageConverter.ts index 825090147be8a..cb4cf8047470d 100644 --- a/apps/meteor/app/importer/server/classes/converters/MessageConverter.ts +++ b/apps/meteor/app/importer/server/classes/converters/MessageConverter.ts @@ -38,7 +38,7 @@ export class MessageConverter extends RecordConverter { } protected async resetLastMessages(): Promise { - for await (const rid of this.rids) { + for (const rid of this.rids) { try { await Rooms.resetLastMessageById(rid, null); } catch (err) { @@ -120,7 +120,7 @@ export class MessageConverter extends RecordConverter { } const result: MentionedChannel[] = []; - for await (const importId of channels) { + for (const importId of channels) { const { name, _id } = (await this.getMentionedChannelData(importId)) || {}; if (!_id || !name) { @@ -146,7 +146,7 @@ export class MessageConverter extends RecordConverter { } const result: MentionedUser[] = []; - for await (const importId of mentions) { + for (const importId of mentions) { if (importId === ('all' as 'string') || importId === 'here') { result.push({ _id: importId, @@ -185,7 +185,7 @@ export class MessageConverter extends RecordConverter { ): Promise { const reactions: IMessageReactions = {}; - for await (const name of Object.keys(importedReactions)) { + for (const name of Object.keys(importedReactions)) { if (!importedReactions.hasOwnProperty(name)) { continue; } @@ -200,7 +200,7 @@ export class MessageConverter extends RecordConverter { usernames: [], }; - for await (const importId of users) { + for (const importId of users) { const username = await this._cache.findImportedUsername(importId); if (username && !reaction.usernames.includes(username)) { reaction.usernames.push(username); @@ -219,7 +219,7 @@ export class MessageConverter extends RecordConverter { protected async convertMessageReplies(replies: string[]): Promise { const result: string[] = []; - for await (const importId of replies) { + for (const importId of replies) { const userId = await this._cache.findImportedUserId(importId); if (userId && !result.includes(userId)) { result.push(userId); diff --git a/apps/meteor/app/importer/server/classes/converters/RecordConverter.ts b/apps/meteor/app/importer/server/classes/converters/RecordConverter.ts index 4b3658febde3b..3ae1df007f75a 100644 --- a/apps/meteor/app/importer/server/classes/converters/RecordConverter.ts +++ b/apps/meteor/app/importer/server/classes/converters/RecordConverter.ts @@ -208,7 +208,7 @@ export class RecordConverter { return ivmEngine; } @@ -48,7 +47,7 @@ type IntegrationThis = GenericRouteExecutionContext & { request: Request & { integration: IIncomingIntegration; }; - user: IUser & { username: RequiredField }; + user: RequiredField; }; async function createIntegration(options: IntegrationOptions, user: IUser): Promise { @@ -60,7 +59,7 @@ async function createIntegration(options: IntegrationOptions, user: IUser): Prom if (options.data == null) { options.data = {}; } - if (options.data.channel_name != null && options.data.channel_name.indexOf('#') === -1) { + if (options.data.channel_name?.indexOf('#') === -1) { options.data.channel_name = `#${options.data.channel_name}`; } return addOutgoingIntegration(user._id, { @@ -118,6 +117,42 @@ async function removeIntegration(options: { target_url: string }, user: IUser): return API.v1.success(); } +/** + * Slack/GitHub-style webhooks send JSON wrapped in a `payload` field + * with Content-Type: application/x-www-form-urlencoded (e.g. `payload={"text":"hello"}`). + * This function unwraps it so integrations receive the parsed JSON directly. + */ +function getBodyParams(bodyParams: unknown, request: Request): Record { + if (!isPlainObject(bodyParams)) { + return {}; + } + + if ( + request.headers.get('content-type')?.startsWith('application/x-www-form-urlencoded') && + Object.keys(bodyParams).length === 1 && + typeof bodyParams.payload === 'string' + ) { + try { + const parsed = JSON.parse(bodyParams.payload); + + // Valid JSON must be an object, not an array or primitive + if (!isPlainObject(parsed)) { + throw new Error('Integration payload must be a JSON object, not an array or primitive'); + } + + return parsed; + } catch (err) { + // Invalid JSON -> return original bodyParams (backward compatibility) + if (err instanceof SyntaxError) { + return bodyParams; + } + throw err; + } + } + + return bodyParams; +} + async function executeIntegrationRest( this: IntegrationThis, ): Promise< @@ -142,14 +177,19 @@ async function executeIntegrationRest( const scriptEngine = getEngine(this.request.integration); - let bodyParams = isPlainObject(this.bodyParams) ? this.bodyParams : {}; + let bodyParams: Record; + try { + bodyParams = getBodyParams(this.bodyParams, this.request); + } catch (err) { + return API.v1.failure(err instanceof Error ? err.message : String(err)); + } + const separateResponse = bodyParams.separateResponse === true; let scriptResponse: Record | undefined; if (scriptEngine.integrationHasValidScript(this.request.integration) && this.request.body) { const buffers = []; const reader = this.request.body.getReader(); - // eslint-disable-next-line no-await-in-loop for (let result = await reader.read(); !result.done; result = await reader.read()) { buffers.push(result.value); } @@ -318,7 +358,7 @@ function integrationInfoRest(): { statusCode: number; body: { success: boolean } } class WebHookAPI extends APIClass<'/hooks'> { - override async authenticatedRoute(routeContext: IntegrationThis): Promise { + override async authenticatedRoute(routeContext: APIActionContext): Promise { const { integrationId, token } = routeContext.urlParams; const integration = await Integrations.findOneByIdAndToken(integrationId, decodeURIComponent(token)); @@ -328,9 +368,12 @@ class WebHookAPI extends APIClass<'/hooks'> { throw new Error('Invalid integration id or token provided.'); } - routeContext.request.integration = integration; + routeContext.request.headers.set('x-auth-token', token); - return Users.findOneById(routeContext.request.integration.userId); + const req = routeContext.request as Request & { integration?: IIncomingIntegration }; + req.integration = integration; + + return Users.findOneById(req.integration.userId); } override shouldAddRateLimitToRoute(options: { rateLimiterOptions?: RateLimiterOptions | boolean }): boolean { @@ -388,51 +431,6 @@ Api.router .use(metricsMiddleware({ basePathRegex: new RegExp(/^\/hooks\//), api: Api, settings, summary: metrics.rocketchatRestApi })) .use(tracerSpanMiddleware); -const middleware = async (c: Context, next: Next): Promise => { - const { req } = c; - if (req.raw.headers.get('content-type') !== 'application/x-www-form-urlencoded') { - return next(); - } - - try { - const content = await req.raw.clone().text(); - const body = Object.fromEntries(new URLSearchParams(content)); - if (!body || typeof body !== 'object' || Object.keys(body).length !== 1) { - return next(); - } - - /** - * Slack/GitHub-style webhooks send JSON wrapped in a `payload` field with - * Content-Type: application/x-www-form-urlencoded (e.g. `payload={"text":"hello"}`). - * We unwrap it here so integrations receive the parsed JSON directly. - * - * Note: These webhooks only send the `payload` field with no additional form - * parameters, so we simply replace bodyParams with the parsed JSON. - */ - if (body.payload) { - if (typeof body.payload === 'string') { - try { - c.set('bodyParams-override', JSON.parse(body.payload)); - } catch { - // Keep original without unwrapping - } - } - return next(); - } - - incomingLogger.debug({ - msg: 'Body received as application/x-www-form-urlencoded without the "payload" key, parsed as string', - content, - }); - } catch (e: any) { - c.body(JSON.stringify({ success: false, error: e.message }), 400); - } - - return next(); -}; - -Api.router.use(middleware); - Api.addRoute( ':integrationId/:userId/:token', { authRequired: true }, diff --git a/apps/meteor/app/integrations/server/lib/triggerHandler.ts b/apps/meteor/app/integrations/server/lib/triggerHandler.ts index 192419d6c2136..09cc1f906ca09 100644 --- a/apps/meteor/app/integrations/server/lib/triggerHandler.ts +++ b/apps/meteor/app/integrations/server/lib/triggerHandler.ts @@ -92,7 +92,6 @@ class RocketChatIntegrationHandler { } } - // eslint-disable-next-line no-unused-vars getEngine(_integration: any): IsolatedVMScriptEngine { return this.ivmEngine; } @@ -181,7 +180,7 @@ class RocketChatIntegrationHandler { channel: tmpRoom.t === 'd' ? `@${tmpRoom._id}` : `#${tmpRoom._id}`, }; - return processWebhookMessage(message, user as IUser & { username: RequiredField }, defaultValues); + return processWebhookMessage(message, user as RequiredField, defaultValues); } eventNameArgumentsToObject(...args: unknown[]) { @@ -465,7 +464,7 @@ class RocketChatIntegrationHandler { outgoingLogger.debug({ msg: 'Found triggers to iterate over', triggerCount: triggersToExecute.length, event }); - for await (const triggerToExecute of triggersToExecute) { + for (const triggerToExecute of triggersToExecute) { outgoingLogger.debug({ msg: 'Checking trigger execution eligibility', triggerName: triggerToExecute.name, @@ -482,7 +481,7 @@ class RocketChatIntegrationHandler { if (!trigger.urls) { return; } - for await (const url of trigger.urls) { + for (const url of trigger.urls) { await this.executeTriggerUrl(url, trigger, argObject, 0); } } @@ -621,6 +620,7 @@ class RocketChatIntegrationHandler { ...(opts.data && { body: opts.data }), // SECURITY: Integrations can only be configured by users with enough privileges. It's ok to disable this check here. ignoreSsrfValidation: true, + size: 10 * 1024 * 1024, }, settings.get('Allow_Invalid_SelfSigned_Certs'), ) @@ -796,11 +796,11 @@ class RocketChatIntegrationHandler { } async replay(integration: IOutgoingIntegration, history: IIntegrationHistory) { - if (!integration || integration.type !== 'webhook-outgoing') { + if (integration?.type !== 'webhook-outgoing') { throw new Meteor.Error('integration-type-must-be-outgoing', 'The integration type to replay must be an outgoing webhook.'); } - if (!history || !history.data) { + if (!history?.data) { throw new Meteor.Error('history-data-must-be-defined', 'The history data must be defined to replay an integration.'); } @@ -810,7 +810,7 @@ class RocketChatIntegrationHandler { let room; let user; - if (history.data.owner && history.data.owner._id) { + if (history.data.owner?._id) { owner = await Users.findOneById(history.data.owner._id); } if (history.data.message_id) { diff --git a/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts b/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts index e86b982d2cf20..fabe8cae7688d 100644 --- a/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts +++ b/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts @@ -53,7 +53,7 @@ function _verifyRequiredFields(integration: INewOutgoingIntegration | IUpdateOut } async function _verifyUserHasPermissionForChannels(userId: IUser['_id'], channels: string[]): Promise { - for await (let channel of channels) { + for (let channel of channels) { if (scopedChannels.includes(channel)) { if (channel === 'all_public_channels') { // No special permissions needed to add integration to public channels diff --git a/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts b/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts index eaa3b98fd6290..a64e0c40c7afe 100644 --- a/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts @@ -2,6 +2,7 @@ import type { INewIncomingIntegration, IIncomingIntegration } from '@rocket.chat 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'; @@ -114,14 +115,14 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn babelOptions = _.extend(babelOptions, { compact: true, minified: true, comments: false }); integrationData.scriptCompiled = Babel.compile(integration.script, babelOptions).code; - integrationData.scriptError = undefined; + delete integrationData.scriptError; } catch (e) { integrationData.scriptCompiled = undefined; integrationData.scriptError = e instanceof Error ? _.pick(e, 'name', 'message', 'stack') : undefined; } } - for await (let channel of channels) { + for (let channel of channels) { let record; const channelType = channel[0]; channel = channel.substr(1); @@ -157,7 +158,9 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn await addUserRolesAsync(user._id, ['bot']); - const { insertedId } = await Integrations.insertOne(integrationData); + const strippedIntegrationData = removeEmpty(integrationData); + + const { insertedId } = await Integrations.insertOne(strippedIntegrationData); if (insertedId) { void notifyOnIntegrationChanged({ ...integrationData, _id: insertedId }, 'inserted'); diff --git a/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts b/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts index 774d7a0d597a0..9e63ce5473b86 100644 --- a/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts @@ -125,7 +125,7 @@ export const updateIncomingIntegration = async ( } } - for await (let channel of channels) { + for (let channel of channels) { const channelType = channel[0]; channel = channel.slice(1); let record; @@ -206,7 +206,6 @@ export const updateIncomingIntegration = async ( }; Meteor.methods({ - // eslint-disable-next-line complexity async updateIncomingIntegration(integrationId, integration) { if (!this.userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { diff --git a/apps/meteor/app/integrations/server/methods/outgoing/addOutgoingIntegration.ts b/apps/meteor/app/integrations/server/methods/outgoing/addOutgoingIntegration.ts index dc54c76b1897f..033e797a6d65a 100644 --- a/apps/meteor/app/integrations/server/methods/outgoing/addOutgoingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/outgoing/addOutgoingIntegration.ts @@ -1,6 +1,7 @@ import type { INewOutgoingIntegration, IOutgoingIntegration } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Integrations } from '@rocket.chat/models'; +import { removeEmpty } from '@rocket.chat/tools'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -59,15 +60,17 @@ export const addOutgoingIntegration = async (userId: string, integration: INewOu const integrationData = await validateOutgoingIntegration(integration, userId); - const { insertedId } = await Integrations.insertOne(integrationData); + const { insertedId } = await Integrations.insertOne(removeEmpty(integrationData)); - if (insertedId) { - void notifyOnIntegrationChanged({ ...integrationData, _id: insertedId }, 'inserted'); + const integrationStored = await Integrations.findOne({ _id: insertedId }); + + if (!integrationStored) { + throw new Error('Error inserting integration'); } - integrationData._id = insertedId; + void notifyOnIntegrationChanged({ ...integrationStored, _id: insertedId }, 'inserted'); - return integrationData; + return integrationStored as IOutgoingIntegration; }; Meteor.methods({ diff --git a/apps/meteor/app/invites/server/functions/sendInvitationEmail.ts b/apps/meteor/app/invites/server/functions/sendInvitationEmail.ts index 900aeea9fdb1f..d76d70ac94ef8 100644 --- a/apps/meteor/app/invites/server/functions/sendInvitationEmail.ts +++ b/apps/meteor/app/invites/server/functions/sendInvitationEmail.ts @@ -42,7 +42,7 @@ export const sendInvitationEmail = async (userId: string, emails: string[]) => { }); } - for await (const email of validEmails) { + for (const email of validEmails) { try { await Mailer.send({ to: email, diff --git a/apps/meteor/app/lib/client/methods/sendMessage.ts b/apps/meteor/app/lib/client/methods/sendMessage.ts index ee67688a0b6ae..0969ea8d9cf36 100644 --- a/apps/meteor/app/lib/client/methods/sendMessage.ts +++ b/apps/meteor/app/lib/client/methods/sendMessage.ts @@ -44,13 +44,7 @@ Meteor.methods({ await onClientMessageReceived(message as IMessage).then((message) => { Messages.state.store(message); - void clientCallbacks.run('afterSaveMessage', message, { room, user }); - - // Now that the message is stored, we can go ahead and mark as sent - Messages.state.update( - (record) => record._id === message._id && record.temp === true, - ({ temp: _, ...record }) => record, - ); + return clientCallbacks.run('afterSaveMessage', message, { room, user }); }); }, }); diff --git a/apps/meteor/app/lib/lib/MessageTypes.ts b/apps/meteor/app/lib/lib/MessageTypes.ts index 8205200615ff7..1d826853ec042 100644 --- a/apps/meteor/app/lib/lib/MessageTypes.ts +++ b/apps/meteor/app/lib/lib/MessageTypes.ts @@ -45,6 +45,14 @@ export const MessageTypesValues: Array<{ key: MessageTypesValuesType; i18nLabel: key: 'mute_unmute', i18nLabel: 'Message_HideType_mute_unmute', }, + { + key: 'user-banned', + i18nLabel: 'Message_HideType_user_banned', + }, + { + key: 'user-unbanned', + i18nLabel: 'Message_HideType_user_unbanned', + }, { key: 'r', // room name changed i18nLabel: 'Message_HideType_r', diff --git a/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts b/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts index 89f4e5e352755..51331cbfc2c4a 100644 --- a/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts +++ b/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts @@ -13,7 +13,7 @@ export const addUserToDefaultChannels = async function (user: IUser, silenced?: await callbacks.run('beforeJoinDefaultChannels', user); const defaultRooms = await getDefaultChannels(); - for await (const room of defaultRooms) { + for (const room of defaultRooms) { if (settings.get('ABAC_Enabled') && room?.abacAttributes?.length) { continue; } diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index 6ebfd84844878..dc4f83654f1c8 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -1,7 +1,7 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; -import { Team, Room } from '@rocket.chat/core-services'; -import { isRoomNativeFederated, type IUser } from '@rocket.chat/core-typings'; +import { Message, Team, Room } from '@rocket.chat/core-services'; +import { isBannedSubscription, isRoomNativeFederated, type IUser } from '@rocket.chat/core-typings'; import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -48,12 +48,6 @@ export const addUserToRoom = async ( throw new Meteor.Error('user-not-found'); } - // Check if user is already in room - const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userToBeAdded._id); - if (subscription) { - return; - } - if ( !(await roomDirectives.allowMemberAction(room, RoomMemberActions.JOIN, userToBeAdded._id)) && !(await roomDirectives.allowMemberAction(room, RoomMemberActions.INVITE, userToBeAdded._id)) @@ -98,6 +92,21 @@ export const addUserToRoom = async ( await callbacks.run('beforeJoinRoom', userToBeAdded, room); } + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userToBeAdded._id); + if (subscription) { + if (!isBannedSubscription(subscription)) { + return true; + } + const deleteCount = await Subscriptions.removeByUserId(userToBeAdded._id); + if (!deleteCount) { + return true; + } + + if (!skipSystemMessage && inviter) { + await Message.saveSystemMessage('user-unbanned', rid, userToBeAdded.username!, inviter, { ts: now }); + } + } + await Room.createUserSubscription({ room, ts: now, diff --git a/apps/meteor/app/lib/server/functions/banUserFromRoom.ts b/apps/meteor/app/lib/server/functions/banUserFromRoom.ts new file mode 100644 index 0000000000000..1fbea41d40081 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/banUserFromRoom.ts @@ -0,0 +1,76 @@ +import { Message, Team } from '@rocket.chat/core-services'; +import { isBannedSubscription } from '@rocket.chat/core-typings'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; + +import { afterBanFromRoomCallback } from '../../../../server/lib/callbacks/afterBanFromRoomCallback'; +import { removeUserFromRolesAsync } from '../../../../server/lib/roles/removeUserFromRoles'; +import { notifyOnRoomChangedById, notifyOnSubscriptionChanged } from '../lib/notifyListener'; + +/** + * Bans a user from a room when triggered by federation or other external events. + * Executes only the necessary database operations, with no callbacks, to prevent + * propagation loops during external event processing. + * `byUser` must be the Rocket.Chat user who initiated the ban (local record). + */ +export const performUserBan = async function (room: IRoom, user: IUser, byUser: IUser): Promise { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id); + if (!subscription) { + return; + } + + if (!user.username) { + throw new Error('User must have a username to be banned from the room'); + } + + // Already banned — nothing to do + if (isBannedSubscription(subscription)) { + return; + } + + // Set subscription status to BANNED (keeps the record, unlike kick which deletes it) + await Subscriptions.banByRoomIdAndUserId(room._id, user._id); + + // Remove the room from the user's __rooms array so they don't appear in member listings + await Users.removeRoomByUserId(user._id, room._id); + + // Decrement the room's user count + await Rooms.incUsersCountById(room._id, -1); + + // Remove room-scoped roles (moderator, owner, leader) + if (['c', 'p'].includes(room.t)) { + await removeUserFromRolesAsync(user._id, ['moderator', 'owner', 'leader'], room._id); + } + + // Remove from team when banning from main team room so roster stays in sync with subscription state + if (room.teamId && room.teamMain) { + await Team.removeMember(room.teamId, user._id); + } + + // Save system message (who banned is always recorded) + await Message.saveSystemMessage('user-banned', room._id, user.username, user, { + u: byUser, + }); + + // Send 'removed' so the client drops the room stream/socket subscription. + // The record still exists in DB with status BANNED for access-control purposes. + void notifyOnSubscriptionChanged(subscription, 'removed'); + void notifyOnRoomChangedById(room._id); +}; + +/** + * Bans a user from the given room by updating the subscription status to BANNED, + * removing them from member listings, and triggering all standard callbacks. + * Used for local actions (UI or API) that should propagate normally to federation + * and other subscribers. + */ +export const banUserFromRoom = async function (rid: string, user: IUser, byUser: IUser): Promise { + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Error('error-invalid-room'); + } + + await performUserBan(room, user, byUser); + + void afterBanFromRoomCallback.run({ bannedUser: user, userWhoBanned: byUser }, room); +}; diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index d4f439d7ae30a..957d8e057d294 100644 --- a/apps/meteor/app/lib/server/functions/createDirectRoom.ts +++ b/apps/meteor/app/lib/server/functions/createDirectRoom.ts @@ -157,7 +157,7 @@ export async function createDirectRoom( const roomNames = getNameForDMs(roomMembers); - for await (const member of membersWithPreferences) { + for (const member of membersWithPreferences) { const subscriptionStatus: Partial = roomExtraData.federated && options.creator !== member._id && creatorUser ? { diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index bbd971e667e66..5b28883d8f8f4 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -63,7 +63,7 @@ async function createUsersSubscriptions({ await FederationMatrix.ensureFederatedUsersExistLocally(membersToInvite); - for await (const memberUsername of membersToInvite) { + for (const memberUsername of membersToInvite) { const member = await Users.findOneByUsername(memberUsername); if (!member) { throw new Error('Federated user not found locally'); @@ -139,7 +139,6 @@ async function createUsersSubscriptions({ await Rooms.incUsersCountById(room._id, subs.length); } -// eslint-disable-next-line complexity export const createRoom = async ( type: T, name: T extends 'd' ? undefined : string, @@ -193,7 +192,9 @@ export const createRoom = async ( return createDirectRoom(members as IUser[], extraData, { ...options, creator: options?.creator || owner?._id }); } - if (!onlyUsernames(members)) { + const memberList = [...members]; + + if (!onlyUsernames(memberList)) { throw new Meteor.Error( 'error-invalid-members', 'members should be an array of usernames if provided for rooms other than direct messages', @@ -218,8 +219,8 @@ export const createRoom = async ( }); } - if (!excludeSelf && owner.username && !members.includes(owner.username)) { - members.push(owner.username); + if (!excludeSelf && owner.username && !memberList.includes(owner.username)) { + memberList.push(owner.username); } if (extraData.broadcast) { @@ -258,7 +259,7 @@ export const createRoom = async ( const tmp = { ...roomProps, - _USERNAMES: members, + _USERNAMES: memberList, }; const prevent = await Apps.self?.triggerEvent(AppEvents.IPreRoomCreatePrevent, tmp).catch((error) => { @@ -299,10 +300,10 @@ export const createRoom = async ( if (shouldBeHandledByFederation) { // Reusing unused callback to create Matrix room. // We should discuss the opportunity to rename it to something with "before" prefix. - await callbacks.run('federation.afterCreateFederatedRoom', room, { owner, originalMemberList: members, options }); + await callbacks.run('federation.afterCreateFederatedRoom', room, { owner, originalMemberList: memberList, options }); } - await createUsersSubscriptions({ room, members, now, owner, options, shouldBeHandledByFederation }); + await createUsersSubscriptions({ room, members: memberList, now, owner, options, shouldBeHandledByFederation }); if (type === 'c') { if (room.teamId) { diff --git a/apps/meteor/app/lib/server/functions/deleteMessage.ts b/apps/meteor/app/lib/server/functions/deleteMessage.ts index f00e7edb98e7b..24176cb529dd8 100644 --- a/apps/meteor/app/lib/server/functions/deleteMessage.ts +++ b/apps/meteor/app/lib/server/functions/deleteMessage.ts @@ -61,7 +61,7 @@ export async function deleteMessage(message: IMessage, user: IUser): Promise { + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Error('error-invalid-room'); + } + + if (!user.username) { + throw new Error('error-invalid-user'); + } + + const subscription = await Subscriptions.findOneBannedSubscription(rid, user._id); + if (!subscription) { + throw new Error('error-user-not-banned'); + } + + // Remove the subscription entirely — the user is no longer banned but also not a member. + // Room count and __rooms were already adjusted during ban, so we only delete the document. + await Subscriptions.removeById(subscription._id); + + await Message.saveSystemMessage('user-unbanned', rid, user.username, user, { + u: { _id: byUser._id, username: byUser.username }, + }); + + void notifyOnSubscriptionChanged(subscription, 'removed'); + void notifyOnRoomChangedById(rid); + + const inviterUser = await Users.findOneById(byUser._id); + if (inviterUser) { + await afterUnbanFromRoomCallback.run({ unbannedUser: user, userWhoUnbanned: inviterUser }, room); + } +}; diff --git a/apps/meteor/app/lib/server/functions/extractMentionsFromMessageAST.ts b/apps/meteor/app/lib/server/functions/extractMentionsFromMessageAST.ts new file mode 100644 index 0000000000000..441aa8147f5d0 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/extractMentionsFromMessageAST.ts @@ -0,0 +1,63 @@ +import type { Root, Paragraph, Blocks, Inlines, UserMention, ChannelMention, Task, ListItem, BigEmoji } from '@rocket.chat/message-parser'; + +type ExtractedMentions = { + mentions: string[]; + channels: string[]; +}; + +type MessageNode = Paragraph | Blocks | Inlines | Task | ListItem | BigEmoji; + +function isUserMention(node: MessageNode): node is UserMention { + return node.type === 'MENTION_USER'; +} + +function isChannelMention(node: MessageNode): node is ChannelMention { + return node.type === 'MENTION_CHANNEL'; +} + +function hasArrayValue(node: MessageNode): node is MessageNode & { value: MessageNode[] } { + return Array.isArray(node.value); +} + +function hasObjectValue(node: MessageNode): node is MessageNode & { value: Record } { + return typeof node.value === 'object' && node.value !== null && !Array.isArray(node.value); +} + +function traverse(node: MessageNode, mentions: Set, channels: Set): void { + if (isUserMention(node)) { + mentions.add(node.value.value); + return; + } + + if (isChannelMention(node)) { + channels.add(node.value.value); + return; + } + + if (hasArrayValue(node)) { + for (const child of node.value) { + traverse(child, mentions, channels); + } + return; + } + + if (hasObjectValue(node)) { + for (const key of Object.keys(node.value)) { + traverse(node.value[key], mentions, channels); + } + } +} + +export function extractMentionsFromMessageAST(ast: Root): ExtractedMentions { + const mentions = new Set(); + const channels = new Set(); + + for (const node of ast) { + traverse(node, mentions, channels); + } + + return { + mentions: Array.from(mentions), + channels: Array.from(channels), + }; +} diff --git a/apps/meteor/app/lib/server/functions/getAvatarSuggestionForUser.ts b/apps/meteor/app/lib/server/functions/getAvatarSuggestionForUser.ts index 278a0c5bd8ded..9404cade7c488 100644 --- a/apps/meteor/app/lib/server/functions/getAvatarSuggestionForUser.ts +++ b/apps/meteor/app/lib/server/functions/getAvatarSuggestionForUser.ts @@ -141,7 +141,7 @@ export async function getAvatarSuggestionForUser( const avatars = []; - for await (const avatarProvider of Object.values(avatarProviders)) { + for (const avatarProvider of Object.values(avatarProviders)) { const avatar = await avatarProvider(user); if (avatar) { if (Array.isArray(avatar)) { @@ -153,7 +153,7 @@ export async function getAvatarSuggestionForUser( } const validAvatars: Record = {}; - for await (const avatar of avatars) { + for (const avatar of avatars) { try { const response = await fetch(avatar.url, { ignoreSsrfValidation: false, diff --git a/apps/meteor/app/lib/server/functions/getRoomByNameOrIdWithOptionToJoin.ts b/apps/meteor/app/lib/server/functions/getRoomByNameOrIdWithOptionToJoin.ts index 6217342d0c534..d6afca615515b 100644 --- a/apps/meteor/app/lib/server/functions/getRoomByNameOrIdWithOptionToJoin.ts +++ b/apps/meteor/app/lib/server/functions/getRoomByNameOrIdWithOptionToJoin.ts @@ -1,6 +1,6 @@ import { Room } from '@rocket.chat/core-services'; import type { IRoom, IUser, RoomType } from '@rocket.chat/core-typings'; -import { Rooms, Users } from '@rocket.chat/models'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { isObject } from '../../../../lib/utils/isObject'; @@ -88,7 +88,10 @@ export const getRoomByNameOrIdWithOptionToJoin = async ({ // If the room type is channel and joinChannel has been passed, try to join them // if they can't join the room, this will error out! if (room.t === 'c' && joinChannel) { - await Room.join({ room, user }); + const sub = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { _id: 1 } }); + if (!sub) { + await Room.join({ room, user }); + } } return room; diff --git a/apps/meteor/app/lib/server/functions/getUsernameSuggestion.ts b/apps/meteor/app/lib/server/functions/getUsernameSuggestion.ts index 9400096d8bf51..f91e39e8ba088 100644 --- a/apps/meteor/app/lib/server/functions/getUsernameSuggestion.ts +++ b/apps/meteor/app/lib/server/functions/getUsernameSuggestion.ts @@ -60,7 +60,7 @@ export async function generateUsernameSuggestion(user: Pick e); - for await (const item of usernames) { + for (const item of usernames) { if (await usernameIsAvailable(item)) { return item; } @@ -71,7 +71,6 @@ export async function generateUsernameSuggestion(user: Pick { export function processWebhookMessage( messageObj: Payload & { separateResponse: true }, - user: IUser & { username: RequiredField }, + user: RequiredField, defaultValues?: DefaultValues, ): Promise; export function processWebhookMessage( messageObj: Payload & { separateResponse?: false | undefined }, - user: IUser & { username: RequiredField }, + user: RequiredField, defaultValues?: DefaultValues, ): Promise; @@ -148,7 +148,7 @@ export async function processWebhookMessage( */ separateResponse?: boolean; }, - user: IUser & { username: RequiredField }, + user: RequiredField, defaultValues: DefaultValues = { channel: '', alias: '', avatar: '', emoji: '' }, ) { const rooms: ({ channel: string } & ({ room: IRoom } | { room: IRoom | null; error?: any }))[] = []; @@ -166,7 +166,7 @@ export async function processWebhookMessage( const message = buildMessage(messageObj, defaultValues); - for await (const channel of channels) { + for (const channel of channels) { const channelType = channel[0]; const channelValue = channel.slice(1); let room: IRoom | null = null; @@ -189,7 +189,7 @@ export async function processWebhookMessage( } } - for await (const roomData of rooms) { + for (const roomData of rooms) { if ('error' in roomData && roomData.error) { if (messageObj.separateResponse) { sentData.push({ channel: roomData.channel, error: roomData.error }); diff --git a/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts b/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts index a22556fb88f94..18ae8ac0e7dc9 100644 --- a/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts +++ b/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts @@ -68,7 +68,7 @@ export const relinquishRoomOwnerships = async function ( // change owners const changeOwner = subscribedRooms.filter(({ shouldChangeOwner }) => shouldChangeOwner); - for await (const { newOwner, rid } of changeOwner) { + for (const { newOwner, rid } of changeOwner) { newOwner && (await addUserRolesAsync(newOwner, ['owner'], rid)); } diff --git a/apps/meteor/app/lib/server/functions/saveUser/saveNewUser.ts b/apps/meteor/app/lib/server/functions/saveUser/saveNewUser.ts index 35fb39b5336f6..3cc1c1227f63f 100644 --- a/apps/meteor/app/lib/server/functions/saveUser/saveNewUser.ts +++ b/apps/meteor/app/lib/server/functions/saveUser/saveNewUser.ts @@ -1,6 +1,7 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import Gravatar from 'gravatar'; +import { Accounts } from 'meteor/accounts-base'; import { getNewUserRoles } from '../../../../../server/services/user/lib/getNewUserRoles'; import { settings } from '../../../../settings/server'; @@ -16,12 +17,13 @@ export const saveNewUser = async function (userData: SaveUserData, sendPassword: await validateEmailDomain(userData.email); const roles = (!!userData.roles && userData.roles.length > 0 && userData.roles) || getNewUserRoles(); - const isGuest = roles && roles.length === 1 && roles.includes('guest'); + const isGuest = roles?.length === 1 && roles.includes('guest'); // insert user const createUser: Record = { username: userData.username, password: userData.password, + ...(userData.name && { name: userData.name }), joinDefaultChannels: userData.joinDefaultChannels, isGuest, globalRoles: roles, diff --git a/apps/meteor/app/lib/server/functions/sendMessage.ts b/apps/meteor/app/lib/server/functions/sendMessage.ts index c6a8483b67b4d..5500b1bdf9453 100644 --- a/apps/meteor/app/lib/server/functions/sendMessage.ts +++ b/apps/meteor/app/lib/server/functions/sendMessage.ts @@ -2,10 +2,10 @@ import { AppEvents, Apps } from '@rocket.chat/apps'; import { Message } from '@rocket.chat/core-services'; import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; +import { isAbsoluteURL } from '@rocket.chat/tools'; import { Match, check } from 'meteor/check'; import { isRelativeURL } from '../../../../lib/utils/isRelativeURL'; -import { isURL } from '../../../../lib/utils/isURL'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { FileUpload } from '../../../file-upload/server'; import { settings } from '../../../settings/server'; @@ -32,7 +32,7 @@ type SendMessageOptions = { const validFullURLParam = Match.Where((value) => { check(value, String); - if (!isURL(value) && !value.startsWith(FileUpload.getPath())) { + if (!isAbsoluteURL(value) && !value.startsWith(FileUpload.getPath())) { throw new Error('Invalid href value provided'); } @@ -46,7 +46,7 @@ const validFullURLParam = Match.Where((value) => { const validPartialURLParam = Match.Where((value) => { check(value, String); - if (!isRelativeURL(value) && !isURL(value) && !value.startsWith(FileUpload.getPath())) { + if (!isRelativeURL(value) && !isAbsoluteURL(value) && !value.startsWith(FileUpload.getPath())) { throw new Error('Invalid href value provided'); } diff --git a/apps/meteor/app/lib/server/functions/setUserAvatar.ts b/apps/meteor/app/lib/server/functions/setUserAvatar.ts index 2e4c07b535ed9..d65e73a887d63 100644 --- a/apps/meteor/app/lib/server/functions/setUserAvatar.ts +++ b/apps/meteor/app/lib/server/functions/setUserAvatar.ts @@ -91,7 +91,7 @@ export async function setUserAvatar( ): Promise { if (service === 'initials') { if (updater) { - updater.set('avatarOrigin', origin); + updater.set('avatarOrigin', service); } else { await Users.setAvatarData(user._id, service, null, { session }); } @@ -199,7 +199,7 @@ export async function setUserAvatar( if (service) { if (updater) { - updater.set('avatarOrigin', origin); + updater.set('avatarOrigin', service); updater.set('avatarETag', avatarETag); } else { // TODO: Why was this timeout added? diff --git a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts index 0a347c7526abc..ae6255a5dfd18 100644 --- a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts +++ b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts @@ -201,7 +201,7 @@ export const sendNotification = async ({ } const attachments = firstAttachment ? [firstAttachment, ...(message.attachments ?? [])].filter(Boolean) : []; - for await (const email of receiver.emails) { + for (const email of receiver.emails) { if (email.verified) { queueItems.push({ type: 'email', @@ -401,7 +401,7 @@ export async function sendAllNotifications(message: IMessage, room: IRoom) { return message; } - if (!room || room.t == null) { + if (room?.t == null) { return message; } diff --git a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts index 0ceda8ab359e9..c222a7a8b753d 100644 --- a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts +++ b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts @@ -1,5 +1,5 @@ import { api } from '@rocket.chat/core-services'; -import { isRoomNativeFederated, type IUser } from '@rocket.chat/core-typings'; +import { isBannedSubscription, isRoomNativeFederated, type IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; import { Match } from 'meteor/check'; @@ -96,6 +96,11 @@ export const addUsersToRoomMethod = async (userId: string, data: { rid: string; } const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, newUser._id); + if (subscription && isBannedSubscription(subscription)) { + throw new Meteor.Error('error-user-is-banned', 'User is banned from this room', { + method: 'addUsersToRoom', + }); + } if (!subscription) { return addUserToRoom(data.rid, newUser, user); } diff --git a/apps/meteor/app/lib/server/methods/getRoomRoles.ts b/apps/meteor/app/lib/server/methods/getRoomRoles.ts index 0e271e2d64f94..b929181ee301d 100644 --- a/apps/meteor/app/lib/server/methods/getRoomRoles.ts +++ b/apps/meteor/app/lib/server/methods/getRoomRoles.ts @@ -1,4 +1,4 @@ -import type { IRoom } from '@rocket.chat/core-typings'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; import { Rooms } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -15,10 +15,10 @@ declare module '@rocket.chat/ddp-client' { } } -export const executeGetRoomRoles = async (rid: IRoom['_id'], fromUserId?: string | null) => { +export const executeGetRoomRoles = async (rid: IRoom['_id'], fromUser?: IUser | null) => { check(rid, String); - if (!fromUserId && settings.get('Accounts_AllowAnonymousRead') === false) { + if (!fromUser && settings.get('Accounts_AllowAnonymousRead') === false) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getRoomRoles' }); } @@ -27,7 +27,7 @@ export const executeGetRoomRoles = async (rid: IRoom['_id'], fromUserId?: string throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'getRoomRoles' }); } - if (fromUserId && !(await canAccessRoomAsync(room, { _id: fromUserId }))) { + if (fromUser && !(await canAccessRoomAsync(room, fromUser))) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getRoomRoles' }); } diff --git a/apps/meteor/app/lib/server/methods/joinRoom.ts b/apps/meteor/app/lib/server/methods/joinRoom.ts index d11050bbb2201..787f729bd578b 100644 --- a/apps/meteor/app/lib/server/methods/joinRoom.ts +++ b/apps/meteor/app/lib/server/methods/joinRoom.ts @@ -1,5 +1,5 @@ import { Room } from '@rocket.chat/core-services'; -import { type IRoom } from '@rocket.chat/core-typings'; +import type { IRoom } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Rooms } from '@rocket.chat/models'; import { check } from 'meteor/check'; diff --git a/apps/meteor/app/lib/server/methods/sendMessage.ts b/apps/meteor/app/lib/server/methods/sendMessage.ts index 895ccc27a5b87..a1208543d6841 100644 --- a/apps/meteor/app/lib/server/methods/sendMessage.ts +++ b/apps/meteor/app/lib/server/methods/sendMessage.ts @@ -30,7 +30,7 @@ import { RateLimiter } from '../lib'; * @returns */ export async function executeSendMessage( - uid: IUser['_id'], + uid: IUser['_id'] | IUser, message: AtLeast, extraInfo?: { ts?: Date; previewUrls?: string[] }, ) { @@ -71,7 +71,7 @@ export async function executeSendMessage( } } - const user = await Users.findOneById(uid); + const user = typeof uid === 'string' ? await Users.findOneById(uid) : uid; if (!user?.username) { throw new Meteor.Error('error-invalid-user', 'Invalid user'); } @@ -95,7 +95,7 @@ export async function executeSendMessage( check(rid, String); try { - const room = await canSendMessageAsync(rid, { uid, username: user.username, type: user.type }); + const room = await canSendMessageAsync(rid, user); if (room.encrypted && settings.get('E2E_Enable') && !settings.get('E2E_Allow_Unencrypted_Messages')) { if (message.t !== 'e2e') { @@ -112,7 +112,7 @@ export async function executeSendMessage( const errorMessage: RocketchatI18nKeys = typeof err === 'string' ? err : err.error || err.message; const errorContext: TOptions = err.details ?? {}; - void api.broadcast('notify.ephemeralMessage', uid, message.rid, { + void api.broadcast('notify.ephemeralMessage', user._id, message.rid, { msg: i18n.t(errorMessage, { ...errorContext, lng: user.language }), }); @@ -151,8 +151,8 @@ Meteor.methods({ sentByEmail: Match.Maybe(Boolean), }); - const uid = Meteor.userId(); - if (!uid) { + const user = (await Meteor.userAsync()) as IUser; + if (!user) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'sendMessage', }); @@ -163,7 +163,7 @@ Meteor.methods({ } try { - return await applyAirGappedRestrictionsValidation(() => executeSendMessage(uid, message, { previewUrls })); + return await applyAirGappedRestrictionsValidation(() => executeSendMessage(user, message, { previewUrls })); } catch (error: any) { if (['error-not-allowed', 'restricted-workspace'].includes(error.error || error.message)) { throw new Meteor.Error(error.error || error.message, error.reason, { diff --git a/apps/meteor/app/livechat/client/lib/stream/queueManager.ts b/apps/meteor/app/livechat/client/lib/stream/queueManager.ts index b5dc34b6314a7..5fc9be444ec98 100644 --- a/apps/meteor/app/livechat/client/lib/stream/queueManager.ts +++ b/apps/meteor/app/livechat/client/lib/stream/queueManager.ts @@ -1,4 +1,11 @@ -import type { ILivechatDepartment, ILivechatInquiryRecord, IOmnichannelAgent, Serialized } from '@rocket.chat/core-typings'; +import { + LivechatInquiryStatus, + type ILivechatDepartment, + type ILivechatInquiryRecord, + type IOmnichannelAgent, + type Serialized, +} from '@rocket.chat/core-typings'; +import { Tracker } from 'meteor/tracker'; import { useLivechatInquiryStore } from '../../../../../client/hooks/useLivechatInquiryStore'; import { queryClient } from '../../../../../client/lib/queryClient'; @@ -20,7 +27,7 @@ const events = { await invalidateRoomQueries(inquiry.rid); }, changed: async (inquiry: ILivechatInquiryRecord) => { - if (inquiry.status !== 'queued' || (inquiry.department && !departments.has(inquiry.department))) { + if (inquiry.status !== LivechatInquiryStatus.QUEUED || (inquiry.department && !departments.has(inquiry.department))) { return removeInquiry(inquiry); } diff --git a/apps/meteor/app/livechat/imports/server/rest/appearance.ts b/apps/meteor/app/livechat/imports/server/rest/appearance.ts index 3f7f3cbe6d137..7ded38f56ea68 100644 --- a/apps/meteor/app/livechat/imports/server/rest/appearance.ts +++ b/apps/meteor/app/livechat/imports/server/rest/appearance.ts @@ -97,7 +97,7 @@ API.v1.addRoute( const auditSettingOperation = updateAuditedByUser({ _id: this.userId, - username: this.user.username!, + username: this.user.username, ip: this.requestIp, useragent: this.request.headers.get('user-agent') || '', }); diff --git a/apps/meteor/app/livechat/imports/server/rest/inquiries.ts b/apps/meteor/app/livechat/imports/server/rest/inquiries.ts index a69604bd083ec..4846fb5100602 100644 --- a/apps/meteor/app/livechat/imports/server/rest/inquiries.ts +++ b/apps/meteor/app/livechat/imports/server/rest/inquiries.ts @@ -18,6 +18,7 @@ import { getPaginationItems } from '../../../../api/server/helpers/getPagination import { findInquiries, findOneInquiryByRoomId } from '../../../server/api/lib/inquiries'; import { returnRoomAsInquiry } from '../../../server/lib/rooms'; import { takeInquiry } from '../../../server/lib/takeInquiry'; +import { Meteor } from 'meteor/meteor'; API.v1.addRoute( 'livechat/inquiries.list', @@ -69,7 +70,7 @@ API.v1.addRoute( return API.v1.failure('The user is invalid'); } return API.v1.success({ - inquiry: await takeInquiry(this.bodyParams.userId || this.userId, this.bodyParams.inquiryId), + inquiry: await takeInquiry(this.bodyParams.userId || this.userId, this.bodyParams.inquiryId, this.bodyParams.options), }); }, }, diff --git a/apps/meteor/app/livechat/imports/server/rest/rooms.ts b/apps/meteor/app/livechat/imports/server/rest/rooms.ts index 917647da06334..f7462495013bf 100644 --- a/apps/meteor/app/livechat/imports/server/rest/rooms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/rooms.ts @@ -29,7 +29,7 @@ API.v1.addRoute( { async get() { const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields } = await this.parseJsonQuery(); + const { sort, fields, query } = await this.parseJsonQuery(); const { agents, departmentId, open, tags, roomName, onhold, queued, units } = this.queryParams; const { createdAt, customFields, closedAt } = this.queryParams; @@ -71,6 +71,7 @@ API.v1.addRoute( onhold, queued, units, + query, options: { offset, count, sort, fields }, callerId: this.userId, }), diff --git a/apps/meteor/app/livechat/server/api/lib/rooms.ts b/apps/meteor/app/livechat/server/api/lib/rooms.ts index f8b4a5d4acd10..5632a56d6411d 100644 --- a/apps/meteor/app/livechat/server/api/lib/rooms.ts +++ b/apps/meteor/app/livechat/server/api/lib/rooms.ts @@ -16,6 +16,7 @@ export async function findRooms({ onhold, queued, units, + query, options: { offset, count, fields, sort }, callerId, }: { @@ -36,10 +37,12 @@ export async function findRooms({ onhold?: string | boolean; queued?: string | boolean; units?: Array; + query?: Record; options: { offset: number; count: number; fields: Record; sort: Record }; callerId: string; }): Promise }>> { - const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { unitsFilter: units, userId: callerId }); + const extraQueryBase = await callbacks.run('livechat.applyRoomRestrictions', {}, { unitsFilter: units, userId: callerId }); + const extraQuery = { ...extraQueryBase, ...(query || {}) }; const { cursor, totalCount } = LivechatRooms.findRoomsWithCriteria({ agents, roomName, diff --git a/apps/meteor/app/livechat/server/api/v1/integration.ts b/apps/meteor/app/livechat/server/api/v1/integration.ts index 48b748f4fb7be..3a9821a7a5b3d 100644 --- a/apps/meteor/app/livechat/server/api/v1/integration.ts +++ b/apps/meteor/app/livechat/server/api/v1/integration.ts @@ -26,9 +26,18 @@ API.v1.addRoute( } = this.bodyParams; const settingsIds = [ - typeof LivechatWebhookUrl !== 'undefined' && { _id: 'Livechat_webhookUrl', value: trim(LivechatWebhookUrl) }, - typeof LivechatSecretToken !== 'undefined' && { _id: 'Livechat_secret_token', value: trim(LivechatSecretToken) }, - typeof LivechatHttpTimeout !== 'undefined' && { _id: 'Livechat_http_timeout', value: LivechatHttpTimeout }, + typeof LivechatWebhookUrl !== 'undefined' && { + _id: 'Livechat_webhookUrl', + value: trim(String(LivechatWebhookUrl ?? '')), + }, + typeof LivechatSecretToken !== 'undefined' && { + _id: 'Livechat_secret_token', + value: trim(String(LivechatSecretToken ?? '')), + }, + typeof LivechatHttpTimeout !== 'undefined' && { + _id: 'Livechat_http_timeout', + value: Number(LivechatHttpTimeout ?? 0), + }, typeof LivechatWebhookOnStart !== 'undefined' && { _id: 'Livechat_webhook_on_start', value: !!LivechatWebhookOnStart }, typeof LivechatWebhookOnClose !== 'undefined' && { _id: 'Livechat_webhook_on_close', value: !!LivechatWebhookOnClose }, typeof LivechatWebhookOnChatTaken !== 'undefined' && { _id: 'Livechat_webhook_on_chat_taken', value: !!LivechatWebhookOnChatTaken }, @@ -53,7 +62,7 @@ API.v1.addRoute( const auditSettingOperation = updateAuditedByUser({ _id: this.userId, - username: this.user.username!, + username: this.user.username, ip: this.requestIp, useragent: this.request.headers.get('user-agent') || '', }); diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index a06c8c2743753..146c119dee02f 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -28,6 +28,7 @@ import { } from '@rocket.chat/rest-typings'; import { isPOSTLivechatVisitorDepartmentTransferParams } from '@rocket.chat/rest-typings/src/v1/omnichannel'; import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../../../server/lib/callbacks'; import { i18n } from '../../../../../server/lib/i18n'; @@ -67,7 +68,7 @@ API.v1.addRoute( agentId: Match.Maybe(String), }); - check(this.queryParams, extraCheckParams as any); + check(this.queryParams, extraCheckParams); const { token, rid, agentId, ...extraParams } = this.queryParams; @@ -291,7 +292,7 @@ API.v1.addRoute( }; const room = await LivechatRooms.findOneById(this.bodyParams.roomId); - if (!room || room.t !== 'l') { + if (room?.t !== 'l') { throw new Error('error-invalid-room'); } @@ -358,7 +359,7 @@ const livechatVisitorDepartmentTransfer = API.v1.post( } const room = await LivechatRooms.findOneById(rid); - if (!room || room.t !== 'l') { + if (room?.t !== 'l') { return API.v1.failure('error-invalid-room'); } @@ -463,7 +464,7 @@ API.v1.addRoute( const firstError = result.find((item) => item.status === 'rejected'); if (firstError) { - throw new Error((firstError as PromiseRejectedResult).reason.error); + throw new Error(firstError.reason.error); } await callbacks.run('livechat.saveInfo', await LivechatRooms.findOneById(roomData._id), { diff --git a/apps/meteor/app/livechat/server/api/v1/webhooks.ts b/apps/meteor/app/livechat/server/api/v1/webhooks.ts index 4a5fdb50f7e44..276a910502d69 100644 --- a/apps/meteor/app/livechat/server/api/v1/webhooks.ts +++ b/apps/meteor/app/livechat/server/api/v1/webhooks.ts @@ -66,6 +66,7 @@ API.v1.addRoute( body: sampleData, // SECURITY: Webhooks can only be configured by users with enough privileges. It's ok to disable this check here. ignoreSsrfValidation: true, + size: 10 * 1024 * 1024, } as ExtendedFetchOptions; const webhookUrl = settings.get('Livechat_webhookUrl'); diff --git a/apps/meteor/app/livechat/server/hooks/afterUserActions.ts b/apps/meteor/app/livechat/server/hooks/afterUserActions.ts index 95355d708412d..3b70b836d7ea6 100644 --- a/apps/meteor/app/livechat/server/hooks/afterUserActions.ts +++ b/apps/meteor/app/livechat/server/hooks/afterUserActions.ts @@ -1,4 +1,4 @@ -import { type IUser } from '@rocket.chat/core-typings'; +import type { IUser } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import { callbacks } from '../../../../server/lib/callbacks'; diff --git a/apps/meteor/app/livechat/server/hooks/sendToCRM.ts b/apps/meteor/app/livechat/server/hooks/sendToCRM.ts index 687fabe08b055..d4c11fba6a582 100644 --- a/apps/meteor/app/livechat/server/hooks/sendToCRM.ts +++ b/apps/meteor/app/livechat/server/hooks/sendToCRM.ts @@ -115,7 +115,7 @@ export async function sendToCRM( } if (messages) { - for await (const message of messages) { + for (const message of messages) { if (message.t && !sendMessageType(message.t)) { continue; } diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index 2ce0a3a66fe40..7d0f81d02b2e7 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -145,6 +145,9 @@ export const prepareLivechatRoom = async ( priorityWeight: LivechatPriorityWeight.NOT_SPECIFIED, estimatedWaitingTimeQueue: DEFAULT_SLA_CONFIG.ESTIMATED_WAITING_TIME_QUEUE, ...extraRoomInfo, + // marker field for unique index - only new rooms have this field (see #39087) + // allows index creation to succeed even if old duplicates exist + _enforceSingleRoom: true, } as InsertionModel; }; @@ -724,7 +727,7 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi } const { servedBy, chatQueued } = roomTaken; - if (!chatQueued && oldServedBy && servedBy && oldServedBy._id === servedBy._id) { + if (!chatQueued && oldServedBy && oldServedBy._id === servedBy?._id) { if (!department?.fallbackForwardDepartment?.length) { logger.debug({ msg: 'Cannot forward room. Chat assigned to agent instead', diff --git a/apps/meteor/app/livechat/server/lib/closeRoom.ts b/apps/meteor/app/livechat/server/lib/closeRoom.ts index dbaa2ad5d4b54..8ee99231ee609 100644 --- a/apps/meteor/app/livechat/server/lib/closeRoom.ts +++ b/apps/meteor/app/livechat/server/lib/closeRoom.ts @@ -42,7 +42,9 @@ export async function closeRoom(params: CloseRoomParams, attempts = 2): Promise< removedInquiryObj = removedInquiry; } catch (e) { logger.error({ err: e, msg: 'Failed to close room', afterAttempts: attempts }); - await session.abortTransaction(); + if (session.inTransaction()) { + await session.abortTransaction(); + } // Dont propagate transaction errors if (shouldRetryTransaction(e)) { if (attempts > 0) { @@ -186,7 +188,7 @@ async function doCloseRoom( } const updatedRoom = await LivechatRooms.closeRoomById(rid, closeData, { session }); - if (!params.forceClose && (!updatedRoom || updatedRoom.modifiedCount !== 1)) { + if (!params.forceClose && updatedRoom?.modifiedCount !== 1) { throw new Error('Error closing room'); } diff --git a/apps/meteor/app/livechat/server/lib/contacts/ContactMerger.ts b/apps/meteor/app/livechat/server/lib/contacts/ContactMerger.ts index f7775998d8374..a52b5bbc17417 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/ContactMerger.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/ContactMerger.ts @@ -92,8 +92,8 @@ export class ContactMerger { } private async loadDataForFields(session: ClientSession | undefined, ...fieldLists: FieldAndValue[][]): Promise { - for await (const fieldList of fieldLists) { - for await (const field of fieldList) { + for (const fieldList of fieldLists) { + for (const field of fieldList) { if (field.type !== 'manager' || 'id' in field.value) { continue; } diff --git a/apps/meteor/app/livechat/server/lib/contacts/registerContact.ts b/apps/meteor/app/livechat/server/lib/contacts/registerContact.ts index 802002cf1832c..007295abe62dc 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/registerContact.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/registerContact.ts @@ -97,7 +97,7 @@ export async function registerContact( const rooms: IOmnichannelRoom[] = await LivechatRooms.findByVisitorId(visitorId, {}, extraQuery).toArray(); if (rooms?.length) { - for await (const room of rooms) { + for (const room of rooms) { const { _id: rid } = room; const responses = await Promise.all([ diff --git a/apps/meteor/app/livechat/server/lib/rooms.ts b/apps/meteor/app/livechat/server/lib/rooms.ts index 070946089142e..03dfbb1dd704a 100644 --- a/apps/meteor/app/livechat/server/lib/rooms.ts +++ b/apps/meteor/app/livechat/server/lib/rooms.ts @@ -21,6 +21,7 @@ import { Users, ReadReceipts, } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; import { normalizeTransferredByData } from './Helper'; import { QueueManager } from './QueueManager'; diff --git a/apps/meteor/app/livechat/server/lib/sendTranscript.ts b/apps/meteor/app/livechat/server/lib/sendTranscript.ts index 11ca540ab87bf..199275f6a516b 100644 --- a/apps/meteor/app/livechat/server/lib/sendTranscript.ts +++ b/apps/meteor/app/livechat/server/lib/sendTranscript.ts @@ -15,6 +15,7 @@ import { MessageTypes } from '@rocket.chat/message-types'; import { LivechatRooms, Messages, Uploads, Users } from '@rocket.chat/models'; import createDOMPurify from 'dompurify'; import { JSDOM } from 'jsdom'; +import { Meteor } from 'meteor/meteor'; import moment from 'moment-timezone'; import { callbacks } from '../../../../server/lib/callbacks'; diff --git a/apps/meteor/app/livechat/server/lib/takeInquiry.ts b/apps/meteor/app/livechat/server/lib/takeInquiry.ts index 1c7541b276f72..3bea195ceaca2 100644 --- a/apps/meteor/app/livechat/server/lib/takeInquiry.ts +++ b/apps/meteor/app/livechat/server/lib/takeInquiry.ts @@ -55,9 +55,5 @@ export const takeInquiry = async ( username: user.username, }; - try { - await RoutingManager.takeInquiry(inquiry, agent, options ?? {}, room); - } catch (e: any) { - throw new Meteor.Error(e.message); - } + await RoutingManager.takeInquiry(inquiry, agent, options ?? {}, room); }; diff --git a/apps/meteor/app/livechat/server/lib/webhooks.ts b/apps/meteor/app/livechat/server/lib/webhooks.ts index b0d2cd94f80e2..661b428cc7cb8 100644 --- a/apps/meteor/app/livechat/server/lib/webhooks.ts +++ b/apps/meteor/app/livechat/server/lib/webhooks.ts @@ -29,6 +29,7 @@ export async function sendRequest( timeout, // SECURITY: Webhooks can only be configured by users with enough privileges. It's ok to disable this check here. ignoreSsrfValidation: true, + size: 10 * 1024 * 1024, }); if (result.status === 200) { diff --git a/apps/meteor/app/livechat/server/roomAccessValidator.internalService.ts b/apps/meteor/app/livechat/server/roomAccessValidator.internalService.ts index 2ee80129962a8..957d78317b9e1 100644 --- a/apps/meteor/app/livechat/server/roomAccessValidator.internalService.ts +++ b/apps/meteor/app/livechat/server/roomAccessValidator.internalService.ts @@ -10,7 +10,7 @@ export class AuthorizationLivechat extends ServiceClassInternal implements IAuth protected override internal = true; async canAccessRoom(room: IOmnichannelRoom, user?: Pick, extraData?: object): Promise { - for await (const validator of validators) { + for (const validator of validators) { if (await validator(room, user, extraData)) { return true; } diff --git a/apps/meteor/app/markdown/lib/parser/filtered/filtered.js b/apps/meteor/app/markdown/lib/parser/filtered/filtered.js index 260fc835d8a0a..868baaee80903 100644 --- a/apps/meteor/app/markdown/lib/parser/filtered/filtered.js +++ b/apps/meteor/app/markdown/lib/parser/filtered/filtered.js @@ -9,7 +9,7 @@ export const filtered = ( supportSchemesForLink: 'http,https', }, ) => { - const schemes = options.supportSchemesForLink.split(',').join('|'); + const schemes = (options.supportSchemesForLink || 'http,https').split(',').join('|'); // Remove block code backticks message = message.replace(/```/g, ''); diff --git a/apps/meteor/app/mentions/server/Mentions.ts b/apps/meteor/app/mentions/server/Mentions.ts index f6d40840b6488..f7b39b2632bb9 100644 --- a/apps/meteor/app/mentions/server/Mentions.ts +++ b/apps/meteor/app/mentions/server/Mentions.ts @@ -4,6 +4,7 @@ */ import { isE2EEMessage, type IMessage, type IRoom, type IUser } from '@rocket.chat/core-typings'; +import { extractMentionsFromMessageAST } from '../../lib/server/functions/extractMentionsFromMessageAST'; import { type MentionsParserArgs, MentionsParser } from '../lib/MentionsParser'; type MentionsServerArgs = MentionsParserArgs & { @@ -50,13 +51,25 @@ export class MentionsServer extends MentionsParser { isE2EEMessage(message) && e2eMentions?.e2eUserMentions && e2eMentions?.e2eUserMentions.length > 0 ? e2eMentions?.e2eUserMentions : this.getUserMentions(msg); - const mentionsAll: { _id: string; username: string }[] = []; - const userMentions = []; - for await (const m of mentions) { - const mention = m.includes(':') ? m.trim() : m.trim().substring(1); + return this.convertMentionsToUsers(mentions, rid, sender); + } + + async convertMentionsToUsers(mentions: string[], rid: string, sender: IMessage['u']): Promise { + const mentionsAll: { _id: string; username: string }[] = []; + const userMentions = new Set(); + + for (const m of mentions) { + let mention: string; + if (m.includes(':')) { + mention = m.trim(); + } else if (m.startsWith('@')) { + mention = m.substring(1); + } else { + mention = m; + } if (mention !== 'all' && mention !== 'here') { - userMentions.push(mention); + userMentions.add(mention); continue; } if (this.messageMaxAll() > 0 && (await this.getTotalChannelMembers(rid)) > this.messageMaxAll()) { @@ -69,7 +82,7 @@ export class MentionsServer extends MentionsParser { }); } - return [...mentionsAll, ...(userMentions.length ? await this.getUsers(userMentions) : [])]; + return [...mentionsAll, ...(userMentions.size ? await this.getUsers(Array.from(userMentions)) : [])]; } async getChannelbyMentions(message: IMessage) { @@ -79,15 +92,23 @@ export class MentionsServer extends MentionsParser { isE2EEMessage(message) && e2eMentions?.e2eChannelMentions && e2eMentions?.e2eChannelMentions.length > 0 ? e2eMentions?.e2eChannelMentions : this.getChannelMentions(msg); - return this.getChannels(channels.map((c) => c.trim().substring(1))); + return this.convertMentionsToChannels(channels); + } + + async convertMentionsToChannels(channels: string[]): Promise[]> { + return this.getChannels(channels.map((c) => (c.startsWith('#') ? c.substring(1) : c))); } async execute(message: IMessage) { - const mentionsAll = await this.getUsersByMentions(message); - const channels = await this.getChannelbyMentions(message); + if (message.md) { + const { mentions, channels } = extractMentionsFromMessageAST(message.md); + message.mentions = await this.convertMentionsToUsers(mentions, message.rid, message.u); + message.channels = await this.convertMentionsToChannels(channels); + return message; + } - message.mentions = mentionsAll; - message.channels = channels; + message.mentions = await this.getUsersByMentions(message); + message.channels = await this.getChannelbyMentions(message); return message; } 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 fdac8b0b77fa8..51a6f4aa2faf3 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts @@ -295,7 +295,7 @@ export class SAML { } let timeoutHandler: NodeJS.Timeout | undefined = undefined; - const redirect = (url?: string | undefined): void => { + const redirect = (url?: string): void => { if (!timeoutHandler) { // If the handler is null, then we already ended the response; return; @@ -493,7 +493,7 @@ export class SAML { private static async subscribeToSAMLChannels(channels: Array, user: IUser): Promise { const { includePrivateChannelsInUpdate } = SAMLUtils.globalSettings; try { - for await (let roomName of channels) { + for (let roomName of channels) { roomName = roomName.trim(); if (!roomName) { continue; diff --git a/apps/meteor/app/notification-queue/server/NotificationQueue.ts b/apps/meteor/app/notification-queue/server/NotificationQueue.ts index 52035313949a9..87218cb869eb6 100644 --- a/apps/meteor/app/notification-queue/server/NotificationQueue.ts +++ b/apps/meteor/app/notification-queue/server/NotificationQueue.ts @@ -81,7 +81,7 @@ class NotificationClass { } try { - for await (const item of notification.items) { + for (const item of notification.items) { switch (item.type) { case 'push': await this.push(notification, item); diff --git a/apps/meteor/app/oauth2-server-config/server/oauth/oauth2-server.ts b/apps/meteor/app/oauth2-server-config/server/oauth/oauth2-server.ts index a3cca5662d604..2a2ac65a5f4eb 100644 --- a/apps/meteor/app/oauth2-server-config/server/oauth/oauth2-server.ts +++ b/apps/meteor/app/oauth2-server-config/server/oauth/oauth2-server.ts @@ -75,8 +75,8 @@ API.v1.addAuthMethod((routeContext) => { const authorization = routeContext.request.headers.get('authorization') ?? undefined; const query = isPlainObject(routeContext.queryParams) ? routeContext.queryParams : {}; const accessToken = typeof query.access_token === 'string' ? query.access_token : undefined; - if ((routeContext.queryParams as Record)?.access_token) { - delete (routeContext.queryParams as Record).access_token; + if (routeContext.queryParams && 'access_token' in routeContext.queryParams) { + delete routeContext.queryParams.access_token; } return oAuth2ServerAuth({ authorization, accessToken }); diff --git a/apps/meteor/app/push/server/apn.spec.ts b/apps/meteor/app/push/server/apn.spec.ts new file mode 100644 index 0000000000000..583eaca2ffd69 --- /dev/null +++ b/apps/meteor/app/push/server/apn.spec.ts @@ -0,0 +1,131 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const sandbox = sinon.createSandbox(); + +const mocks = { + logger: { + debug: sandbox.stub(), + warn: sandbox.stub(), + error: sandbox.stub(), + }, + ApnProvider: sandbox.stub(), +}; + +const apnMock = { + 'Provider': mocks.ApnProvider, + 'Notification': sandbox.stub(), + '@noCallThru': true, +}; + +const { initAPN } = proxyquire.noCallThru().load('./apn', { + '@parse/node-apn': { + default: apnMock, + ...apnMock, + }, + './logger': { logger: mocks.logger }, +}); + +const baseOptions = { + apn: { + cert: 'cert-data', + key: 'key-data', + gateway: undefined as string | undefined, + }, + production: false, +}; + +const buildOptions = (overrides: Record = {}, apnOverrides: Record = {}) => ({ + ...baseOptions, + ...overrides, + apn: { + ...baseOptions.apn, + ...apnOverrides, + }, +}); + +describe('initAPN', () => { + beforeEach(() => { + sandbox.resetHistory(); + mocks.ApnProvider.reset(); + }); + + describe('APN provider initialization', () => { + it('should create apn.Provider with correct options', () => { + const options = buildOptions({ production: true }, { gateway: 'gateway.push.apple.com' }); + + initAPN({ + options, + absoluteUrl: 'https://example.com', + }); + + expect(mocks.ApnProvider.calledWithNew()).to.be.true; + }); + + it('should pass production flag from options to Provider', () => { + initAPN({ + options: buildOptions({ production: true }), + absoluteUrl: 'https://example.com', + }); + + expect(mocks.ApnProvider.firstCall.args[0]).to.have.property('production', true); + }); + + it('should pass production false when options.production is false', () => { + initAPN({ + options: buildOptions({ production: false }), + absoluteUrl: 'https://example.com', + }); + + expect(mocks.ApnProvider.firstCall.args[0]).to.have.property('production', false); + }); + + it('should pass cert and key to Provider', () => { + initAPN({ + options: buildOptions({}, { cert: 'my-cert', key: 'my-key' }), + absoluteUrl: 'https://example.com', + }); + + const providerArgs = mocks.ApnProvider.firstCall.args[0]; + expect(providerArgs).to.have.property('cert', 'my-cert'); + expect(providerArgs).to.have.property('key', 'my-key'); + }); + + it('should pass gateway to Provider when specified', () => { + initAPN({ + options: buildOptions({}, { gateway: 'gateway.push.apple.com' }), + absoluteUrl: 'https://example.com', + }); + + expect(mocks.ApnProvider.firstCall.args[0]).to.have.property('gateway', 'gateway.push.apple.com'); + }); + + it('should spread all apn options to Provider', () => { + initAPN({ + options: buildOptions({ production: true }, { cert: 'c', key: 'k', gateway: 'gateway.sandbox.push.apple.com' }), + absoluteUrl: 'https://example.com', + }); + + const providerArgs = mocks.ApnProvider.firstCall.args[0]; + expect(providerArgs).to.deep.equal({ + cert: 'c', + key: 'k', + gateway: 'gateway.sandbox.push.apple.com', + production: true, + }); + }); + + it('should not throw when Provider constructor throws', () => { + mocks.ApnProvider.throws(new Error('APN init failed')); + + expect(() => + initAPN({ + options: buildOptions(), + absoluteUrl: 'https://example.com', + }), + ).to.not.throw(); + }); + }); +}); diff --git a/apps/meteor/app/push/server/apn.ts b/apps/meteor/app/push/server/apn.ts index 634665cad9403..e8732a9daae5f 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 { IAppsTokens, RequiredField } from '@rocket.chat/core-typings'; +import type { IPushToken, 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: IAppsTokens['token']) => void; + _removeToken: (token: IPushToken['token']) => void; }) => { if (!apnConnection) { throw new Error('Apn Connection not initialized.'); @@ -137,7 +137,10 @@ export const initAPN = ({ options, absoluteUrl }: { options: RequiredField>; + 'raix:push-update'(options: PushUpdateOptions): Promise>; 'raix:push-setuser'(options: { id: string; userId: string }): Promise; } } -export const pushUpdate = async (options: PushUpdateOptions): Promise> => { - // we always store the hashed token to protect users - const hashedToken = Accounts._hashLoginToken(options.authToken); - - let doc; - - // lookup app by id if one was included - if (options.id) { - doc = await AppsTokens.findOne({ _id: options.id }); - } else if (options.userId) { - doc = await AppsTokens.findOne({ userId: options.userId }); - } - - // No doc was found - we check the database to see if - // we can find a match for the app via token and appName - if (!doc) { - doc = await AppsTokens.findOne({ - $and: [ - { token: options.token }, // Match token - { appName: options.appName }, // Match appName - { token: { $exists: true } }, // Make sure token exists - ], - }); - } - - // if we could not find the id or token then create it - if (!doc) { - // Rig default doc - doc = { - token: options.token, - authToken: hashedToken, - appName: options.appName, - userId: options.userId, - enabled: true, - createdAt: new Date(), - updatedAt: new Date(), - metadata: options.metadata || {}, - - // XXX: We might want to check the id - Why isnt there a match for id - // in the Meteor check... Normal length 17 (could be larger), and - // numbers+letters are used in Random.id() with exception of 0 and 1 - _id: options.id || Random.id(), - // The user wanted us to use a specific id, we didn't find this while - // searching. The client could depend on the id eg. as reference so - // we respect this and try to create a document with the selected id; - }; - - await AppsTokens.insertOne(doc); - } else { - // We found the app so update the updatedAt and set the token - await AppsTokens.updateOne( - { _id: doc._id }, - { - $set: { - updatedAt: new Date(), - token: options.token, - authToken: hashedToken, - }, - }, - ); - } - - if (doc.token) { - const removed = ( - await AppsTokens.deleteMany({ - $and: [ - { _id: { $ne: doc._id } }, - { token: doc.token }, // Match token - { appName: doc.appName }, // Match appName - { token: { $exists: true } }, // Make sure token exists - ], - }) - ).deletedCount; - - if (removed) { - logger.debug({ msg: 'Removed existing app items', removed }); - } - } - - logger.debug({ msg: 'Push token updated', doc }); - - // Return the doc we want to use - return doc; -}; - Meteor.methods({ async 'raix:push-update'(options) { logger.debug({ msg: 'Got push token from app', options }); @@ -124,11 +39,28 @@ Meteor.methods({ }); // The if user id is set then user id should match on client and connection - if (options.userId && options.userId !== this.userId) { + if (!this.userId || (options.userId && options.userId !== this.userId)) { throw new Meteor.Error(403, 'Forbidden access'); } - return pushUpdate(options); + // Retain old behavior: if id is not specified but userId is explicitly set, then update the user's first token + if (!options.id && options.userId) { + const firstDoc = await PushToken.findFirstByUserId(options.userId, { projection: { _id: 1 } }); + if (firstDoc) { + options.id = firstDoc._id; + } + } + + const authToken = Accounts._hashLoginToken(options.authToken); + + return Push.registerPushToken({ + ...(options.id && { _id: options.id }), + token: options.token, + appName: options.appName, + authToken, + userId: this.userId, + ...(options.metadata && { metadata: options.metadata }), + }); }, // Deprecated async 'raix:push-setuser'(id) { @@ -138,7 +70,7 @@ Meteor.methods({ } logger.debug({ msg: 'Setting userId for app', userId: this.userId, appId: id }); - const found = await AppsTokens.updateOne({ _id: id }, { $set: { userId: this.userId } }); + const found = await PushToken.updateOne({ _id: id }, { $set: { userId: this.userId } }); return !!found; }, diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index 04e217822156a..860900a92471c 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -1,5 +1,5 @@ -import type { IAppsTokens, RequiredField, Optional, IPushNotificationConfig } from '@rocket.chat/core-typings'; -import { AppsTokens } from '@rocket.chat/models'; +import type { IPushToken, RequiredField, Optional, IPushNotificationConfig } from '@rocket.chat/core-typings'; +import { PushToken } from '@rocket.chat/models'; import { ajv } from '@rocket.chat/rest-typings'; import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; @@ -112,8 +112,8 @@ type GatewayNotification = { query?: { userId: any; }; - token?: IAppsTokens['token']; - tokens?: IAppsTokens['token'][]; + token?: IPushToken['token']; + tokens?: IPushToken['token'][]; payload?: Record; delayUntil?: Date; createdAt: Date; @@ -123,8 +123,8 @@ type GatewayNotification = { export type NativeNotificationParameters = { userTokens: string | string[]; notification: PendingPushNotification; - _replaceToken: (currentToken: IAppsTokens['token'], newToken: IAppsTokens['token']) => void; - _removeToken: (token: IAppsTokens['token']) => void; + _replaceToken: (currentToken: IPushToken['token'], newToken: IPushToken['token']) => void; + _removeToken: (token: IPushToken['token']) => void; options: RequiredField; }; @@ -167,12 +167,12 @@ class PushClass { } } - private replaceToken(currentToken: IAppsTokens['token'], newToken: IAppsTokens['token']): void { - void AppsTokens.updateMany({ token: currentToken }, { $set: { token: newToken } }); + private replaceToken(currentToken: IPushToken['token'], newToken: IPushToken['token']): void { + void PushToken.updateMany({ token: currentToken }, { $set: { token: newToken } }); } - private removeToken(token: IAppsTokens['token']): void { - void AppsTokens.deleteOne({ token }); + private removeToken(token: IPushToken['token']): void { + void PushToken.deleteOne({ token }); } private shouldUseGateway(): boolean { @@ -180,7 +180,7 @@ class PushClass { } private async sendNotificationNative( - app: IAppsTokens, + app: IPushToken, notification: PendingPushNotification, countApn: string[], countGcm: string[], @@ -275,7 +275,7 @@ class PushClass { if (result.status === 406) { logger.info({ msg: 'removing push token', token }); - await AppsTokens.deleteMany({ + await PushToken.deleteMany({ $or: [ { 'token.apn': token, @@ -325,7 +325,7 @@ class PushClass { } private async sendNotificationGateway( - app: IAppsTokens, + app: IPushToken, notification: PendingPushNotification, countApn: string[], countGcm: string[], @@ -378,7 +378,7 @@ class PushClass { $or: [{ 'token.apn': { $exists: true } }, { 'token.gcm': { $exists: true } }], }; - const appTokens = AppsTokens.find(query); + const appTokens = PushToken.find(query); for await (const app of appTokens) { logger.debug({ msg: 'send to token', token: app.token }); @@ -402,15 +402,15 @@ class PushClass { // Add some verbosity about the send result, making sure the developer // understands what just happened. if (!countApn.length && !countGcm.length) { - if ((await AppsTokens.estimatedDocumentCount()) === 0) { + if ((await PushToken.estimatedDocumentCount()) === 0) { logger.debug('GUIDE: The "AppsTokens" is empty - No clients have registered on the server yet...'); } } else if (!countApn.length) { - if ((await AppsTokens.countApnTokens()) === 0) { + if ((await PushToken.countApnTokens()) === 0) { logger.debug('GUIDE: The "AppsTokens" - No APN clients have registered on the server yet...'); } } else if (!countGcm.length) { - if ((await AppsTokens.countGcmTokens()) === 0) { + if ((await PushToken.countGcmTokens()) === 0) { logger.debug('GUIDE: The "AppsTokens" - No GCM clients have registered on the server yet...'); } } diff --git a/apps/meteor/app/retention-policy/server/cronPruneMessages.ts b/apps/meteor/app/retention-policy/server/cronPruneMessages.ts index 640aa517a6799..fbd3f4763d445 100644 --- a/apps/meteor/app/retention-policy/server/cronPruneMessages.ts +++ b/apps/meteor/app/retention-policy/server/cronPruneMessages.ts @@ -35,7 +35,7 @@ async function job(): Promise { const ignoreDiscussionQuery = ignoreDiscussion ? { prid: { $exists: false } } : {}; // get all rooms with default values - for await (const type of types) { + for (const type of types) { const maxAge = getMaxAgeSettingIdByRoomType(type) || 0; const latest = new Date(now.getTime() - maxAge); diff --git a/apps/meteor/app/settings/server/CachedSettings.ts b/apps/meteor/app/settings/server/CachedSettings.ts index 3c46dd05a6806..8332f45c5c497 100644 --- a/apps/meteor/app/settings/server/CachedSettings.ts +++ b/apps/meteor/app/settings/server/CachedSettings.ts @@ -181,9 +181,15 @@ export class CachedSettings const settings = _id.map((id) => this.store.get(id)?.value); callback(settings as T[]); } - const mergeFunction = _.debounce((): void => { - callback(_id.map((id) => this.store.get(id)?.value) as T[]); - }, 100); + + const mergeFunction = + process.env.TEST_MODE !== 'true' + ? _.debounce((): void => { + callback(_id.map((id) => this.store.get(id)?.value) as T[]); + }, 100) + : (): void => { + callback(_id.map((id) => this.store.get(id)?.value) as T[]); + }; const fns = _id.map((id) => this.on(id, mergeFunction)); return (): void => { diff --git a/apps/meteor/app/slackbridge/server/SlackAPI.ts b/apps/meteor/app/slackbridge/server/SlackAPI.ts index a352537059c0c..1862e31501441 100644 --- a/apps/meteor/app/slackbridge/server/SlackAPI.ts +++ b/apps/meteor/app/slackbridge/server/SlackAPI.ts @@ -28,7 +28,7 @@ export class SlackAPI { if (response && response && Array.isArray(response.channels) && response.channels.length > 0) { channels = channels.concat(response.channels); - if (response.response_metadata && response.response_metadata.next_cursor) { + if (response.response_metadata?.next_cursor) { const nextChannels = await this.getChannels(response.response_metadata.next_cursor); channels = channels.concat(nextChannels); } @@ -56,7 +56,7 @@ export class SlackAPI { if (response && response && Array.isArray(response.channels) && response.channels.length > 0) { groups = groups.concat(response.channels); - if (response.response_metadata && response.response_metadata.next_cursor) { + if (response.response_metadata?.next_cursor) { const nextGroups = await this.getGroups(response.response_metadata.next_cursor); groups = groups.concat(nextGroups); } @@ -87,7 +87,6 @@ export class SlackAPI { let members = []; let currentCursor = ''; for (let index = 0; index < num_members; index += MAX_MEMBERS_PER_CALL) { - // eslint-disable-next-line no-await-in-loop const request = await fetch('https://slack.com/api/conversations.members', { // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. ignoreSsrfValidation: true, @@ -100,11 +99,10 @@ export class SlackAPI { ...(currentCursor && { cursor: currentCursor }), }, }); - // eslint-disable-next-line no-await-in-loop const response = await request.json(); if (response && response && request.status === 200 && request.ok && Array.isArray(response.members)) { members = members.concat(response.members); - const hasMoreItems = response.response_metadata && response.response_metadata.next_cursor; + const hasMoreItems = response.response_metadata?.next_cursor; if (hasMoreItems) { currentCursor = response.response_metadata.next_cursor; } diff --git a/apps/meteor/app/slackbridge/server/SlackAdapter.ts b/apps/meteor/app/slackbridge/server/SlackAdapter.ts index e62d0bcdcd932..c0eae9cfb3076 100644 --- a/apps/meteor/app/slackbridge/server/SlackAdapter.ts +++ b/apps/meteor/app/slackbridge/server/SlackAdapter.ts @@ -607,7 +607,7 @@ export default class SlackAdapter { * https://api.slack.com/events/message */ async onMessage(slackMessage, isImporting) { - const isAFileShare = slackMessage && slackMessage.files && Array.isArray(slackMessage.files) && slackMessage.files.length; + const isAFileShare = slackMessage?.files && Array.isArray(slackMessage.files) && slackMessage.files.length; if (isAFileShare) { await this.processFileShare(slackMessage); return; @@ -841,22 +841,22 @@ export default class SlackAdapter { } async postMessage(slackChannel, rocketMessage) { - if (slackChannel && slackChannel.id) { - let iconUrl = getUserAvatarURL(rocketMessage.u && rocketMessage.u.username); + if (slackChannel?.id) { + let iconUrl = getUserAvatarURL(rocketMessage.u?.username); if (iconUrl) { iconUrl = Meteor.absoluteUrl().replace(/\/$/, '') + iconUrl; } const data = { text: rocketMessage.msg, channel: slackChannel.id, - username: rocketMessage.u && rocketMessage.u.username, + username: rocketMessage.u?.username, icon_url: iconUrl, link_names: 1, }; if (rocketMessage.tmid) { const tmessage = await Messages.findOneById(rocketMessage.tmid); - if (tmessage && tmessage.slackTs) { + if (tmessage?.slackTs) { data.thread_ts = tmessage.slackTs; } } @@ -873,7 +873,7 @@ export default class SlackAdapter { this.removeMessageBeingSent(data); } - if (postResult && postResult.message && postResult.message.bot_id && postResult.message.ts) { + if (postResult?.message?.bot_id && postResult.message.ts) { this.slackBotId = postResult.message.bot_id; await Messages.setSlackBotIdAndSlackTs(rocketMessage._id, postResult.message.bot_id, postResult.message.ts); slackLogger.debug({ @@ -890,7 +890,7 @@ export default class SlackAdapter { https://api.slack.com/methods/chat.update */ async postMessageUpdate(slackChannel, rocketMessage) { - if (slackChannel && slackChannel.id) { + if (slackChannel?.id) { const data = { ts: this.getTimeStamp(rocketMessage), channel: slackChannel.id, @@ -931,7 +931,7 @@ export default class SlackAdapter { } const file = slackMessage.files[0]; - if (file && file.url_private_download !== undefined) { + if (file?.url_private_download !== undefined) { const rocketChannel = await this.rocket.getChannel(slackMessage); const rocketUser = await this.rocket.getUser(slackMessage.user); @@ -1158,7 +1158,7 @@ export default class SlackAdapter { } async processShareMessage(rocketChannel, rocketUser, slackMessage, isImporting) { - if (slackMessage.file && slackMessage.file.url_private_download !== undefined) { + if (slackMessage.file?.url_private_download !== undefined) { const details = { message_id: this.createSlackMessageId(slackMessage.ts), name: slackMessage.file.name, @@ -1178,7 +1178,7 @@ export default class SlackAdapter { } async processPinnedItemMessage(rocketChannel, rocketUser, slackMessage, isImporting) { - if (slackMessage.attachments && slackMessage.attachments[0] && slackMessage.attachments[0].text) { + if (slackMessage.attachments?.[0]?.text) { // TODO: refactor this logic to use the service to send this system message instead of using sendMessage const rocketMsgObj = { rid: rocketChannel._id, @@ -1288,7 +1288,7 @@ export default class SlackAdapter { attachment.image_url = url; attachment.image_type = file.type; attachment.image_size = file.size; - attachment.image_dimensions = file.identify && file.identify.size; + attachment.image_dimensions = file.identify?.size; } if (/^audio\/.+/.test(file.type)) { attachment.audio_url = url; @@ -1347,7 +1347,7 @@ export default class SlackAdapter { if (channel) { const members = await this.slackAPI.getMembers(channelMap.id); if (members && Array.isArray(members) && members.length) { - for await (const member of members) { + for (const member of members) { const user = (await this.rocket.findUser(member)) || (await this.rocket.addUser(member)); if (user) { slackLogger.debug({ msg: 'Adding user to room', username: user.username, rid }); @@ -1359,13 +1359,13 @@ export default class SlackAdapter { let topic = ''; let topic_last_set = 0; let topic_creator = null; - if (channel && channel.topic && channel.topic.value) { + if (channel?.topic?.value) { topic = channel.topic.value; topic_last_set = channel.topic.last_set; topic_creator = channel.topic.creator; } - if (channel && channel.purpose && channel.purpose.value) { + if (channel?.purpose?.value) { if (topic_last_set) { if (topic_last_set < channel.purpose.last_set) { topic = channel.purpose.topic; @@ -1388,7 +1388,7 @@ export default class SlackAdapter { async copyPins(rid, channelMap) { const items = await this.slackAPI.getPins(channelMap.id); if (items && Array.isArray(items) && items.length) { - for await (const pin of items) { + for (const pin of items) { if (pin.message) { const user = await this.rocket.findUser(pin.message.user); // TODO: send this system message to the room as well (using the service) @@ -1433,8 +1433,7 @@ export default class SlackAdapter { channel: this.getSlackChannel(rid).id, oldest: 1, }); - while (results && results.has_more) { - // eslint-disable-next-line no-await-in-loop + while (results?.has_more) { results = await this.importFromHistory({ channel: this.getSlackChannel(rid).id, oldest: results.ts, diff --git a/apps/meteor/app/slashcommands-ban/client/client.ts b/apps/meteor/app/slashcommands-ban/client/client.ts new file mode 100644 index 0000000000000..1abdfad8b7086 --- /dev/null +++ b/apps/meteor/app/slashcommands-ban/client/client.ts @@ -0,0 +1,32 @@ +import { queryClient } from '../../../client/lib/queryClient'; +import { roomsQueryKeys } from '../../../client/lib/queryKeys'; +import { slashCommands } from '../../utils/client/slashCommand'; + +const invalidateMembers = (err: unknown, _result: unknown, params: { msg: { rid: string } }) => { + if (err) return; + + void queryClient.invalidateQueries({ queryKey: roomsQueryKeys.bannedUsers(params.msg.rid) }); + void queryClient.invalidateQueries({ queryKey: [...roomsQueryKeys.room(params.msg.rid), 'members'] }); +}; + +slashCommands.add({ + command: 'ban', + providesPreview: false, + options: { + description: 'Ban_user_from_room', + params: '@username', + permission: 'ban-user', + }, + result: invalidateMembers, +}); + +slashCommands.add({ + command: 'unban', + providesPreview: false, + options: { + description: 'Unban_user_from_room', + params: '@username', + permission: 'ban-user', + }, + result: invalidateMembers, +}); diff --git a/apps/meteor/app/slashcommands-ban/client/index.ts b/apps/meteor/app/slashcommands-ban/client/index.ts new file mode 100644 index 0000000000000..d99e4ed773526 --- /dev/null +++ b/apps/meteor/app/slashcommands-ban/client/index.ts @@ -0,0 +1 @@ +import './client'; diff --git a/apps/meteor/app/slashcommands-ban/server/ban.ts b/apps/meteor/app/slashcommands-ban/server/ban.ts new file mode 100644 index 0000000000000..3ca43a3b5f4db --- /dev/null +++ b/apps/meteor/app/slashcommands-ban/server/ban.ts @@ -0,0 +1,39 @@ +import { api } from '@rocket.chat/core-services'; +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { banUserFromRoomMethod } from '../../../server/lib/banUserFromRoom'; +import { i18n } from '../../../server/lib/i18n'; +import { sanitizeUsername } from '../../lib/server/methods/addUsersToRoom'; +import { settings } from '../../settings/server'; +import { slashCommands } from '../../utils/server/slashCommand'; + +slashCommands.add({ + command: 'ban', + callback: async ({ params, message, userId }: SlashCommandCallbackParams<'ban'>): Promise => { + const username = sanitizeUsername(params.trim()); + + if (!username) { + return; + } + + const user = await Users.findOneByUsernameIgnoringCase(username); + if (!user) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Username_doesnt_exist', { + postProcess: 'sprintf', + sprintf: [username], + lng: settings.get('Language') || 'en', + }), + }); + return; + } + + await banUserFromRoomMethod(userId, { rid: message.rid, username }); + }, + options: { + description: 'Ban_user_from_room', + params: '@username', + permission: 'ban-user', + }, +}); diff --git a/apps/meteor/app/slashcommands-ban/server/index.ts b/apps/meteor/app/slashcommands-ban/server/index.ts new file mode 100644 index 0000000000000..f04cdbe4b66a4 --- /dev/null +++ b/apps/meteor/app/slashcommands-ban/server/index.ts @@ -0,0 +1,2 @@ +import './ban'; +import './unban'; diff --git a/apps/meteor/app/slashcommands-ban/server/unban.ts b/apps/meteor/app/slashcommands-ban/server/unban.ts new file mode 100644 index 0000000000000..4cb52047b651f --- /dev/null +++ b/apps/meteor/app/slashcommands-ban/server/unban.ts @@ -0,0 +1,39 @@ +import { api } from '@rocket.chat/core-services'; +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { i18n } from '../../../server/lib/i18n'; +import { unbanUserFromRoom } from '../../../server/lib/unbanUserFromRoom'; +import { sanitizeUsername } from '../../lib/server/methods/addUsersToRoom'; +import { settings } from '../../settings/server'; +import { slashCommands } from '../../utils/server/slashCommand'; + +slashCommands.add({ + command: 'unban', + callback: async ({ params, message, userId }: SlashCommandCallbackParams<'unban'>): Promise => { + const username = sanitizeUsername(params.trim()); + + if (!username) { + return; + } + + const user = await Users.findOneByUsernameIgnoringCase(username); + if (!user) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Username_doesnt_exist', { + postProcess: 'sprintf', + sprintf: [username], + lng: settings.get('Language') || 'en', + }), + }); + return; + } + + await unbanUserFromRoom(userId, { rid: message.rid, username }); + }, + options: { + description: 'Unban_user_from_room', + params: '@username', + permission: 'ban-user', + }, +}); diff --git a/apps/meteor/app/slashcommands-invite/client/client.ts b/apps/meteor/app/slashcommands-invite/client/client.ts index 7c8af755d64d7..35e6f9779c82b 100644 --- a/apps/meteor/app/slashcommands-invite/client/client.ts +++ b/apps/meteor/app/slashcommands-invite/client/client.ts @@ -1,3 +1,5 @@ +import { queryClient } from '../../../client/lib/queryClient'; +import { roomsQueryKeys } from '../../../client/lib/queryKeys'; import { slashCommands } from '../../utils/client/slashCommand'; slashCommands.add({ @@ -8,4 +10,7 @@ slashCommands.add({ permission: 'add-user-to-joined-room', }, providesPreview: false, + result: (err, _result, params) => { + if (!err) void queryClient.invalidateQueries({ queryKey: [...roomsQueryKeys.room(params.msg.rid), 'members'] }); + }, }); diff --git a/apps/meteor/app/slashcommands-invite/server/server.ts b/apps/meteor/app/slashcommands-invite/server/server.ts index 26ea1256c3c34..9fd22b7b54ce5 100644 --- a/apps/meteor/app/slashcommands-invite/server/server.ts +++ b/apps/meteor/app/slashcommands-invite/server/server.ts @@ -1,5 +1,6 @@ import { api, FederationMatrix, isMeteorError } from '@rocket.chat/core-services'; import type { IUser, SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { isBannedSubscription } from '@rocket.chat/core-typings'; import { validateFederatedUsername } from '@rocket.chat/federation-matrix'; import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -74,10 +75,10 @@ slashCommands.add({ for await (const user of users) { const subscription = await Subscriptions.findOneByRoomIdAndUserId(message.rid, user._id, { - projection: { _id: 1 }, + projection: { _id: 1, status: 1 }, }); - if (subscription == null) { - usersFiltered.push(user as IUser); + if (subscription == null || isBannedSubscription(subscription)) { + usersFiltered.push(user); continue; } const usernameStr = user.username as string; diff --git a/apps/meteor/app/slashcommands-inviteall/client/client.ts b/apps/meteor/app/slashcommands-inviteall/client/client.ts index 5083cd4a83ab2..1dfa75b220150 100644 --- a/apps/meteor/app/slashcommands-inviteall/client/client.ts +++ b/apps/meteor/app/slashcommands-inviteall/client/client.ts @@ -1,5 +1,11 @@ +import { queryClient } from '../../../client/lib/queryClient'; +import { roomsQueryKeys } from '../../../client/lib/queryKeys'; import { slashCommands } from '../../utils/client/slashCommand'; +const invalidateMembers = (_err: unknown, _result: unknown, params: { msg: { rid: string } }) => { + if (!_err) void queryClient.invalidateQueries({ queryKey: [...roomsQueryKeys.room(params.msg.rid), 'members'] }); +}; + slashCommands.add({ command: 'invite-all-to', options: { @@ -7,6 +13,7 @@ slashCommands.add({ params: '#room', permission: ['add-user-to-joined-room', 'add-user-to-any-c-room', 'add-user-to-any-p-room'], }, + result: invalidateMembers, }); slashCommands.add({ command: 'invite-all-from', @@ -15,4 +22,5 @@ slashCommands.add({ params: '#room', permission: 'add-user-to-joined-room', }, + result: invalidateMembers, }); diff --git a/apps/meteor/app/slashcommands-kick/client/client.ts b/apps/meteor/app/slashcommands-kick/client/client.ts index 7fc167e17c887..d68a702ab087e 100644 --- a/apps/meteor/app/slashcommands-kick/client/client.ts +++ b/apps/meteor/app/slashcommands-kick/client/client.ts @@ -1,5 +1,7 @@ import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { queryClient } from '../../../client/lib/queryClient'; +import { roomsQueryKeys } from '../../../client/lib/queryKeys'; import { slashCommands } from '../../utils/client/slashCommand'; slashCommands.add({ @@ -16,4 +18,7 @@ slashCommands.add({ params: '@username', permission: 'remove-user', }, + result: (err, _result, params) => { + if (!err) void queryClient.invalidateQueries({ queryKey: [...roomsQueryKeys.room(params.msg.rid), 'members'] }); + }, }); diff --git a/apps/meteor/app/slashcommands-kick/server/server.ts b/apps/meteor/app/slashcommands-kick/server/server.ts index fdde07b897bfd..f29a65a764e7c 100644 --- a/apps/meteor/app/slashcommands-kick/server/server.ts +++ b/apps/meteor/app/slashcommands-kick/server/server.ts @@ -5,13 +5,14 @@ import { Users } from '@rocket.chat/models'; import { i18n } from '../../../server/lib/i18n'; import { removeUserFromRoomMethod } from '../../../server/methods/removeUserFromRoom'; +import { sanitizeUsername } from '../../lib/server/methods/addUsersToRoom'; import { settings } from '../../settings/server'; import { slashCommands } from '../../utils/server/slashCommand'; slashCommands.add({ command: 'kick', callback: async ({ params, message, userId }: SlashCommandCallbackParams<'kick'>): Promise => { - const username = params.trim().replace('@', ''); + const username = sanitizeUsername(params.trim()); if (username === '') { return; } diff --git a/apps/meteor/app/slashcommands-leave/server/leave.ts b/apps/meteor/app/slashcommands-leave/server/leave.ts index 4eafeea0d0cfc..3d86b998b49c8 100644 --- a/apps/meteor/app/slashcommands-leave/server/leave.ts +++ b/apps/meteor/app/slashcommands-leave/server/leave.ts @@ -1,6 +1,7 @@ import { api } from '@rocket.chat/core-services'; import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; import { i18n } from '../../../server/lib/i18n'; import { leaveRoomMethod } from '../../lib/server/methods/leaveRoom'; diff --git a/apps/meteor/app/threads/server/functions.ts b/apps/meteor/app/threads/server/functions.ts index b747a8d5d9555..a32d591ad238f 100644 --- a/apps/meteor/app/threads/server/functions.ts +++ b/apps/meteor/app/threads/server/functions.ts @@ -1,7 +1,8 @@ -import type { IMessage } from '@rocket.chat/core-typings'; +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 { callbacks } from '../../../server/lib/callbacks'; import { notifyOnSubscriptionChangedByRoomIdAndUserIds, notifyOnSubscriptionChangedByRoomIdAndUserId, @@ -82,8 +83,8 @@ export async function unfollow({ tmid, rid, uid }: { tmid: string; rid: string; await Messages.removeThreadFollowerByThreadId(tmid, uid); } -export const readThread = async ({ userId, rid, tmid }: { userId: string; rid: string; tmid: string }) => { - const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { tunread: 1 } }); +export const readThread = async ({ user, room, tmid }: { user: IUser; room: IRoom; tmid: string }) => { + const sub = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { tunread: 1 } }); if (!sub) { return; } @@ -91,10 +92,12 @@ export const readThread = async ({ userId, rid, tmid }: { userId: string; rid: s // if the thread being marked as read is the last one unread also clear the unread subscription flag const clearAlert = sub.tunread && sub.tunread?.length <= 1 && sub.tunread.includes(tmid); - const removeUnreadThreadResponse = await Subscriptions.removeUnreadThreadByRoomIdAndUserId(rid, userId, tmid, clearAlert); + const removeUnreadThreadResponse = await Subscriptions.removeUnreadThreadByRoomIdAndUserId(room._id, user._id, tmid, clearAlert); if (removeUnreadThreadResponse.modifiedCount) { - void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, userId); + void notifyOnSubscriptionChangedByRoomIdAndUserId(room._id, user._id); } - await NotificationQueue.clearQueueByUserId(userId); + await NotificationQueue.clearQueueByUserId(user._id); + + callbacks.runAsync('afterReadMessages', room, { uid: user._id, tmid }); }; diff --git a/apps/meteor/app/threads/server/methods/getThreadMessages.ts b/apps/meteor/app/threads/server/methods/getThreadMessages.ts index ab5e8a5e3f00d..d90df8d554f19 100644 --- a/apps/meteor/app/threads/server/methods/getThreadMessages.ts +++ b/apps/meteor/app/threads/server/methods/getThreadMessages.ts @@ -1,4 +1,4 @@ -import type { IMessage } from '@rocket.chat/core-typings'; +import type { IMessage, IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Messages, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -48,7 +48,7 @@ Meteor.methods({ } await callbacks.run('beforeReadMessages', thread.rid, user._id); - await readThread({ userId: user._id, rid: thread.rid, tmid }); + await readThread({ user: user as IUser, room, tmid }); const result = await Messages.findVisibleThreadByThreadId(tmid, { ...(skip && { skip }), @@ -56,8 +56,6 @@ Meteor.methods({ sort: { ts: -1 }, }).toArray(); - callbacks.runAsync('afterReadMessages', room, { uid: user._id, tmid }); - return [thread, ...result]; }, }); diff --git a/apps/meteor/app/ui-message/client/messageBox/AddLinkComposerActionModal.tsx b/apps/meteor/app/ui-message/client/messageBox/AddLinkComposerActionModal.tsx index ce75e12ee68df..58edd971d1e1c 100644 --- a/apps/meteor/app/ui-message/client/messageBox/AddLinkComposerActionModal.tsx +++ b/apps/meteor/app/ui-message/client/messageBox/AddLinkComposerActionModal.tsx @@ -1,9 +1,11 @@ -import { Field, FieldGroup, TextInput, FieldLabel, FieldRow, Box } from '@rocket.chat/fuselage'; +import { Field, FieldGroup, TextInput, FieldLabel, FieldRow, Box, FieldError } from '@rocket.chat/fuselage'; import { GenericModal } from '@rocket.chat/ui-client'; import { useEffect, useId } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { isValidLink } from '../../../../client/views/room/MessageList/lib/isValidLink'; + type AddLinkComposerActionModalProps = { selectedText?: string; onConfirm: (url: string, text: string) => void; @@ -15,8 +17,8 @@ const AddLinkComposerActionModal = ({ selectedText, onClose, onConfirm }: AddLin const textField = useId(); const urlField = useId(); - const { handleSubmit, setFocus, control } = useForm({ - mode: 'onBlur', + const { handleSubmit, setFocus, control, formState } = useForm({ + mode: 'onChange', defaultValues: { text: selectedText || '', url: '', @@ -40,6 +42,7 @@ const AddLinkComposerActionModal = ({ selectedText, onClose, onConfirm }: AddLin confirmText={t('Add')} onCancel={onClose} wrapperFunction={(props) => void submit(e)} {...props} />} + confirmDisabled={!formState.isValid} title={t('Add_link')} > @@ -52,8 +55,20 @@ const AddLinkComposerActionModal = ({ selectedText, onClose, onConfirm }: AddLin {t('URL')} - } /> + isValidLink(value) || t('Invalid_URL'), + required: { + value: true, + message: t(`URL_is_required`), + }, + }} + render={({ field }) => } + /> + {formState.errors.url?.message} diff --git a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts index a7d92ed3f02f3..876a4bea7f0f8 100644 --- a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts +++ b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts @@ -1,12 +1,14 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { Accounts } from 'meteor/accounts-base'; +import { Tracker } from 'meteor/tracker'; import type { RefObject } from 'react'; import { limitQuoteChain } from './limitQuoteChain'; import type { FormattingButton } from './messageBoxFormatting'; import { formattingButtons } from './messageBoxFormatting'; import type { ComposerAPI } from '../../../../client/lib/chats/ChatAPI'; +import { createUploadsAPI } from '../../../../client/lib/chats/uploads'; import { withDebouncing } from '../../../../lib/utils/highOrderFunctions'; export const createComposerAPI = ( @@ -14,6 +16,7 @@ export const createComposerAPI = ( storageID: string, quoteChainLimit: number, composerRef: RefObject, + { rid, tmid }: { rid: string; tmid?: string }, ): ComposerAPI => { const triggerEvent = (input: HTMLTextAreaElement, evt: string): void => { const event = new Event(evt, { bubbles: true }); @@ -230,11 +233,11 @@ export const createComposerAPI = ( focus(); const startPattern = pattern.slice(0, pattern.indexOf('{{text}}')); - const startPatternFound = [...startPattern].reverse().every((char, index) => input.value.slice(selectionStart - index - 1, 1) === char); + const startPatternFound = input.value.slice(selectionStart - startPattern.length, selectionStart) === startPattern; if (startPatternFound) { const endPattern = pattern.slice(pattern.indexOf('{{text}}') + '{{text}}'.length); - const endPatternFound = [...endPattern].every((char, index) => input.value.slice(selectionEnd + index, 1) === char); + const endPatternFound = input.value.slice(selectionEnd, selectionEnd + endPattern.length) === endPattern; if (endPatternFound) { insertText(selectedText); @@ -350,5 +353,6 @@ export const createComposerAPI = ( formatters, isMicrophoneDenied, setIsMicrophoneDenied, + uploads: createUploadsAPI({ rid, tmid }), }; }; diff --git a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts index 1d901f0c80bdb..14b1d291ef016 100644 --- a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts +++ b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts @@ -296,6 +296,14 @@ class RoomHistoryManagerClass extends Emitter { } public async getSurroundingMessages(message?: Pick & { ts?: Date }) { + return this.loadSurroundingMessages(message, true); + } + + public async getSurroundingChannelMessages(message?: Pick & { ts?: Date }) { + return this.loadSurroundingMessages(message, false); + } + + private async loadSurroundingMessages(message: (Pick & { ts?: Date }) | undefined, showThreadMessages: boolean) { if (!message?.rid) { return; } @@ -309,7 +317,7 @@ class RoomHistoryManagerClass extends Emitter { const room = this.getRoom(message.rid); const subscription = Subscriptions.state.find((record) => record.rid === message.rid); - const result = await callWithErrorHandling('loadSurroundingMessages', message, defaultLimit); + const result = await callWithErrorHandling('loadSurroundingMessages', message, defaultLimit, showThreadMessages); this.clear(message.rid); diff --git a/apps/meteor/app/ui/client/lib/ChatMessages.ts b/apps/meteor/app/ui/client/lib/ChatMessages.ts index 44cbf6bdbea43..a6febf3fdfef9 100644 --- a/apps/meteor/app/ui/client/lib/ChatMessages.ts +++ b/apps/meteor/app/ui/client/lib/ChatMessages.ts @@ -4,9 +4,10 @@ import type { IActionManager } from '@rocket.chat/ui-contexts'; import { CurrentEditingMessage } from './CurrentEditingMessage'; import { UserAction } from './UserAction'; -import type { ChatAPI, ComposerAPI, DataAPI, UploadsAPI } from '../../../../client/lib/chats/ChatAPI'; +import type { ChatAPI, ComposerAPI, DataAPI } from '../../../../client/lib/chats/ChatAPI'; import { createDataAPI } from '../../../../client/lib/chats/data'; import { processMessageEditing } from '../../../../client/lib/chats/flows/processMessageEditing'; +import { processMessageUploads } from '../../../../client/lib/chats/flows/processMessageUploads'; import { processSetReaction } from '../../../../client/lib/chats/flows/processSetReaction'; import { processSlashCommand } from '../../../../client/lib/chats/flows/processSlashCommand'; import { processTooLongMessage } from '../../../../client/lib/chats/flows/processTooLongMessage'; @@ -15,7 +16,6 @@ import { requestMessageDeletion } from '../../../../client/lib/chats/flows/reque import { sendMessage } from '../../../../client/lib/chats/flows/sendMessage'; import { uploadFiles } from '../../../../client/lib/chats/flows/uploadFiles'; import { ReadStateManager } from '../../../../client/lib/chats/readStateManager'; -import { createUploadsAPI } from '../../../../client/lib/chats/uploads'; import { setHighlightMessage } from '../../../../client/views/room/MessageList/providers/messageHighlightSubscription'; type DeepWritable = T extends (...args: any) => any @@ -42,8 +42,6 @@ export class ChatMessages implements ChatAPI { public readStateManager: ReadStateManager; - public uploads: UploadsAPI; - public ActionManager: any; public emojiPicker: { @@ -121,6 +119,7 @@ export class ChatMessages implements ChatAPI { await this.currentEditingMessage.stop(); }, 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; await this.currentEditingMessage.stop(); @@ -147,7 +146,6 @@ export class ChatMessages implements ChatAPI { this.tmid = tmid; this.uid = params.uid; this.data = createDataAPI({ rid, tmid }); - this.uploads = createUploadsAPI({ rid, tmid }); this.ActionManager = params.actionManager; this.currentEditingMessage = new CurrentEditingMessage(this); @@ -180,6 +178,7 @@ export class ChatMessages implements ChatAPI { processSlashCommand: processSlashCommand.bind(null, this), processTooLongMessage: processTooLongMessage.bind(null, this), processMessageEditing: processMessageEditing.bind(null, this), + processMessageUploads: processMessageUploads.bind(null, this), processSetReaction: processSetReaction.bind(null, this), requestMessageDeletion: requestMessageDeletion.bind(this, this), replyBroadcast: replyBroadcast.bind(null, this), diff --git a/apps/meteor/app/utils/lib/getURL.ts b/apps/meteor/app/utils/lib/getURL.ts index 3d757abb6c83c..ed510767311da 100644 --- a/apps/meteor/app/utils/lib/getURL.ts +++ b/apps/meteor/app/utils/lib/getURL.ts @@ -1,6 +1,6 @@ import { escapeRegExp } from '@rocket.chat/string-helpers'; +import { isAbsoluteURL } from '@rocket.chat/tools'; -import { isURL } from '../../../lib/utils/isURL'; import { ltrim, rtrim, trim } from '../../../lib/utils/stringUtils'; function getCloudUrl( @@ -41,7 +41,7 @@ export const _getURL = ( { cdn, full, cloud, cloud_route, cloud_params, _cdn_prefix, _root_url_path_prefix, _site_url }: Record, deeplinkUrl?: string, ): string => { - if (isURL(path)) { + if (isAbsoluteURL(path)) { return path; } diff --git a/apps/meteor/app/utils/rocketchat.info b/apps/meteor/app/utils/rocketchat.info index d40b5066c297b..547de34360c67 100644 --- a/apps/meteor/app/utils/rocketchat.info +++ b/apps/meteor/app/utils/rocketchat.info @@ -1,3 +1,3 @@ { - "version": "8.2.1" + "version": "8.3.0-rc.4" } diff --git a/apps/meteor/app/utils/server/getUserNotificationPreference.ts b/apps/meteor/app/utils/server/getUserNotificationPreference.ts index 2c70a7a11a18c..c93eb199294b3 100644 --- a/apps/meteor/app/utils/server/getUserNotificationPreference.ts +++ b/apps/meteor/app/utils/server/getUserNotificationPreference.ts @@ -40,7 +40,7 @@ export const getUserNotificationPreference = async (user: IUser | string, pref: }; } const serverValue = settings.get(`Accounts_Default_User_Preferences_${preferenceKey}`); - if (serverValue) { + if (typeof serverValue !== 'undefined') { return { value: serverValue, origin: 'server', diff --git a/apps/meteor/app/utils/server/lib/getValidRoomName.ts b/apps/meteor/app/utils/server/lib/getValidRoomName.ts index 27575c6c624c5..41ec9cc620674 100644 --- a/apps/meteor/app/utils/server/lib/getValidRoomName.ts +++ b/apps/meteor/app/utils/server/lib/getValidRoomName.ts @@ -51,7 +51,6 @@ export const getValidRoomName = async (displayName: string, rid = '', options: { if (settings.get('UI_Allow_room_names_with_special_chars')) { let tmpName = slugifiedName; let next = 0; - // eslint-disable-next-line no-await-in-loop while (await Rooms.findOneByNameAndNotId(tmpName, rid)) { tmpName = `${slugifiedName}-${++next}`; } diff --git a/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.spec.ts b/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.spec.ts new file mode 100644 index 0000000000000..0f8f8198e2d5f --- /dev/null +++ b/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.spec.ts @@ -0,0 +1,123 @@ +import { buildVersionUpdateMessage } from './buildVersionUpdateMessage'; +import { sendMessagesToAdmins } from '../../../../server/lib/sendMessagesToAdmins'; + +const originalTestMode = process.env.TEST_MODE; + +const mockInfoVersion = jest.fn(() => '7.5.0'); + +jest.mock('../../../utils/rocketchat.info', () => ({ + Info: { + get version() { + return mockInfoVersion(); + }, + }, +})); + +const mockSetBannersInBulk = jest.fn(); +const mockFindUsersInRolesWithQuery = jest.fn(); + +jest.mock('@rocket.chat/models', () => ({ + Settings: { + updateValueById: jest.fn().mockResolvedValue({ modifiedCount: 0 }), + }, + Users: { + findUsersInRolesWithQuery: () => mockFindUsersInRolesWithQuery(), + setBannersInBulk: (updates: unknown) => mockSetBannersInBulk(updates), + }, +})); + +const mockSettingsGet = jest.fn(); + +jest.mock('../../../settings/server', () => ({ + settings: { + get: (key: string) => mockSettingsGet(key), + }, +})); + +jest.mock('../../../../server/lib/i18n', () => ({ + i18n: { + t: jest.fn((key) => key), + }, +})); + +jest.mock('../../../../server/lib/sendMessagesToAdmins', () => ({ + sendMessagesToAdmins: jest.fn(), +})); + +jest.mock('../../../../server/settings/lib/auditedSettingUpdates', () => ({ + updateAuditedBySystem: jest.fn(() => () => Promise.resolve({ modifiedCount: 0 })), +})); + +jest.mock('../../../lib/server/lib/notifyListener', () => ({ + notifyOnSettingChangedById: jest.fn(), +})); + +describe('buildVersionUpdateMessage', () => { + // Delete the TEST_MODE environment variable so buildVersionUpdateMessage() + // doesn't return early (see line 40 in buildVersionUpdateMessage.ts) + beforeAll(() => { + delete process.env.TEST_MODE; + }); + + afterAll(() => { + process.env.TEST_MODE = originalTestMode; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockInfoVersion.mockReturnValue('7.5.0'); + mockSettingsGet.mockReturnValue('7.0.0'); + }); + + describe('cleanupOutdatedVersionUpdateBanners', () => { + it('should remove outdated version banners (<= current installed)', async () => { + const admin = { _id: 'admin1', banners: { 'versionUpdate-6_2_0': { id: 'versionUpdate-6_2_0' } } }; + mockFindUsersInRolesWithQuery.mockReturnValue([admin]); + + await buildVersionUpdateMessage([]); + + expect(mockSetBannersInBulk).toHaveBeenCalledWith([{ userId: 'admin1', banners: {} }]); + }); + + it('should keep version banners > current installed', async () => { + const admin = { _id: 'admin1', banners: { 'versionUpdate-8_0_0': { id: 'versionUpdate-8_0_0' } } }; + mockFindUsersInRolesWithQuery.mockReturnValue([admin]); + + await buildVersionUpdateMessage([]); + + expect(mockSetBannersInBulk).not.toHaveBeenCalled(); + }); + + it('should remove banners with invalid semver version IDs', async () => { + const admin = { _id: 'admin1', banners: { 'versionUpdate-invalid_version': { id: 'versionUpdate-invalid_version' } } }; + mockFindUsersInRolesWithQuery.mockReturnValue([admin]); + + await buildVersionUpdateMessage([]); + + expect(mockSetBannersInBulk).toHaveBeenCalledWith([{ userId: 'admin1', banners: {} }]); + }); + }); + + describe('version sorting', () => { + it('should process versions in descending order (highest first)', async () => { + mockFindUsersInRolesWithQuery.mockReturnValue([]); + + await buildVersionUpdateMessage([ + { version: '7.6.0', security: false, infoUrl: 'https://example.com/7.6.0' }, + { version: '8.0.0', security: false, infoUrl: 'https://example.com/8.0.0' }, + { version: '7.8.0', security: false, infoUrl: 'https://example.com/7.8.0' }, + ]); + + expect(sendMessagesToAdmins).toHaveBeenCalledTimes(1); + expect(sendMessagesToAdmins).toHaveBeenCalledWith( + expect.objectContaining({ + banners: expect.arrayContaining([ + expect.objectContaining({ + id: 'versionUpdate-8_0_0', + }), + ]), + }), + ); + }); + }); +}); diff --git a/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.ts b/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.ts index be53cee5959af..9811ae1ec94a6 100644 --- a/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.ts +++ b/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.ts @@ -1,4 +1,5 @@ -import { Settings } from '@rocket.chat/models'; +import type { IUser } from '@rocket.chat/core-typings'; +import { Settings, Users } from '@rocket.chat/models'; import semver from 'semver'; import { i18n } from '../../../../server/lib/i18n'; @@ -8,6 +9,39 @@ import { notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListen import { settings } from '../../../settings/server'; import { Info } from '../../../utils/rocketchat.info'; +const cleanupOutdatedVersionUpdateBanners = async (): Promise => { + const admins = Users.findUsersInRolesWithQuery('admin', { banners: { $exists: true } }, { projection: { _id: 1, banners: 1 } }); + + const updates: { userId: IUser['_id']; banners: NonNullable }[] = []; + + for await (const admin of admins) { + if (!admin.banners) { + continue; + } + + const filteredBanners = Object.fromEntries( + Object.entries(admin.banners).filter(([bannerId]) => { + if (!bannerId.startsWith('versionUpdate-')) { + return true; + } + const version = bannerId.replace('versionUpdate-', '').replace(/_/g, '.'); + if (!semver.valid(version) || semver.lte(version, Info.version)) { + return false; + } + return true; + }), + ); + + if (Object.keys(filteredBanners).length !== Object.keys(admin.banners).length) { + updates.push({ userId: admin._id, banners: filteredBanners }); + } + } + + if (updates.length > 0) { + await Users.setBannersInBulk(updates); + } +}; + export const buildVersionUpdateMessage = async ( versions: { version: string; @@ -25,8 +59,11 @@ export const buildVersionUpdateMessage = async ( return; } - for await (const version of versions) { - // Ignore prerelease versions + const sortedVersions = [...versions].sort((a, b) => semver.rcompare(a.version, b.version)); + + await cleanupOutdatedVersionUpdateBanners(); + + for await (const version of sortedVersions) { if (semver.prerelease(version.version)) { continue; } diff --git a/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx b/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx index 1bbf634a0f8d5..e40afaf35f438 100644 --- a/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx +++ b/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx @@ -1,6 +1,7 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; import { GenericModal } from '@rocket.chat/ui-client'; +import { Tracker } from 'meteor/tracker'; import type { ReactElement } from 'react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/apps/meteor/client/cachedStores/RoomsCachedStore.ts b/apps/meteor/client/cachedStores/RoomsCachedStore.ts index f5ee93b9701b7..85e78c9041b4f 100644 --- a/apps/meteor/client/cachedStores/RoomsCachedStore.ts +++ b/apps/meteor/client/cachedStores/RoomsCachedStore.ts @@ -1,5 +1,5 @@ import type { IOmnichannelRoom, IRoom, IRoomWithRetentionPolicy } from '@rocket.chat/core-typings'; -import { DEFAULT_SLA_CONFIG, isRoomNativeFederated, LivechatPriorityWeight } from '@rocket.chat/core-typings'; +import { DEFAULT_SLA_CONFIG, isABACManagedRoom, isRoomNativeFederated, LivechatPriorityWeight } from '@rocket.chat/core-typings'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import { PrivateCachedStore } from '../lib/cachedStores/CachedStore'; @@ -53,7 +53,9 @@ class RoomsCachedStore extends PrivateCachedStore { source: (room as IOmnichannelRoom | undefined)?.source, queuedAt: (room as IOmnichannelRoom | undefined)?.queuedAt, federated: room.federated, - + ...(isABACManagedRoom(room) && { + abacAttributes: room.abacAttributes, + }), ...(isRoomNativeFederated(room) && { federation: room.federation, }), diff --git a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.spec.tsx b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.spec.tsx index ccee1d2e3ed55..937218de30bfb 100644 --- a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.spec.tsx +++ b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.spec.tsx @@ -8,10 +8,6 @@ import CreateDiscussion from './CreateDiscussion'; import * as stories from './CreateDiscussion.stories'; import { createFakeRoom } from '../../../tests/mocks/data'; -jest.mock('../../lib/utils/goToRoomById', () => ({ - goToRoomById: jest.fn(), -})); - jest.mock('../../lib/rooms/roomCoordinator', () => ({ roomCoordinator: { getRoomDirectives: () => ({ diff --git a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx index 2ee2a44c1f3a6..77ee3e750e762 100644 --- a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx +++ b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx @@ -19,8 +19,8 @@ import { useMutation } from '@tanstack/react-query'; import { useId, useState } from 'react'; import { useForm, Controller } from 'react-hook-form'; -import { goToRoomById } from '../../lib/utils/goToRoomById'; import { useEncryptedRoomDescription } from '../../navbar/NavBarPagesGroup/actions/useEncryptedRoomDescription'; +import { useGoToRoom } from '../../views/room/hooks/useGoToRoom'; import RoomAutoComplete from '../RoomAutoComplete'; import UserAutoCompleteMultiple from '../UserAutoCompleteMultiple'; import DefaultParentRoomField from './DefaultParentRoomField'; @@ -83,10 +83,12 @@ const CreateDiscussion = ({ const createDiscussion = useEndpoint('POST', '/v1/rooms.createDiscussion'); + const goToRoom = useGoToRoom(); + const createDiscussionMutation = useMutation({ mutationFn: createDiscussion, onSuccess: ({ discussion }) => { - goToRoomById(discussion._id); + goToRoom(discussion._id); onClose(); }, }); diff --git a/apps/meteor/client/components/FingerprintChangeModal.tsx b/apps/meteor/client/components/FingerprintChangeModal.tsx index 353193e50a337..066d77334cf44 100644 --- a/apps/meteor/client/components/FingerprintChangeModal.tsx +++ b/apps/meteor/client/components/FingerprintChangeModal.tsx @@ -1,8 +1,7 @@ import { Box } from '@rocket.chat/fuselage'; -import { GenericModal } from '@rocket.chat/ui-client'; -import DOMPurify from 'dompurify'; +import { ExternalLink, GenericModal } from '@rocket.chat/ui-client'; import type { ReactElement } from 'react'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import { links } from '../lib/links'; @@ -24,26 +23,15 @@ const FingerprintChangeModal = ({ onConfirm, onCancel, onClose }: FingerprintCha confirmText={t('Configuration_update')} cancelText={t('New_workspace')} > - - + + + + + }} + /> + ); }; diff --git a/apps/meteor/client/components/FingerprintChangeModalConfirmation.tsx b/apps/meteor/client/components/FingerprintChangeModalConfirmation.tsx index 76be8755acff0..ea13e86196857 100644 --- a/apps/meteor/client/components/FingerprintChangeModalConfirmation.tsx +++ b/apps/meteor/client/components/FingerprintChangeModalConfirmation.tsx @@ -1,8 +1,7 @@ import { Box } from '@rocket.chat/fuselage'; -import { GenericModal } from '@rocket.chat/ui-client'; -import DOMPurify from 'dompurify'; +import { ExternalLink, GenericModal } from '@rocket.chat/ui-client'; import type { ReactElement } from 'react'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import { links } from '../lib/links'; @@ -30,28 +29,19 @@ const FingerprintChangeModalConfirmation = ({ confirmText={newWorkspace ? t('Confirm_new_workspace') : t('Confirm_configuration_update')} onClose={onClose} > - - + + {newWorkspace ? ( + + ) : ( + + )} + + + }} + /> + ); }; diff --git a/apps/meteor/client/components/GazzodownText.tsx b/apps/meteor/client/components/GazzodownText.tsx index ce8a6fa60a383..f491b90b41487 100644 --- a/apps/meteor/client/components/GazzodownText.tsx +++ b/apps/meteor/client/components/GazzodownText.tsx @@ -7,6 +7,7 @@ import { useLayout, useRouter, useUserPreference, useUserId, useUserCard } from import type { UIEvent } from 'react'; import { useCallback, memo, useMemo } from 'react'; +import { normalizeUsername } from '../../lib/utils/normalizeUsername'; import { detectEmoji } from '../lib/utils/detectEmoji'; import { fireGlobalEvent } from '../lib/utils/fireGlobalEvent'; import { useMessageListHighlights, useMessageListShowRealName } from './message/list/MessageListContext'; @@ -63,11 +64,9 @@ const GazzodownText = ({ mentions, channels, searchText, children }: GazzodownTe return undefined; } - const normalizedMention = mention.startsWith('@') ? mention.substring(1) : mention; const filterUser = ({ username, type }: UserMention) => { if (!username || type === 'team') return false; - const normalizedUsername = username.startsWith('@') ? username.substring(1) : username; - return normalizedUsername === normalizedMention; + return normalizeUsername(username) === normalizeUsername(mention); }; const filterTeam = ({ name, type }: UserMention) => type === 'team' && name === mention; diff --git a/apps/meteor/client/components/message/list/MessageListSkeleton.tsx b/apps/meteor/client/components/ListSkeleton.tsx similarity index 64% rename from apps/meteor/client/components/message/list/MessageListSkeleton.tsx rename to apps/meteor/client/components/ListSkeleton.tsx index 5a94fd1a25677..034f167bca0e8 100644 --- a/apps/meteor/client/components/message/list/MessageListSkeleton.tsx +++ b/apps/meteor/client/components/ListSkeleton.tsx @@ -4,14 +4,14 @@ import { memo, useMemo } from 'react'; const availablePercentualWidths = [47, 68, 75, 82]; -type MessageListSkeletonProps = { - messageCount?: number; +type ListSkeletonProps = { + listCount?: number; }; -const MessageListSkeleton = ({ messageCount = 2 }: MessageListSkeletonProps): ReactElement => { +const ListSkeleton = ({ listCount = 2 }: ListSkeletonProps): ReactElement => { const widths = useMemo( - () => Array.from({ length: messageCount }, (_, index) => `${availablePercentualWidths[index % availablePercentualWidths.length]}%`), - [messageCount], + () => Array.from({ length: listCount }, (_, index) => `${availablePercentualWidths[index % availablePercentualWidths.length]}%`), + [listCount], ); return ( @@ -31,4 +31,4 @@ const MessageListSkeleton = ({ messageCount = 2 }: MessageListSkeletonProps): Re ); }; -export default memo(MessageListSkeleton); +export default memo(ListSkeleton); diff --git a/apps/meteor/client/components/MarkdownText.spec.tsx b/apps/meteor/client/components/MarkdownText.spec.tsx index a7b9da94b84e3..a59c53cc14012 100644 --- a/apps/meteor/client/components/MarkdownText.spec.tsx +++ b/apps/meteor/client/components/MarkdownText.spec.tsx @@ -1,5 +1,6 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; import { render, screen } from '@testing-library/react'; +import dompurify from 'dompurify'; import MarkdownText, { supportedURISchemes } from './MarkdownText'; @@ -432,3 +433,69 @@ describe('code handling', () => { expect(screen.getByRole('code').outerHTML).toEqual(expected); }); }); + +describe('line breaks handling', () => { + it('should convert newlines to
in document variant', () => { + const content = 'First Line\nSecond Line\nThird Line'; + const { container } = render(, { + wrapper: mockAppRoot().build(), + }); + + const html = container.innerHTML; + expect(html).toContain('First Line
Second Line
Third Line'); + }); + + it('should convert newlines to
in inline variant', () => { + const content = 'First Line\nSecond Line\nThird Line'; + const { container } = render(, { + wrapper: mockAppRoot().build(), + }); + + const html = container.innerHTML; + expect(html).not.toContain('
'); + }); + + it('should not convert newlines to
in inlineWithoutBreaks variant', () => { + const content = 'First Line\nSecond Line\nThird Line'; + const { container } = render(, { + wrapper: mockAppRoot().build(), + }); + + const html = container.innerHTML; + expect(html).not.toContain('
'); + }); +}); + +describe('DOMPurify hook registration', () => { + it('should register hook only once at module level', () => { + // Import the module to trigger hook registration + + const addHookSpy = jest.spyOn(dompurify, 'addHook'); + + // Clear any previous calls from module initialization + addHookSpy.mockClear(); + + const { rerender, unmount } = render(, { + wrapper: mockAppRoot().build(), + }); + + // Hook should NOT be registered during component render (it's registered at module level) + expect(addHookSpy).toHaveBeenCalledTimes(0); + + // Re-rendering with different props should not register hook again + rerender(); + expect(addHookSpy).toHaveBeenCalledTimes(0); + + // Rendering another instance should not register hook again + render(, { + wrapper: mockAppRoot().build(), + }); + expect(addHookSpy).toHaveBeenCalledTimes(0); + + // Unmounting should not affect the module-level hook + unmount(); + expect(addHookSpy).toHaveBeenCalledTimes(0); + + addHookSpy.mockRestore(); + }); +}); diff --git a/apps/meteor/client/components/MarkdownText.tsx b/apps/meteor/client/components/MarkdownText.tsx index 8503d5ee7ed5f..faefcf806d54b 100644 --- a/apps/meteor/client/components/MarkdownText.tsx +++ b/apps/meteor/client/components/MarkdownText.tsx @@ -78,6 +78,7 @@ const defaultOptions = { const options = { ...defaultOptions, + breaks: true, renderer: documentRenderer, }; @@ -101,6 +102,49 @@ type MarkdownTextProps = Partial; export const supportedURISchemes = ['http', 'https', 'notes', 'ftp', 'ftps', 'tel', 'mailto', 'sms', 'cid']; +const isElement = (node: Node): node is Element => node.nodeType === Node.ELEMENT_NODE; +const isLinkElement = (node: Node): node is HTMLAnchorElement => isElement(node) && node.tagName.toLowerCase() === 'a'; + +// Generate a unique token at runtime to prevent enumeration attacks +// This token marks internal links that need translation +const INTERNAL_LINK_TOKEN = `__INTERNAL_LINK_TITLE_${Math.random().toString(36).substring(2, 15)}__`; + +// Register the DOMPurify hook once at module level to prevent memory leaks +// This hook will be shared by all MarkdownText component instances +dompurify.addHook('afterSanitizeAttributes', (node) => { + if (!isLinkElement(node)) { + return; + } + + const href = node.getAttribute('href') || ''; + const isExternalLink = isExternal(href); + const isMailto = href.startsWith('mailto:'); + + // Set appropriate attributes based on link type + if (isExternalLink || isMailto) { + node.setAttribute('rel', 'nofollow noopener noreferrer'); + // Enforcing external links to open in new tabs is critical to assure users never navigate away from the chat + // This attribute must be preserved to guarantee users maintain their chat context + node.setAttribute('target', '_blank'); + } + + // Set appropriate title based on link type + if (isMailto) { + // For mailto links, use the email address as the title for better user experience + // Example: for href "mailto:user@example.com" the title would be "mailto:user@example.com" + node.setAttribute('title', href); + } else if (isExternalLink) { + // For external links, set an empty title to prevent tooltips + // This reduces visual clutter and lets users see the URL in the browser's status bar instead + node.setAttribute('title', ''); + } else { + // For internal links, use a token that will be replaced with translated text in the component + // This allows us to use the contextualized translation function + const relativePath = href.replace(getBaseURI(), ''); + node.setAttribute('title', `${INTERNAL_LINK_TOKEN}${relativePath}`); + } +}); + const MarkdownText = ({ content, variant = 'document', @@ -143,41 +187,16 @@ const MarkdownText = ({ } })(); - // Add a hook to make all external links open a new window - dompurify.addHook('afterSanitizeAttributes', (node) => { - if (!isLinkElement(node)) { - return; - } + const sanitizedHtml = preserveHtml + ? html + : html && sanitizer(html, { ADD_ATTR: ['target'], ALLOWED_URI_REGEXP: getRegexp(supportedURISchemes) }); - const href = node.getAttribute('href') || ''; - const isExternalLink = isExternal(href); - const isMailto = href.startsWith('mailto:'); + // Replace internal link tokens with contextualized translations + if (sanitizedHtml && typeof sanitizedHtml === 'string') { + return sanitizedHtml.replace(new RegExp(`${INTERNAL_LINK_TOKEN}([^"]*)`, 'g'), (_, href) => t('Go_to_href', { href })); + } - // Set appropriate attributes based on link type - if (isExternalLink || isMailto) { - node.setAttribute('rel', 'nofollow noopener noreferrer'); - // Enforcing external links to open in new tabs is critical to assure users never navigate away from the chat - // This attribute must be preserved to guarantee users maintain their chat context - node.setAttribute('target', '_blank'); - } - - // Set appropriate title based on link type - if (isMailto) { - // For mailto links, use the email address as the title for better user experience - // Example: for href "mailto:user@example.com" the title would be "mailto:user@example.com" - node.setAttribute('title', href); - } else if (isExternalLink) { - // For external links, set an empty title to prevent tooltips - // This reduces visual clutter and lets users see the URL in the browser's status bar instead - node.setAttribute('title', ''); - } else { - // For internal links, add a translated title with the relative path - // Example: for href "https://my-server.rocket.chat/channel/general" the title would be "Go to #general" - node.setAttribute('title', `${t('Go_to_href', { href: href.replace(getBaseURI(), '') })}`); - } - }); - - return preserveHtml ? html : html && sanitizer(html, { ADD_ATTR: ['target'], ALLOWED_URI_REGEXP: getRegexp(supportedURISchemes) }); + return sanitizedHtml; }, [preserveHtml, sanitizer, content, variant, markedOptions, parseEmoji, t]); return __html ? ( @@ -190,7 +209,4 @@ const MarkdownText = ({ ) : null; }; -const isElement = (node: Node): node is Element => node.nodeType === Node.ELEMENT_NODE; -const isLinkElement = (node: Node): node is HTMLAnchorElement => isElement(node) && node.tagName.toLowerCase() === 'a'; - export default MarkdownText; diff --git a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelAppSourceRoomIcon.tsx b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelAppSourceRoomIcon.tsx index f57508cb070a1..268143a7dcd1d 100644 --- a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelAppSourceRoomIcon.tsx +++ b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelAppSourceRoomIcon.tsx @@ -1,4 +1,4 @@ -import { type IOmnichannelSourceFromApp } from '@rocket.chat/core-typings'; +import type { IOmnichannelSourceFromApp } from '@rocket.chat/core-typings'; import { Icon, Box } from '@rocket.chat/fuselage'; import type { ComponentProps } from 'react'; diff --git a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultipleOption.tsx b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultipleOption.tsx index 9a82c5eba53d5..b1508a8b33cb0 100644 --- a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultipleOption.tsx +++ b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultipleOption.tsx @@ -4,6 +4,7 @@ import { useSetting } from '@rocket.chat/ui-contexts'; import { useMemo } from 'react'; import type { UserLabel } from './UserAutoCompleteMultipleOptions'; +import { normalizeUsername } from '../../../lib/utils/normalizeUsername'; type UserAutoCompleteMultipleOptionProps = { label: UserLabel; @@ -14,8 +15,9 @@ type UserAutoCompleteMultipleOptionProps = { }; const UserAutoCompleteMultipleOption = ({ label, ...props }: UserAutoCompleteMultipleOptionProps) => { - const { name, username, _federated } = label; + const { name, _federated } = label; const useRealName = useSetting('UI_Use_Real_Name'); + const username = normalizeUsername(label.username); const optionLabel = useMemo(() => { if (!useRealName || !name) { diff --git a/apps/meteor/client/components/UserAutoCompleteMultiple/__snapshots__/UserAvatarChip.spec.tsx.snap b/apps/meteor/client/components/UserAutoCompleteMultiple/__snapshots__/UserAvatarChip.spec.tsx.snap index a55dbb1d84acd..6b7320236d10d 100644 --- a/apps/meteor/client/components/UserAutoCompleteMultiple/__snapshots__/UserAvatarChip.spec.tsx.snap +++ b/apps/meteor/client/components/UserAutoCompleteMultiple/__snapshots__/UserAvatarChip.spec.tsx.snap @@ -14,7 +14,7 @@ exports[`UserAvatarChip renders Default without crashing 1`] = ` class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x20" > void; }; -const IgnoredContent = ({ onShowMessageIgnored }: IgnoredContentProps): ReactElement => { +const IgnoredContent = ({ messageId, onShowMessageIgnored }: IgnoredContentProps): ReactElement => { const { t } = useTranslation(); const showMessageIgnored = (event: SyntheticEvent): void => { @@ -17,7 +18,7 @@ const IgnoredContent = ({ onShowMessageIgnored }: IgnoredContentProps): ReactEle }; return ( - + ; -const MessageContentBody = ({ mentions, channels, md, searchText, ...props }: MessageContentBodyProps) => ( - - }> - - - - - -); +const MessageContentBody = ({ mentions, channels, md, searchText, ...props }: MessageContentBodyProps) => { + const { t } = useTranslation(); + + return ( + + }> + + + + + + ); +}; export default MessageContentBody; diff --git a/apps/meteor/client/components/message/MessageHeader.tsx b/apps/meteor/client/components/message/MessageHeader.tsx index 427f61803b584..53f740e2d817e 100644 --- a/apps/meteor/client/components/message/MessageHeader.tsx +++ b/apps/meteor/client/components/message/MessageHeader.tsx @@ -24,6 +24,7 @@ import { useMessageListFormatDateAndTime, useMessageListFormatTime, } from './list/MessageListContext'; +import { normalizeUsername } from '../../../lib/utils/normalizeUsername'; type MessageHeaderProps = { message: IMessage; @@ -42,6 +43,7 @@ const MessageHeader = ({ message }: MessageHeaderProps): ReactElement => { const usernameAndRealNameAreSame = !user.name || user.username === user.name; const showUsername = useMessageListShowUsername() && showRealName && !usernameAndRealNameAreSame; const displayName = useUserDisplayName(user); + const normalizedUsername = normalizeUsername(user.username); const showRoles = useMessageListShowRoles(); const roles = useMessageRoles(message.u._id, message.rid, showRoles); @@ -57,18 +59,15 @@ const MessageHeader = ({ message }: MessageHeaderProps): ReactElement => { {...triggerProps} > {message.alias || displayName} {showUsername && ( <> {' '} - - @{user.username} - + @{normalizedUsername} )} 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 6d4955fb73424..d2dbd4f888257 100644 --- a/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx @@ -78,12 +78,7 @@ const GenericFileAttachment = ({ } > - + {title} {size && ( diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentText.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentText.tsx index 75153d1d57eb5..05a694df24997 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentText.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentText.tsx @@ -1,10 +1,12 @@ import { Box } from '@rocket.chat/fuselage'; import type { ComponentPropsWithoutRef } from 'react'; +import { useTranslation } from 'react-i18next'; type AttachmentTextProps = ComponentPropsWithoutRef; -const AttachmentText = (props: AttachmentTextProps) => ( - -); +const AttachmentText = (props: AttachmentTextProps) => { + const { t } = useTranslation(); + return ; +}; export default AttachmentText; diff --git a/apps/meteor/client/components/message/content/reactions/Reaction.tsx b/apps/meteor/client/components/message/content/reactions/Reaction.tsx index ce61ee4d77de7..fd0302bef8bf6 100644 --- a/apps/meteor/client/components/message/content/reactions/Reaction.tsx +++ b/apps/meteor/client/components/message/content/reactions/Reaction.tsx @@ -6,10 +6,11 @@ import { useRef, useContext } from 'react'; import { useTranslation } from 'react-i18next'; import ReactionTooltip from './ReactionTooltip'; +import { normalizeUsername } from '../../../../../lib/utils/normalizeUsername'; import { getEmojiClassNameAndDataTitle } from '../../../../lib/utils/renderEmoji'; import { MessageListContext } from '../../list/MessageListContext'; -const normalizeUsernames = (names: string[]) => names.map((name) => (name.startsWith('@') ? name.slice(1) : name)); +const normalizeUsernames = (names: string[]) => names.map(normalizeUsername); // TODO: replace it with proper usage of i18next plurals type ReactionProps = { diff --git a/apps/meteor/client/components/message/hooks/useNormalizedMessage.ts b/apps/meteor/client/components/message/hooks/useNormalizedMessage.ts index 5082dec99d807..a2a8047560ee4 100644 --- a/apps/meteor/client/components/message/hooks/useNormalizedMessage.ts +++ b/apps/meteor/client/components/message/hooks/useNormalizedMessage.ts @@ -40,16 +40,16 @@ const normalizeAttachments = (attachments: MessageAttachment[], name?: string, t if (isFileAttachment(attachment)) { if (attachment.title_link && !attachment.title_link.startsWith('/file-decrypt/')) { - attachment.title_link = `/file-decrypt${attachment.title_link}?key=${key}`; + attachment.title_link = `/file-decrypt${attachment.title_link}?key=${encodeURIComponent(key)}`; } if (isFileImageAttachment(attachment) && !attachment.image_url.startsWith('/file-decrypt/')) { - attachment.image_url = `/file-decrypt${attachment.image_url}?key=${key}`; + attachment.image_url = `/file-decrypt${attachment.image_url}?key=${encodeURIComponent(key)}`; } if (isFileAudioAttachment(attachment) && !attachment.audio_url.startsWith('/file-decrypt/')) { - attachment.audio_url = `/file-decrypt${attachment.audio_url}?key=${key}`; + attachment.audio_url = `/file-decrypt${attachment.audio_url}?key=${encodeURIComponent(key)}`; } if (isFileVideoAttachment(attachment) && !attachment.video_url.startsWith('/file-decrypt/')) { - attachment.video_url = `/file-decrypt${attachment.video_url}?key=${key}`; + attachment.video_url = `/file-decrypt${attachment.video_url}?key=${encodeURIComponent(key)}`; } } diff --git a/apps/meteor/client/components/message/toolbar/MessageToolbarItem.tsx b/apps/meteor/client/components/message/toolbar/MessageToolbarItem.tsx index 633f3fc72d679..6cb9f2d351d0b 100644 --- a/apps/meteor/client/components/message/toolbar/MessageToolbarItem.tsx +++ b/apps/meteor/client/components/message/toolbar/MessageToolbarItem.tsx @@ -8,27 +8,17 @@ type MessageToolbarItemProps = { icon: IconName; title: string; disabled?: boolean; - qa: string; onClick: MouseEventHandler; }; -const MessageToolbarItem = ({ id, icon, title, disabled, qa, onClick }: MessageToolbarItemProps) => { +const MessageToolbarItem = ({ id, icon, title, disabled, onClick }: MessageToolbarItemProps) => { const hiddenActions = useLayoutHiddenActions().messageToolbox; if (hiddenActions.includes(id)) { return null; } - return ( - - ); + return ; }; export default MessageToolbarItem; diff --git a/apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.tsx b/apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.tsx index 64e11d578272c..6c5720990b128 100644 --- a/apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.tsx +++ b/apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.tsx @@ -35,7 +35,6 @@ const ForwardMessageAction = ({ message, room }: ForwardMessageActionProps) => { id='forward-message' icon='arrow-forward' title={getTitle} - qa='Forward_message' disabled={encrypted || isABACEnabled} onClick={async () => { const permalink = await getPermaLink(message._id); diff --git a/apps/meteor/client/components/message/toolbar/items/actions/JumpToMessageAction.tsx b/apps/meteor/client/components/message/toolbar/items/actions/JumpToMessageAction.tsx index 1f1d757287f3d..802b505a55045 100644 --- a/apps/meteor/client/components/message/toolbar/items/actions/JumpToMessageAction.tsx +++ b/apps/meteor/client/components/message/toolbar/items/actions/JumpToMessageAction.tsx @@ -17,7 +17,6 @@ const JumpToMessageAction = ({ id, message }: JumpToMessageActionProps) => { id={id} icon='jump' title={t('Jump_to_message')} - qa='Jump_to_message' onClick={() => { setMessageJumpQueryStringParameter(message._id); }} diff --git a/apps/meteor/client/components/message/toolbar/items/actions/QuoteMessageAction.tsx b/apps/meteor/client/components/message/toolbar/items/actions/QuoteMessageAction.tsx index d73f193cbb1d5..088fb1f61028b 100644 --- a/apps/meteor/client/components/message/toolbar/items/actions/QuoteMessageAction.tsx +++ b/apps/meteor/client/components/message/toolbar/items/actions/QuoteMessageAction.tsx @@ -40,7 +40,6 @@ const QuoteMessageAction = ({ message, subscription }: QuoteMessageActionProps) id='quote-message' icon='quote' title={t('Quote')} - qa='Quote' onClick={() => { if (message && autoTranslateOptions?.autoTranslateEnabled && autoTranslateOptions.showAutoTranslate(message)) { message.msg = diff --git a/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx b/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx index 8772672c707ec..6ce3dc1be51d6 100644 --- a/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx +++ b/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx @@ -72,7 +72,6 @@ const ReactionMessageAction = ({ message, room, subscription }: ReactionMessageA id='reaction-message' icon='add-reaction' title={t('Add_Reaction')} - qa='Add_Reaction' onClick={(event) => { event.stopPropagation(); chat?.emojiPicker.open(event.currentTarget, (emoji) => { diff --git a/apps/meteor/client/components/message/toolbar/items/actions/ReplyInThreadMessageAction.tsx b/apps/meteor/client/components/message/toolbar/items/actions/ReplyInThreadMessageAction.tsx index d46f01935d582..d962cceffbe99 100644 --- a/apps/meteor/client/components/message/toolbar/items/actions/ReplyInThreadMessageAction.tsx +++ b/apps/meteor/client/components/message/toolbar/items/actions/ReplyInThreadMessageAction.tsx @@ -37,7 +37,6 @@ const ReplyInThreadMessageAction = ({ message, room, subscription }: ReplyInThre id='reply-in-thread' icon='thread' title={t('Reply_in_thread')} - qa='Reply_in_thread' onClick={(event) => { event.stopPropagation(); const routeName = router.getRouteName(); diff --git a/apps/meteor/client/components/message/toolbar/useReadReceiptsDetailsAction.tsx b/apps/meteor/client/components/message/toolbar/useReadReceiptsDetailsAction.tsx index 1be47e82775b6..faaf1f3a9efed 100644 --- a/apps/meteor/client/components/message/toolbar/useReadReceiptsDetailsAction.tsx +++ b/apps/meteor/client/components/message/toolbar/useReadReceiptsDetailsAction.tsx @@ -18,7 +18,7 @@ export const useReadReceiptsDetailsAction = (message: IMessage): MessageActionCo id: 'receipt-detail', icon: 'check-double', label: 'Read_Receipts', - context: ['starred', 'message', 'message-mobile', 'threads', 'videoconf', 'videoconf-threads'], + context: ['starred', 'message', 'message-mobile', 'threads', 'videoconf', 'videoconf-threads', 'federated'], type: 'duplication', action() { setModal( diff --git a/apps/meteor/client/components/message/variants/RoomMessage.tsx b/apps/meteor/client/components/message/variants/RoomMessage.tsx index 43d885f6839f6..14895b05d0929 100644 --- a/apps/meteor/client/components/message/variants/RoomMessage.tsx +++ b/apps/meteor/client/components/message/variants/RoomMessage.tsx @@ -1,4 +1,4 @@ -import { type IMessage } from '@rocket.chat/core-typings'; +import type { IMessage } from '@rocket.chat/core-typings'; import { Message, MessageLeftContainer, MessageContainer, CheckBox } from '@rocket.chat/fuselage'; import { useToggle } from '@rocket.chat/fuselage-hooks'; import { MessageAvatar } from '@rocket.chat/ui-avatar'; @@ -21,6 +21,7 @@ import MessageHeader from '../MessageHeader'; import MessageToolbarHolder from '../MessageToolbarHolder'; import StatusIndicators from '../StatusIndicators'; import RoomMessageContent from './room/RoomMessageContent'; +import { useMessageListReadReceipts } from '../list/MessageListContext'; type RoomMessageProps = { message: IMessage & { ignored?: boolean }; @@ -34,6 +35,30 @@ type RoomMessageProps = { searchText?: string; } & ComponentProps; +const getAriaLabelledBy = ({ + readReceiptEnabled, + messageId, + sequential, +}: { + readReceiptEnabled: boolean; + messageId: string; + sequential: boolean; +}) => { + const labels: string[] = []; + + if (!sequential) { + labels.push(`${messageId}-displayName`, `${messageId}-time`); + } + + labels.push(`${messageId}-content`); + + if (readReceiptEnabled) { + labels.push(`${messageId}-read-status`); + } + + return labels.join(' '); +}; + const RoomMessage = ({ message, showUserAvatar, @@ -58,6 +83,8 @@ const RoomMessage = ({ const toggleSelected = useToggleSelect(message._id); const selected = useIsSelectedMessage(message._id); + const { enabled: readReceiptEnabled } = useMessageListReadReceipts(); + useCountSelected(); const messageRef = useJumpToMessage(message._id); @@ -68,20 +95,17 @@ const RoomMessage = ({ role='listitem' aria-roledescription={t('message')} tabIndex={0} - aria-labelledby={`${message._id}-displayName ${message._id}-time ${message._id}-content ${message._id}-read-status`} + aria-labelledby={getAriaLabelledBy({ readReceiptEnabled, messageId: message._id, sequential })} onClick={selecting ? toggleSelected : undefined} isSelected={selected} isEditing={editing} isPending={message.temp} sequential={sequential} - data-qa-editing={editing} - data-qa-selected={selected} data-id={message._id} data-mid={message._id} data-unread={unread} data-sequential={sequential} data-own={message.u._id === uid} - data-qa-type='message' aria-busy={message.temp} {...props} > @@ -104,7 +128,7 @@ const RoomMessage = ({ {!sequential && } {ignored ? ( - + ) : ( )} diff --git a/apps/meteor/client/components/message/variants/SystemMessage.tsx b/apps/meteor/client/components/message/variants/SystemMessage.tsx index 1577007c06097..61095f2a8810e 100644 --- a/apps/meteor/client/components/message/variants/SystemMessage.tsx +++ b/apps/meteor/client/components/message/variants/SystemMessage.tsx @@ -21,6 +21,7 @@ import type { ComponentProps, ReactElement } from 'react'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; +import { normalizeUsername } from '../../../../lib/utils/normalizeUsername'; import { useIsSelecting, useToggleSelect, @@ -49,7 +50,8 @@ const SystemMessage = ({ message, showUserAvatar, ...props }: SystemMessageProps const showRealName = useMessageListShowRealName(); const user = { ...message.u, roles: [], ...useUserPresence(message.u._id) }; - const usernameAndRealNameAreSame = !user.name || user.username === user.name; + const normalizedUsername = normalizeUsername(user.username); + const usernameAndRealNameAreSame = !user.name || normalizedUsername === user.name; const showUsername = useMessageListShowUsername() && showRealName && !usernameAndRealNameAreSame; const displayName = useUserDisplayName(user); @@ -68,8 +70,6 @@ const SystemMessage = ({ message, showUserAvatar, ...props }: SystemMessageProps tabIndex={0} onClick={isSelecting ? toggleSelected : undefined} isSelected={isSelected} - data-qa-selected={isSelected} - data-qa='system-message' data-system-message-type={message.t} {...props} > @@ -84,11 +84,15 @@ const SystemMessage = ({ message, showUserAvatar, ...props }: SystemMessageProps {showUsername && ( <> {' '} - @{user.username} + @{normalizedUsername} )} - {messageType && {messageType.text(t, message)}} + {messageType && ( + + {messageType.text(t, message)} + + )} {formatTime(message.ts)} {message.attachments && ( diff --git a/apps/meteor/client/components/message/variants/ThreadMessage.tsx b/apps/meteor/client/components/message/variants/ThreadMessage.tsx index 86a0442a69948..04a93a3d78d52 100644 --- a/apps/meteor/client/components/message/variants/ThreadMessage.tsx +++ b/apps/meteor/client/components/message/variants/ThreadMessage.tsx @@ -45,13 +45,11 @@ const ThreadMessage = ({ message, sequential, unread, showUserAvatar }: ThreadMe isEditing={editing} isPending={message.temp} sequential={sequential} - data-qa-editing={editing} data-id={message._id} data-mid={message._id} data-unread={unread} data-sequential={sequential} data-own={message.u._id === uid} - data-qa-type='message' > {!sequential && message.u.username && showUserAvatar && ( @@ -72,7 +70,11 @@ const ThreadMessage = ({ message, sequential, unread, showUserAvatar }: ThreadMe {!sequential && } - {ignored ? : } + {ignored ? ( + + ) : ( + + )} {!message.private && } diff --git a/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx b/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx index 393f8b16e01a5..7aec9412fd07d 100644 --- a/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx +++ b/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx @@ -1,4 +1,4 @@ -import { type IThreadMessage } from '@rocket.chat/core-typings'; +import type { IThreadMessage } from '@rocket.chat/core-typings'; import { Skeleton, ThreadMessage, @@ -77,7 +77,6 @@ const ThreadMessagePreview = ({ message, showUserAvatar, sequential, ...props }: onClick={handleThreadClick} onKeyDown={(e) => e.code === 'Enter' && handleThreadClick()} isSelected={isSelected} - data-qa-selected={isSelected} {...props} > {!sequential && ( diff --git a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx index a8fbfb2e345e4..813d63e9f9130 100644 --- a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx @@ -2,9 +2,10 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { isDiscussionMessage, isThreadMainMessage, isE2EEMessage, isQuoteAttachment } from '@rocket.chat/core-typings'; import { MessageBody } from '@rocket.chat/fuselage'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; -import { useTranslation, useUserId, useUserPresence } from '@rocket.chat/ui-contexts'; +import { useUserId, useUserPresence } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; import { useChat } from '../../../../views/room/contexts/ChatContext'; import MessageContentBody from '../../MessageContentBody'; @@ -40,7 +41,7 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM const { enabled: readReceiptEnabled } = useMessageListReadReceipts(); const messageUser = { ...message.u, roles: [], ...useUserPresence(message.u._id) }; const chat = useChat(); - const t = useTranslation(); + const { t } = useTranslation(); const normalizedMessage = useNormalizedMessage(message); const isMessageEncrypted = encrypted && normalizedMessage?.e2e === 'pending'; @@ -51,7 +52,11 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM return ( <> - {isMessageEncrypted && {t('E2E_message_encrypted_placeholder')}} + {isMessageEncrypted && ( + + {t('E2E_message_encrypted_placeholder')} + + )} {!!quotes?.length && } diff --git a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx index bb422e1f20b90..d137162d0b06d 100644 --- a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx @@ -46,7 +46,11 @@ const ThreadMessageContent = ({ message }: ThreadMessageContentProps): ReactElem return ( <> - {isMessageEncrypted && {t('E2E_message_encrypted_placeholder')}} + {isMessageEncrypted && ( + + {t('E2E_message_encrypted_placeholder')} + + )} {!!quotes?.length && } diff --git a/apps/meteor/client/hooks/roomActions/useBannedUsersRoomAction.ts b/apps/meteor/client/hooks/roomActions/useBannedUsersRoomAction.ts new file mode 100644 index 0000000000000..e4bbbd9a371f3 --- /dev/null +++ b/apps/meteor/client/hooks/roomActions/useBannedUsersRoomAction.ts @@ -0,0 +1,29 @@ +import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts'; +import { usePermission } from '@rocket.chat/ui-contexts'; +import { lazy, useMemo } from 'react'; + +import { useRoom } from '../../views/room/contexts/RoomContext'; + +const BannedUsers = lazy(() => import('../../views/room/contextualBar/BannedUsers')); + +export const useBannedUsersRoomAction = () => { + const room = useRoom(); + + const hasPermissionToBan = usePermission('ban-user', room._id); + + return useMemo((): RoomToolboxActionConfig | undefined => { + if (!hasPermissionToBan) { + return undefined; + } + + return { + id: 'banned-users', + groups: ['channel', 'group', 'team'], + title: 'Banned_Users', + icon: 'ban', + tabComponent: BannedUsers, + order: 13, + type: 'moderation', + }; + }, [hasPermissionToBan]); +}; diff --git a/apps/meteor/client/hooks/useAppActionButtons.ts b/apps/meteor/client/hooks/useAppActionButtons.ts index 58173987308a4..acd8413b2176a 100644 --- a/apps/meteor/client/hooks/useAppActionButtons.ts +++ b/apps/meteor/client/hooks/useAppActionButtons.ts @@ -1,4 +1,4 @@ -import { type IUIActionButton, type UIActionButtonContext } from '@rocket.chat/apps-engine/definition/ui'; +import type { IUIActionButton, UIActionButtonContext } from '@rocket.chat/apps-engine/definition/ui'; import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; import { useConnectionStatus, useEndpoint, useStream, useUserId } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; diff --git a/apps/meteor/client/importPackages.ts b/apps/meteor/client/importPackages.ts index d1ec0b95d0221..6ec3976093bc5 100644 --- a/apps/meteor/client/importPackages.ts +++ b/apps/meteor/client/importPackages.ts @@ -10,6 +10,7 @@ import '../app/notifications/client'; import '../app/slackbridge/client'; import '../app/slashcommands-archiveroom/client'; import '../app/slashcommand-asciiarts/client'; +import '../app/slashcommands-ban/client'; import '../app/slashcommands-create/client'; import '../app/slashcommands-hide/client'; import '../app/slashcommands-invite/client'; diff --git a/apps/meteor/client/lib/2fa/overrideLoginMethod.ts b/apps/meteor/client/lib/2fa/overrideLoginMethod.ts index 7cf01ba3370c9..b499201756768 100644 --- a/apps/meteor/client/lib/2fa/overrideLoginMethod.ts +++ b/apps/meteor/client/lib/2fa/overrideLoginMethod.ts @@ -1,3 +1,6 @@ +import { Accounts } from 'meteor/accounts-base'; +import type { Meteor } from 'meteor/meteor'; + import { isTotpInvalidError, isTotpMaxAttemptsError, isTotpRequiredError } from './utils'; type LoginError = globalThis.Error | Meteor.Error | Meteor.TypedError; diff --git a/apps/meteor/client/lib/2fa/utils.ts b/apps/meteor/client/lib/2fa/utils.ts index ab2234f2e589a..d8d797d47b267 100644 --- a/apps/meteor/client/lib/2fa/utils.ts +++ b/apps/meteor/client/lib/2fa/utils.ts @@ -1,3 +1,5 @@ +import type { Meteor } from 'meteor/meteor'; + export const isTotpRequiredError = ( error: unknown, ): error is Meteor.Error & ({ error: 'totp-required' } | { errorType: 'totp-required' }) => diff --git a/apps/meteor/client/lib/chats/ChatAPI.ts b/apps/meteor/client/lib/chats/ChatAPI.ts index 96f6c6f03a5f4..b7155a71cd3e5 100644 --- a/apps/meteor/client/lib/chats/ChatAPI.ts +++ b/apps/meteor/client/lib/chats/ChatAPI.ts @@ -2,7 +2,7 @@ import type { IMessage, IRoom, ISubscription, IE2EEMessage, IUpload } from '@roc import type { IActionManager } from '@rocket.chat/ui-contexts'; import type { RefObject } from 'react'; -import type { Upload } from './Upload'; +import type { Upload, EncryptedFile } from './Upload'; import type { ReadStateManager } from './readStateManager'; import type { FormattingButton } from '../../../app/ui-message/client/messageBox/messageBoxFormatting'; @@ -65,6 +65,8 @@ export type ComposerAPI = { readonly formatters: Subscribable; readonly composerRef: RefObject; + + readonly uploads: UploadsAPI; }; export type DataAPI = { @@ -100,17 +102,24 @@ export type DataAPI = { getSubscriptionFromMessage(message: IMessage): Promise; }; +export type EncryptedFileUploadContent = { + rawFile: File; + fileContent: { raw: Partial; encrypted?: IE2EEMessage['content'] }; + encryptedFile: EncryptedFile; +}; + export type UploadsAPI = { get(): readonly Upload[]; subscribe(callback: () => void): () => void; wipeFailedOnes(): void; + clear(): void; + getProcessingUploads(): boolean; + setProcessingUploads(processing: boolean): void; cancel(id: Upload['id']): void; - send( - file: File, - { description, msg, t, e2e }: { description?: string; msg?: string; t?: IMessage['t']; e2e?: IMessage['e2e'] }, - getContent?: (fileId: string, fileUrl: string) => Promise, - fileContent?: { raw: Partial; encrypted: IE2EEMessage['content'] }, - ): Promise; + removeUpload(id: Upload['id']): void; + editUploadFileName: (id: Upload['id'], fileName: string) => void; + send(file: File, encrypted?: never): Promise; + send(file: File, encrypted: EncryptedFileUploadContent): Promise; }; export type ChatAPI = { @@ -118,7 +127,6 @@ export type ChatAPI = { readonly composer?: ComposerAPI; readonly setComposerAPI: (composer?: ComposerAPI) => void; readonly data: DataAPI; - readonly uploads: UploadsAPI; readonly readStateManager: ReadStateManager; readonly messageEditing: { toPreviousMessage(): Promise; @@ -148,7 +156,7 @@ export type ChatAPI = { ActionManager: IActionManager; readonly flows: { - readonly uploadFiles: (files: readonly File[], resetFileInput?: () => void) => Promise; + readonly uploadFiles: ({ files, resetFileInput }: { files: readonly File[]; resetFileInput?: () => void }) => Promise; readonly sendMessage: ({ text, tshow, @@ -157,6 +165,7 @@ export type ChatAPI = { tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean; + tmid?: IMessage['tmid']; }) => Promise; readonly processSlashCommand: (message: IMessage, userId: string | null) => Promise; readonly processTooLongMessage: (message: IMessage) => Promise; @@ -164,6 +173,7 @@ export type ChatAPI = { message: Pick & Partial>, previewUrls?: string[], ) => Promise; + readonly processMessageUploads: (message: IMessage) => Promise; readonly processSetReaction: (message: Pick) => Promise; readonly requestMessageDeletion: (message: IMessage) => Promise; readonly replyBroadcast: (message: IMessage) => Promise; diff --git a/apps/meteor/client/lib/chats/Upload.ts b/apps/meteor/client/lib/chats/Upload.ts index a2d6bf18cd3ce..798d955032049 100644 --- a/apps/meteor/client/lib/chats/Upload.ts +++ b/apps/meteor/client/lib/chats/Upload.ts @@ -1,6 +1,27 @@ -export type Upload = { +import type { IUpload } from '@rocket.chat/core-typings'; + +export type NonEncryptedUpload = { readonly id: string; - readonly name: string; + readonly file: File; + readonly url?: string; readonly percentage: number; readonly error?: Error; }; + +export type EncryptedUpload = NonEncryptedUpload & { + readonly encryptedFile: EncryptedFile; + readonly metadataForEncryption: Partial; +}; + +export type Upload = EncryptedUpload | NonEncryptedUpload; + +export type EncryptedFile = { + readonly file: File; + readonly key: JsonWebKey; + readonly iv: string; + readonly type: File['type']; + readonly hash: string; +}; + +export const isEncryptedUpload = (upload: Upload): upload is EncryptedUpload => + 'encryptedFile' in upload && upload.encryptedFile !== undefined; diff --git a/apps/meteor/client/lib/chats/flows/processMessageEditing.ts b/apps/meteor/client/lib/chats/flows/processMessageEditing.ts index 0985b5ca6d974..638038d30edf7 100644 --- a/apps/meteor/client/lib/chats/flows/processMessageEditing.ts +++ b/apps/meteor/client/lib/chats/flows/processMessageEditing.ts @@ -23,6 +23,7 @@ export const processMessageEditing = async ( } try { + chat.composer?.clear(); await chat.data.updateMessage({ ...message, _id: mid }, previewUrls); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); diff --git a/apps/meteor/client/lib/chats/flows/processMessageUploads.ts b/apps/meteor/client/lib/chats/flows/processMessageUploads.ts new file mode 100644 index 0000000000000..2736bd9fbf8d5 --- /dev/null +++ b/apps/meteor/client/lib/chats/flows/processMessageUploads.ts @@ -0,0 +1,214 @@ +import type { AtLeast, FileAttachmentProps, IE2EEMessage, IMessage, IUploadToConfirm } from '@rocket.chat/core-typings'; +import { imperativeModal, GenericModal } from '@rocket.chat/ui-client'; + +import { sdk } from '../../../../app/utils/client/lib/SDKClient'; +import { t } from '../../../../app/utils/lib/i18n'; +import { getFileExtension } from '../../../../lib/utils/getFileExtension'; +import { e2e } from '../../e2ee/rocketchat.e2e'; +import type { E2ERoom } from '../../e2ee/rocketchat.e2e.room'; +import { dispatchToastMessage } from '../../toast'; +import type { ChatAPI, UploadsAPI } from '../ChatAPI'; +import { isEncryptedUpload, type EncryptedUpload } from '../Upload'; + +const getHeightAndWidthFromDataUrl = (dataURL: string): Promise<{ height: number; width: number }> => { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + resolve({ + height: img.height, + width: img.width, + }); + }; + img.onerror = () => { + reject(new Error('Failed to load image for dimensions')); + }; + img.src = dataURL; + }); +}; + +const getAttachmentForFile = async (fileToUpload: EncryptedUpload): Promise => { + const attachment: FileAttachmentProps = { + title: fileToUpload.file.name, + type: 'file', + title_link: fileToUpload.url, + title_link_download: true, + encryption: { + key: fileToUpload.encryptedFile.key, + iv: fileToUpload.encryptedFile.iv, + }, + hashes: { + sha256: fileToUpload.encryptedFile.hash, + }, + fileId: fileToUpload.id, + }; + + const fileType = fileToUpload.file.type.match(/^(image|audio|video)\/.+/)?.[1] as 'image' | 'audio' | 'video' | undefined; + + if (!fileType) { + return { + ...attachment, + size: fileToUpload.file.size, + format: getFileExtension(fileToUpload.file.name), + }; + } + + return { + ...attachment, + [`${fileType}_url`]: fileToUpload.url, + [`${fileType}_type`]: fileToUpload.file.type, + [`${fileType}_size`]: fileToUpload.file.size, + ...(fileType === 'image' && { + image_dimensions: await getHeightAndWidthFromDataUrl(window.URL.createObjectURL(fileToUpload.file)), + }), + }; +}; + +const getEncryptedContent = async (filesToUpload: readonly EncryptedUpload[], e2eRoom: E2ERoom, msg: string) => { + const attachments: FileAttachmentProps[] = []; + + const arrayOfFiles = await Promise.all( + filesToUpload.map(async (fileToUpload) => { + attachments.push(await getAttachmentForFile(fileToUpload)); + + const file = { + _id: fileToUpload.id, + name: fileToUpload.file.name, + type: fileToUpload.file.type, + size: fileToUpload.file.size, + format: getFileExtension(fileToUpload.file.name), + }; + + return file; + }), + ); + + return e2eRoom.encryptMessageContent({ + attachments, + files: arrayOfFiles, + file: arrayOfFiles[0], + msg, + }); +}; + +async function continueSendingMessage(store: UploadsAPI, message: IMessage) { + const { msg, rid, tmid } = message; + const e2eRoom = await e2e.getInstanceByRoomId(rid); + const shouldConvertSentMessages = await e2eRoom?.shouldConvertSentMessages({ msg }); + const filesToUpload = store.get(); + + const confirmFilesQueue: (IUploadToConfirm & { + composedMessage: AtLeast & { fileName?: string; fileContent?: IE2EEMessage['content'] }; + })[] = []; + + const validFiles = filesToUpload.filter((file) => !file.error); + + for (const upload of validFiles) { + if (!upload.url || !upload.id) { + continue; + } + + /** + * The first message will keep the composedMessage, + * subsequent messages will have a empty text + * */ + const currentMsg = upload === validFiles[0] ? msg : ''; + + let content; + if (!e2eRoom || !isEncryptedUpload(upload)) { + confirmFilesQueue.push({ + _id: upload.id, + name: upload.file.name, + composedMessage: { tmid, msg: currentMsg, fileName: upload.file.name }, + }); + continue; + } + + const fileContent = await e2eRoom.encryptMessageContent(upload.metadataForEncryption); + + if (shouldConvertSentMessages) { + content = await getEncryptedContent([upload], e2eRoom, currentMsg); + } + + const composedMessage = { + tmid, + content, + t: 'e2e', + msg: '', + fileContent, + } as const; + + confirmFilesQueue.push({ _id: upload.id, name: upload.file.name, content: fileContent, composedMessage }); + } + + try { + store.setProcessingUploads(true); + for (const fileToConfirm of confirmFilesQueue) { + await sdk.rest.post(`/v1/rooms.mediaConfirm/${rid}/${fileToConfirm._id}`, fileToConfirm.composedMessage); + store.removeUpload(fileToConfirm._id); + } + } catch (error: unknown) { + dispatchToastMessage({ type: 'error', message: error }); + } finally { + store.setProcessingUploads(false); + } + + return true; +} + +export const processMessageUploads = async (chat: ChatAPI, message: IMessage): Promise => { + const store = chat.composer?.uploads; + + if (!store) { + return false; + } + + const filesToUpload = store.get(); + + if (filesToUpload.length === 0) { + return false; + } + + const failedUploads = filesToUpload.filter((upload) => upload.error); + + if (!failedUploads.length) { + return continueSendingMessage(store, message); + } + + const allUploadsFailed = failedUploads.length === filesToUpload.length; + + return new Promise((resolve) => { + imperativeModal.open({ + component: GenericModal, + props: { + variant: 'warning', + children: t('__count__files_failed_to_upload', { + count: failedUploads.length, + ...(failedUploads.length === 1 && { name: failedUploads[0].file.name }), + }), + ...(allUploadsFailed && { + title: t('Warning'), + confirmText: t('Ok'), + onConfirm: () => { + imperativeModal.close(); + }, + }), + ...(!allUploadsFailed && { + title: t('Are_you_sure'), + confirmText: t('Send_anyway'), + cancelText: t('Cancel'), + onConfirm: () => { + imperativeModal.close(); + failedUploads.forEach((upload) => store.removeUpload(upload.id)); + resolve(continueSendingMessage(store, message)); + }, + onCancel: () => { + imperativeModal.close(); + }, + }), + onClose: () => { + imperativeModal.close(); + }, + }, + }); + }); +}; diff --git a/apps/meteor/client/lib/chats/flows/processSetReaction.ts b/apps/meteor/client/lib/chats/flows/processSetReaction.ts index 172886960768b..1478320ef3e0b 100644 --- a/apps/meteor/client/lib/chats/flows/processSetReaction.ts +++ b/apps/meteor/client/lib/chats/flows/processSetReaction.ts @@ -21,6 +21,7 @@ export const processSetReaction = async (chat: ChatAPI, { msg }: Pick { - chat.composer?.setText(msg); - imperativeModal.close(); resolve(); }; diff --git a/apps/meteor/client/lib/chats/flows/sendMessage.ts b/apps/meteor/client/lib/chats/flows/sendMessage.ts index 64ef5ecf37a16..e2ad4962a851d 100644 --- a/apps/meteor/client/lib/chats/flows/sendMessage.ts +++ b/apps/meteor/client/lib/chats/flows/sendMessage.ts @@ -2,14 +2,16 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { sdk } from '../../../../app/utils/client/lib/SDKClient'; import { t } from '../../../../app/utils/lib/i18n'; +import { closeUnclosedCodeBlock } from '../../../../lib/utils/closeUnclosedCodeBlock'; +import { Messages } from '../../../stores'; import { onClientBeforeSendMessage } from '../../onClientBeforeSendMessage'; import { dispatchToastMessage } from '../../toast'; import type { ChatAPI } from '../ChatAPI'; import { processMessageEditing } from './processMessageEditing'; +import { processMessageUploads } from './processMessageUploads'; import { processSetReaction } from './processSetReaction'; import { processSlashCommand } from './processSlashCommand'; import { processTooLongMessage } from './processTooLongMessage'; -import { closeUnclosedCodeBlock } from '../../../../lib/utils/closeUnclosedCodeBlock'; const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[], isSlashCommandAllowed?: boolean): Promise => { const mid = chat.currentEditingMessage.getMID(); @@ -26,6 +28,11 @@ const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[], return; } + if (await processMessageUploads(chat, message)) { + chat.composer?.clear(); + return; + } + message = (await onClientBeforeSendMessage({ ...message, isEditing: !!mid })) as IMessage & { isEditing?: boolean }; // e2e should be a client property only @@ -36,7 +43,14 @@ const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[], return; } + chat.composer?.clear(); await sdk.call('sendMessage', message, previewUrls); + + // after the request is complete we can go ahead and mark as sent + Messages.state.update( + (record) => record._id === message._id && record.temp === true, + ({ temp: _, ...record }) => record, + ); }; export const sendMessage = async ( @@ -46,7 +60,7 @@ export const sendMessage = async ( tshow, previewUrls, isSlashCommandAllowed, - }: { text: string; tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean }, + }: { text: string; tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean; tmid?: IMessage['tmid'] }, ): Promise => { if (!(await chat.data.isSubscribedToRoom())) { try { @@ -59,33 +73,33 @@ export const sendMessage = async ( chat.readStateManager.clearUnreadMark(); + const uploadsStore = chat.composer?.uploads; + text = text.trim(); text = closeUnclosedCodeBlock(text); const mid = chat.currentEditingMessage.getMID(); - if (!text && !mid) { + + const hasFiles = uploadsStore && uploadsStore.get().length > 0; + if (!text && !mid && !hasFiles) { // Nothing to do return false; } - if (text) { + if (text || hasFiles) { const message = await chat.data.composeMessage(text, { sendToChannel: tshow, quotedMessages: chat.composer?.quotedMessages.get() ?? [], originalMessage: mid ? await chat.data.findMessageByID(mid) : null, }); + // When editing an encrypted message with files, preserve the original attachments/files + // This ensures they're included in the re-encryption process if (mid) { const originalMessage = await chat.data.findMessageByID(mid); - if ( - originalMessage?.t === 'e2e' && - originalMessage.attachments && - originalMessage.attachments.length > 0 && - originalMessage.attachments[0].description !== undefined - ) { - originalMessage.attachments[0].description = message.msg; + if (originalMessage?.t === 'e2e' && originalMessage.attachments && originalMessage.attachments.length > 0) { message.attachments = originalMessage.attachments; - message.msg = originalMessage.msg; + message.file = originalMessage.file; } } diff --git a/apps/meteor/client/lib/chats/flows/uploadFiles.ts b/apps/meteor/client/lib/chats/flows/uploadFiles.ts index fee271eebbf3d..5986282f17fc0 100644 --- a/apps/meteor/client/lib/chats/flows/uploadFiles.ts +++ b/apps/meteor/client/lib/chats/flows/uploadFiles.ts @@ -1,207 +1,86 @@ -import type { IMessage, FileAttachmentProps, IE2EEMessage, IUpload } from '@rocket.chat/core-typings'; -import { isRoomFederated } from '@rocket.chat/core-typings'; -import { imperativeModal } from '@rocket.chat/ui-client'; - -import { fileUploadIsValidContentType } from '../../../../app/utils/client'; -import { getFileExtension } from '../../../../lib/utils/getFileExtension'; -import FileUploadModal from '../../../views/room/modals/FileUploadModal'; +import { t } from '../../../../app/utils/lib/i18n'; +import { MAX_MULTIPLE_UPLOADED_FILES } from '../../../../lib/constants'; import { e2e } from '../../e2ee'; import { settings } from '../../settings'; -import { prependReplies } from '../../utils/prependReplies'; +import { dispatchToastMessage } from '../../toast'; import type { ChatAPI } from '../ChatAPI'; -const getHeightAndWidthFromDataUrl = (dataURL: string): Promise<{ height: number; width: number }> => { - return new Promise((resolve) => { - const img = new Image(); - img.onload = () => { - resolve({ - height: img.height, - width: img.width, - }); - }; - img.src = dataURL; - }); -}; - -export const uploadFiles = async (chat: ChatAPI, files: readonly File[], resetFileInput?: () => void): Promise => { - const replies = chat.composer?.quotedMessages.get() ?? []; - - const msg = await prependReplies('', replies); +export const uploadFiles = async ( + chat: ChatAPI, + { files, resetFileInput }: { files: readonly File[]; resetFileInput?: () => void }, +): Promise => { + const uploadsStore = chat.composer?.uploads; + if (!uploadsStore) { + throw new Error('No uploads store found in composer'); + } + + const mergedFilesLength = files.length + uploadsStore.get().length; + if (mergedFilesLength > MAX_MULTIPLE_UPLOADED_FILES) { + return dispatchToastMessage({ + type: 'error', + message: t('You_cant_upload_more_than__count__files', { count: MAX_MULTIPLE_UPLOADED_FILES }), + }); + } const room = await chat.data.getRoom(); - const queue = [...files]; + if (room.encrypted && !settings.peek('E2E_Allow_Unencrypted_Messages') && !settings.peek('E2E_Enable_Encrypt_Files')) { + return dispatchToastMessage({ + type: 'error', + message: t('You_cant_send_unencrypted_files_in_an_encrypted_room'), + }); + } - const uploadFile = ( - file: File, - extraData?: Pick & { description?: string }, - getContent?: (fileId: string, fileUrl: string) => Promise, - fileContent?: { raw: Partial; encrypted: IE2EEMessage['content'] }, - ) => { - chat.uploads.send( - file, - { - msg, - ...extraData, - }, - getContent, - fileContent, - ); - chat.composer?.clear(); - imperativeModal.close(); - uploadNextFile(); - }; + const uploadFile = async (file: File) => { + Object.defineProperty(file, 'name', { + writable: true, + value: file.name, + }); - const uploadNextFile = (): void => { - const file = queue.pop(); - if (!file) { - chat.composer?.dismissAllQuotedMessages(); + const e2eRoom = await e2e.getInstanceByRoomId(room._id); + + if (!e2eRoom || !settings.peek('E2E_Enable_Encrypt_Files')) { + await uploadsStore.send(file); return; } - imperativeModal.open({ - component: FileUploadModal, - props: { - file, - fileName: file.name, - fileDescription: chat.composer?.text ?? '', - showDescription: room && !isRoomFederated(room), - onClose: (): void => { - imperativeModal.close(); - uploadNextFile(); - }, - onSubmit: async (fileName, description): Promise => { - Object.defineProperty(file, 'name', { - writable: true, - value: fileName, - }); - - // encrypt attachment description - const e2eRoom = await e2e.getInstanceByRoomId(room._id); - - if (!e2eRoom) { - uploadFile(file, { description }); - return; - } - - if (!settings.peek('E2E_Enable_Encrypt_Files')) { - uploadFile(file, { description }); - return; - } - - const shouldConvertSentMessages = await e2eRoom.shouldConvertSentMessages({ msg }); - - if (!shouldConvertSentMessages) { - uploadFile(file, { description }); - return; - } + const encryptedFile = await e2eRoom.encryptFile(file); - const encryptedFile = await e2eRoom.encryptFile(file); - - if (encryptedFile) { - const getContent = async (_id: string, fileUrl: string): Promise => { - const attachments = []; - - const attachment: FileAttachmentProps = { - title: file.name, - type: 'file', - description, - title_link: fileUrl, - title_link_download: true, - encryption: { - key: encryptedFile.key, - iv: encryptedFile.iv, - }, - hashes: { - sha256: encryptedFile.hash, - }, - fileId: _id, - }; - - if (/^image\/.+/.test(file.type)) { - const dimensions = await getHeightAndWidthFromDataUrl(window.URL.createObjectURL(file)); - - attachments.push({ - ...attachment, - image_url: fileUrl, - image_type: file.type, - image_size: file.size, - ...(dimensions && { - image_dimensions: dimensions, - }), - }); - } else if (/^audio\/.+/.test(file.type)) { - attachments.push({ - ...attachment, - audio_url: fileUrl, - audio_type: file.type, - audio_size: file.size, - }); - } else if (/^video\/.+/.test(file.type)) { - attachments.push({ - ...attachment, - video_url: fileUrl, - video_type: file.type, - video_size: file.size, - }); - } else { - attachments.push({ - ...attachment, - size: file.size, - format: getFileExtension(file.name), - }); - } - - const files = [ - { - _id, - name: file.name, - type: file.type, - size: file.size, - // "format": "png" - }, - ] as IMessage['files']; - - return e2eRoom.encryptMessageContent({ - attachments, - files, - file: files?.[0], - }); - }; + if (!e2eRoom.isReady() || !encryptedFile) { + dispatchToastMessage({ + type: 'error', + message: t('Error_encrypting_file'), + }); + return; + } - const fileContentData = { - type: file.type, - typeGroup: file.type.split('/')[0], - name: fileName, - encryption: { - key: encryptedFile.key, - iv: encryptedFile.iv, - }, - hashes: { - sha256: encryptedFile.hash, - }, - }; + const fileContentData = { + type: file.type, + typeGroup: file.type.split('/')[0], + name: file.name, + encryption: { + key: encryptedFile.key, + iv: encryptedFile.iv, + }, + hashes: { + sha256: encryptedFile.hash, + }, + }; - const fileContent = { - raw: fileContentData, - encrypted: await e2eRoom.encryptMessageContent(fileContentData), - }; + const fileContent = { + raw: fileContentData, + encrypted: await e2eRoom.encryptMessageContent(fileContentData), + }; - uploadFile( - encryptedFile.file, - { - t: 'e2e', - }, - getContent, - fileContent, - ); - } - }, - invalidContentType: !fileUploadIsValidContentType(file.type), - }, - }); + await uploadsStore.send(encryptedFile.file, { rawFile: file, fileContent, encryptedFile }); }; - uploadNextFile(); resetFileInput?.(); + chat?.action.performContinuously('uploading'); + + try { + await Promise.allSettled(files.map((file) => uploadFile(file))); + } finally { + chat.composer?.focus(); + } }; diff --git a/apps/meteor/client/lib/chats/uploads.ts b/apps/meteor/client/lib/chats/uploads.ts index ce475b891e689..d243389333a9a 100644 --- a/apps/meteor/client/lib/chats/uploads.ts +++ b/apps/meteor/client/lib/chats/uploads.ts @@ -1,184 +1,201 @@ -import type { IMessage, IRoom, IE2EEMessage, IUpload } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { Random } from '@rocket.chat/random'; +import fileSize from 'filesize'; -import { UserAction, USER_ACTIVITIES } from '../../../app/ui/client/lib/UserAction'; -import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { getErrorMessage } from '../errorHandling'; -import type { UploadsAPI } from './ChatAPI'; -import type { Upload } from './Upload'; - -let uploads: readonly Upload[] = []; - -const emitter = new Emitter<{ update: void; [x: `cancelling-${Upload['id']}`]: void }>(); - -const updateUploads = (update: (uploads: readonly Upload[]) => readonly Upload[]): void => { - uploads = update(uploads); - emitter.emit('update'); -}; - -const get = (): readonly Upload[] => uploads; - -const subscribe = (callback: () => void): (() => void) => emitter.on('update', callback); - -const cancel = (id: Upload['id']): void => { - emitter.emit(`cancelling-${id}`); -}; - -const wipeFailedOnes = (): void => { - updateUploads((uploads) => uploads.filter((upload) => !upload.error)); -}; - -const send = async ( - file: File, - { - description, - msg, - rid, - tmid, - t, - }: { - description?: string; - msg?: string; - rid: string; - tmid?: string; - t?: IMessage['t']; - }, - getContent?: (fileId: string, fileUrl: string) => Promise, - fileContent?: { raw: Partial; encrypted: IE2EEMessage['content'] }, -): Promise => { - const id = Random.id(); - - const upload: Upload = { - id, - name: fileContent?.raw.name || file.name, - percentage: 0, +import type { UploadsAPI, EncryptedFileUploadContent } from './ChatAPI'; +import { isEncryptedUpload, type Upload } from './Upload'; +import { USER_ACTIVITIES, UserAction } from '../../../app/ui/client/lib/UserAction'; +import { fileUploadIsValidContentType } from '../../../app/utils/client'; +import { sdk } from '../../../app/utils/client/lib/SDKClient'; +import { i18n } from '../../../app/utils/lib/i18n'; +import { settings } from '../settings'; + +class UploadsStore extends Emitter<{ update: void; [x: `cancelling-${Upload['id']}`]: void }> implements UploadsAPI { + private rid: string; + + private tmid?: string; + + constructor({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }) { + super(); + this.rid = rid; + this.tmid = tmid; + } + + private uploads: readonly Upload[] = []; + + private processingUploads: boolean = false; + + set = (uploads: Upload[]): void => { + this.uploads = uploads; + this.emit('update'); }; - updateUploads((uploads) => [...uploads, upload]); - - try { - await new Promise((resolve, reject) => { - const xhr = sdk.rest.upload( - `/v1/rooms.media/${rid}`, - { - file, - ...(fileContent && { - content: JSON.stringify(fileContent.encrypted), - }), - }, - { - load: (event) => { - resolve(event); - }, - progress: (event) => { - if (!event.lengthComputable) { - return; - } - const progress = (event.loaded / event.total) * 100; - if (progress === 100) { - return; - } + get = (): readonly Upload[] => this.uploads; - updateUploads((uploads) => - uploads.map((upload) => { - if (upload.id !== id) { - return upload; - } - - return { - ...upload, - percentage: Math.round(progress) || 0, - }; - }), - ); - }, - error: (event) => { - updateUploads((uploads) => - uploads.map((upload) => { - if (upload.id !== id) { - return upload; - } - - return { - ...upload, - percentage: 0, - error: new Error(xhr.responseText), - }; - }), - ); - reject(event); - }, - }, - ); + subscribe = (callback: () => void): (() => void) => this.on('update', callback); - xhr.onload = async () => { - if (xhr.readyState === xhr.DONE) { - if (xhr.status === 400) { - const error = JSON.parse(xhr.responseText); - updateUploads((uploads) => [...uploads, { ...upload, error: new Error(error.error) }]); - return; - } + setProcessingUploads = (processing: boolean): void => { + this.processingUploads = processing; + this.emit('update'); + }; - if (xhr.status === 200) { - const result = JSON.parse(xhr.responseText); - let content; - if (getContent) { - content = await getContent(result.file._id, result.file.url); - } + getProcessingUploads = (): boolean => this.processingUploads; + + cancel = (id: Upload['id']): void => { + this.emit(`cancelling-${id}`); + }; + + wipeFailedOnes = (): void => { + this.set(this.uploads.filter((upload) => !upload.error)); + }; + + private updateUpload(id: Upload['id'], patch: Partial): void { + this.set(this.uploads.map((upload) => (upload.id !== id ? upload : { ...upload, ...patch }))); + } + + removeUpload = (id: Upload['id']): void => { + this.set(this.uploads.filter((upload) => upload.id !== id)); + + if (this.uploads.length === 0) { + UserAction.stop(this.rid, USER_ACTIVITIES.USER_UPLOADING, { tmid: this.tmid }); + } + }; + + editUploadFileName = (uploadId: Upload['id'], fileName: Upload['file']['name']) => { + try { + this.set( + this.uploads.map((upload) => { + if (upload.id !== uploadId) { + return upload; + } - await sdk.rest.post(`/v1/rooms.mediaConfirm/${rid}/${result.file._id}`, { - msg, - tmid, - description, - t, - content, - }); + return { + ...upload, + file: new File([upload.file], fileName, upload.file), + ...(isEncryptedUpload(upload) && { + metadataForEncryption: { ...upload.metadataForEncryption, name: fileName }, + }), + }; + }), + ); + } catch (error) { + this.set( + this.uploads.map((upload) => { + if (upload.id !== uploadId) { + return upload; } + + return { + ...upload, + percentage: 0, + error: new Error(i18n.t('FileUpload_Update_Failed')), + }; + }), + ); + } + }; + + clear = () => { + this.set([]); + UserAction.stop(this.rid, USER_ACTIVITIES.USER_UPLOADING, { tmid: this.tmid }); + }; + + async send(file: File, encrypted?: EncryptedFileUploadContent): Promise { + const maxFileSize = settings.peek('FileUpload_MaxFileSize'); + const invalidContentType = !fileUploadIsValidContentType(encrypted ? encrypted.rawFile.type : file.type); + const id = Random.id(); + + this.set([ + ...this.uploads, + { + id, + file: encrypted ? encrypted.rawFile : file, + percentage: 0, + ...(encrypted && { + encryptedFile: encrypted.encryptedFile, + metadataForEncryption: encrypted.fileContent.raw, + }), + }, + ]); + + try { + await new Promise((resolve, reject) => { + if (file.size === 0) { + return reject(new Error(i18n.t('FileUpload_File_Empty'))); } - }; - if (uploads.length) { - UserAction.performContinuously(rid, USER_ACTIVITIES.USER_UPLOADING, { tmid }); - } + // -1 maxFileSize means there is no limit + if (maxFileSize > -1 && (file.size || 0) > maxFileSize) { + return reject(new Error(i18n.t('File_exceeds_allowed_size_of_bytes', { size: fileSize(maxFileSize) }))); + } - emitter.once(`cancelling-${id}`, () => { - xhr.abort(); - updateUploads((uploads) => uploads.filter((upload) => upload.id !== id)); - }); - }); - - updateUploads((uploads) => uploads.filter((upload) => upload.id !== id)); - } catch (error: unknown) { - updateUploads((uploads) => - uploads.map((upload) => { - if (upload.id !== id) { - return upload; + if (invalidContentType) { + return reject(new Error(i18n.t('FileUpload_MediaType_NotAccepted__type__', { type: file.type }))); } - return { - ...upload, - percentage: 0, - error: new Error(getErrorMessage(error)), + const xhr = sdk.rest.upload( + `/v1/rooms.media/${this.rid}`, + { + file, + ...(encrypted && { + content: JSON.stringify(encrypted.fileContent.encrypted), + }), + }, + { + load: (event) => { + resolve(event); + }, + progress: (event) => { + if (!event.lengthComputable) { + return; + } + const progress = (event.loaded / event.total) * 100; + this.updateUpload(id, { percentage: Math.min(Math.round(progress), 99) || 0 }); + }, + error: (event) => { + this.updateUpload(id, { percentage: 0, error: new Error(xhr.responseText) }); + reject(event); + }, + }, + ); + + xhr.onload = () => { + try { + if (xhr.readyState !== xhr.DONE) { + return; + } + + if (xhr.status === 400) { + const error = JSON.parse(xhr.responseText); + this.updateUpload(id, { percentage: 0, error: new Error(error.error) }); + return; + } + + if (xhr.status === 200) { + const result = JSON.parse(xhr.responseText); + this.updateUpload(id, { id: result.file._id, url: result.file.url, percentage: 100 }); + return; + } + + this.updateUpload(id, { percentage: 0, error: new Error(i18n.t('FileUpload_Error')) }); + } catch (error) { + this.updateUpload(id, { percentage: 0, error: new Error(getErrorMessage(error)) }); + } }; - }), - ); - } finally { - if (!uploads.length) { - UserAction.stop(rid, USER_ACTIVITIES.USER_UPLOADING, { tmid }); + + this.once(`cancelling-${id}`, () => { + xhr.abort(); + this.removeUpload(id); + reject(new Error(i18n.t('FileUpload_Canceled'))); + }); + }); + } catch (error: unknown) { + this.updateUpload(id, { percentage: 0, error: new Error(getErrorMessage(error)) }); } } -}; - -export const createUploadsAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }): UploadsAPI => ({ - get, - subscribe, - wipeFailedOnes, - cancel, - send: ( - file: File, - { description, msg, t }: { description?: string; msg?: string; t?: IMessage['t'] }, - getContent?: (fileId: string, fileUrl: string) => Promise, - fileContent?: { raw: Partial; encrypted: IE2EEMessage['content'] }, - ): Promise => send(file, { description, msg, rid, tmid, t }, getContent, fileContent), -}); +} + +export const createUploadsAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }): UploadsAPI => + new UploadsStore({ rid, tmid }); diff --git a/apps/meteor/client/lib/createRouteGroup.tsx b/apps/meteor/client/lib/createRouteGroup.tsx index 514ae079cbfc2..be9d5c2595e1f 100644 --- a/apps/meteor/client/lib/createRouteGroup.tsx +++ b/apps/meteor/client/lib/createRouteGroup.tsx @@ -1,5 +1,5 @@ import type { IRouterPaths, RouteName, RouterPathPattern } from '@rocket.chat/ui-contexts'; -import { type ElementType, type ReactNode } from 'react'; +import type { ElementType, ReactNode } from 'react'; import { appLayout } from './appLayout'; import { router } from '../providers/RouterProvider'; diff --git a/apps/meteor/client/lib/customOAuth/CustomOAuth.ts b/apps/meteor/client/lib/customOAuth/CustomOAuth.ts index 8ac31adc0a5ce..69883efa401f8 100644 --- a/apps/meteor/client/lib/customOAuth/CustomOAuth.ts +++ b/apps/meteor/client/lib/customOAuth/CustomOAuth.ts @@ -1,11 +1,11 @@ import type { OAuthConfiguration, OauthConfig } from '@rocket.chat/core-typings'; import { Random } from '@rocket.chat/random'; import { capitalize } from '@rocket.chat/string-helpers'; +import { isAbsoluteURL } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; import { OAuth } from 'meteor/oauth'; -import { isURL } from '../../../lib/utils/isURL'; import type { IOAuthProvider } from '../../definitions/IOAuthProvider'; import { createOAuthTotpLoginMethod } from '../../meteor/login/oauth'; import { overrideLoginMethod, type LoginCallback } from '../2fa/overrideLoginMethod'; @@ -48,7 +48,7 @@ export class CustomOAuth implements IOAuth this.scope = options.scope ?? 'openid'; this.responseType = options.responseType || 'code'; - if (!isURL(this.authorizePath)) { + if (!isAbsoluteURL(this.authorizePath)) { this.authorizePath = this.serverURL + this.authorizePath; } } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 625e305365af1..e349e404fa08a 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -164,7 +164,7 @@ export class E2ERoom extends Emitter { this.setState('KEYS_RECEIVED'); } - async shouldConvertSentMessages(message: { msg: string }) { + async shouldConvertSentMessages(message: { msg?: string }) { if (!this.isReady() || this[PAUSED]) { return false; } @@ -175,7 +175,7 @@ export class E2ERoom extends Emitter { }); } - if (message.msg[0] === '/') { + if (message.msg?.[0] === '/') { return false; } diff --git a/apps/meteor/client/lib/federation/Federation.ts b/apps/meteor/client/lib/federation/Federation.ts index 8e9391b18bf26..28818615d6fe2 100644 --- a/apps/meteor/client/lib/federation/Federation.ts +++ b/apps/meteor/client/lib/federation/Federation.ts @@ -8,6 +8,7 @@ import { roomsQueryKeys } from '../queryKeys'; const allowedUserActionsInFederatedRooms: ValueOf[] = [ RoomMemberActions.REMOVE_USER, + RoomMemberActions.BAN, RoomMemberActions.SET_AS_OWNER, RoomMemberActions.SET_AS_MODERATOR, ]; @@ -53,9 +54,10 @@ export const actionAllowed = ( return displayingUserRoomRoles.includes('owner') ? myself : true; } - if (action === RoomMemberActions.REMOVE_USER) { + if (action === RoomMemberActions.REMOVE_USER || action === RoomMemberActions.BAN) { return !displayingUserRoomRoles.includes('owner'); } + const allowedForOwnersOverDefaultUsers = allowedUserActionsInFederatedRooms.includes(action); return allowedForOwnersOverDefaultUsers; @@ -73,7 +75,8 @@ export const actionAllowed = ( return false; } - const allowedForModeratorsOverDefaultUsers = action === RoomMemberActions.SET_AS_MODERATOR || action === RoomMemberActions.REMOVE_USER; + const allowedForModeratorsOverDefaultUsers = + action === RoomMemberActions.SET_AS_MODERATOR || action === RoomMemberActions.REMOVE_USER || action === RoomMemberActions.BAN; return allowedForModeratorsOverDefaultUsers; } diff --git a/apps/meteor/client/lib/queryKeys.ts b/apps/meteor/client/lib/queryKeys.ts index 44529ad6d2543..43dc9af71a5f4 100644 --- a/apps/meteor/client/lib/queryKeys.ts +++ b/apps/meteor/client/lib/queryKeys.ts @@ -31,6 +31,7 @@ export const roomsQueryKeys = { !type && !filter ? ([...roomsQueryKeys.room(rid), 'members', roomType] as const) : ([...roomsQueryKeys.room(rid), 'members', roomType, type, filter] as const), + bannedUsers: (rid: IRoom['_id']) => [...roomsQueryKeys.room(rid), 'banned-users'] as const, files: (rid: IRoom['_id'], options?: { type: string; text: string }) => [...roomsQueryKeys.room(rid), 'files', options] as const, images: (rid: IRoom['_id'], options?: { startingFromId?: string }) => [...roomsQueryKeys.room(rid), 'images', options] as const, autocomplete: (text: string) => [...roomsQueryKeys.all, 'autocomplete', text] as const, @@ -187,3 +188,8 @@ export const videoConferenceQueryKeys = { all: ['video-conference'] as const, fromRoom: (roomId: IRoom['_id']) => [...videoConferenceQueryKeys.all, 'rooms', roomId] as const, } as const; + +export const messagesQueryKeys = { + all: ['messages'] as const, + message: (messageId: IMessage['_id']) => [...messagesQueryKeys.all, messageId] as const, +}; diff --git a/apps/meteor/client/lib/utils/goToRoomById.ts b/apps/meteor/client/lib/utils/goToRoomById.ts deleted file mode 100644 index 8a5eca30aae29..0000000000000 --- a/apps/meteor/client/lib/utils/goToRoomById.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { IRoom } from '@rocket.chat/core-typings'; -import { memoize } from '@rocket.chat/memo'; - -import { callWithErrorHandling } from './callWithErrorHandling'; -import { router } from '../../providers/RouterProvider'; -import { Subscriptions } from '../../stores'; -import { roomCoordinator } from '../rooms/roomCoordinator'; - -const getRoomById = memoize((rid: IRoom['_id']) => callWithErrorHandling('getRoomById', rid)); - -export type GoToRoomByIdOptions = { - replace?: boolean; - routeParamsOverrides?: Record; -}; - -export const goToRoomById = async (rid: IRoom['_id'], options: GoToRoomByIdOptions = {}): Promise => { - if (!rid) { - return; - } - - const subscription = Subscriptions.state.find((record) => record.rid === rid); - - if (subscription) { - roomCoordinator.openRouteLink(subscription.t, subscription, router.getSearchParameters(), options); - return; - } - - const room = await getRoomById(rid); - roomCoordinator.openRouteLink(room.t, { rid: room._id, ...room }, router.getSearchParameters(), options); -}; diff --git a/apps/meteor/client/lib/utils/legacyJumpToMessage.ts b/apps/meteor/client/lib/utils/legacyJumpToMessage.ts deleted file mode 100644 index 8b880e517db95..0000000000000 --- a/apps/meteor/client/lib/utils/legacyJumpToMessage.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { IMessage } from '@rocket.chat/core-typings'; -import { isThreadMessage } from '@rocket.chat/core-typings'; - -import { goToRoomById } from './goToRoomById'; -import { RoomHistoryManager } from '../../../app/ui-utils/client'; -import { router } from '../../providers/RouterProvider'; -import { RoomManager } from '../RoomManager'; - -/** @deprecated */ -export const legacyJumpToMessage = async (message: IMessage) => { - if (isThreadMessage(message) || message.tcount) { - const { tab, context } = router.getRouteParameters(); - - if (tab === 'thread' && (context === message.tmid || context === message._id)) { - return; - } - - await goToRoomById(message.rid, { - routeParamsOverrides: { tab: 'thread', context: message.tmid || message._id }, - replace: RoomManager.opened === message.rid, - }); - await RoomHistoryManager.getSurroundingMessages(message); - return; - } - - if (RoomManager.opened === message.rid) { - await RoomHistoryManager.getSurroundingMessages(message); - return; - } - - await goToRoomById(message.rid); - - await RoomHistoryManager.getSurroundingMessages(message); -}; diff --git a/apps/meteor/client/meteor/minimongo/DiffSequence.ts b/apps/meteor/client/meteor/minimongo/DiffSequence.ts index 6b66285111dfe..9ff21263ac322 100644 --- a/apps/meteor/client/meteor/minimongo/DiffSequence.ts +++ b/apps/meteor/client/meteor/minimongo/DiffSequence.ts @@ -1,3 +1,5 @@ +import { Meteor } from 'meteor/meteor'; + import type { IdMap } from './IdMap'; import { clone, hasOwn, equals } from './common'; import type { Observer, OrderedObserver, UnorderedObserver } from './observers'; diff --git a/apps/meteor/client/meteor/minimongo/OrderedDict.ts b/apps/meteor/client/meteor/minimongo/OrderedDict.ts index 9bb039f6d0ea1..be6174f8658c5 100644 --- a/apps/meteor/client/meteor/minimongo/OrderedDict.ts +++ b/apps/meteor/client/meteor/minimongo/OrderedDict.ts @@ -130,7 +130,6 @@ export class OrderedDict { let i = 0; let elt = this._first; while (elt !== null) { - // eslint-disable-next-line no-await-in-loop const b = await asyncIter.call(context, elt.value, elt.key, i); if (b === OrderedDict.BREAK) return; elt = elt.next; diff --git a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.spec.tsx b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.spec.tsx index c64e282aabc3c..e89b15af771ce 100644 --- a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.spec.tsx +++ b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.spec.tsx @@ -5,9 +5,7 @@ import userEvent from '@testing-library/user-event'; import CreateChannelModal from './CreateChannelModal'; import { createFakeLicenseInfo } from '../../../../tests/mocks/data'; -jest.mock('../../../lib/utils/goToRoomById', () => ({ - goToRoomById: jest.fn(), -})); +jest.mock('../../../lib/rooms/roomCoordinator', () => ({})); describe('CreateChannelModal', () => { describe('Encryption', () => { diff --git a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.tsx b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.tsx index 528d261f4fab0..ad0f0627247ee 100644 --- a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.tsx +++ b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.tsx @@ -39,7 +39,7 @@ import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultip import { useCreateChannelTypePermission } from '../../../hooks/useCreateChannelTypePermission'; import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; import { useIsFederationEnabled } from '../../../hooks/useIsFederationEnabled'; -import { goToRoomById } from '../../../lib/utils/goToRoomById'; +import { useGoToRoom } from '../../../views/room/hooks/useGoToRoom'; type CreateChannelModalProps = { teamId?: string; @@ -160,6 +160,8 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh } }; + const goToRoom = useGoToRoom(); + const handleCreateChannel = async ({ name, members, readOnly, topic, broadcast, encrypted, federated }: CreateChannelModalPayload) => { let roomData; const params = { @@ -178,18 +180,17 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh try { if (isPrivate) { roomData = await createPrivateChannel(params); - !teamId && goToRoomById(roomData.group._id); + if (!teamId) goToRoom(roomData.group._id); } else { roomData = await createChannel(params); - !teamId && goToRoomById(roomData.channel._id); + if (!teamId) goToRoom(roomData.channel._id); } dispatchToastMessage({ type: 'success', message: t('Room_has_been_created') }); reload?.(); + onClose(); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); - } finally { - onClose(); } }; diff --git a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateDirectMessage.tsx b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateDirectMessage.tsx index 59318d02fa26b..3a5802a29036d 100644 --- a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateDirectMessage.tsx +++ b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateDirectMessage.tsx @@ -21,7 +21,7 @@ import { useId, memo } from 'react'; import { useForm, Controller } from 'react-hook-form'; import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple'; -import { goToRoomById } from '../../../lib/utils/goToRoomById'; +import { useGoToRoom } from '../../../views/room/hooks/useGoToRoom'; type CreateDirectMessageProps = { onClose: () => void }; @@ -40,10 +40,12 @@ const CreateDirectMessage = ({ onClose }: CreateDirectMessageProps) => { formState: { isSubmitting, isValidating, errors }, } = useForm({ mode: 'onBlur', defaultValues: { users: [] } }); + const goToRoom = useGoToRoom(); + const mutateDirectMessage = useMutation({ mutationFn: createDirectAction, onSuccess: ({ room: { rid } }) => { - goToRoomById(rid); + goToRoom(rid); }, onError: (error) => { dispatchToastMessage({ type: 'error', message: error }); diff --git a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.spec.tsx b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.spec.tsx index ecdf1f22591d0..a100df356076c 100644 --- a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.spec.tsx +++ b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.spec.tsx @@ -4,9 +4,7 @@ import userEvent from '@testing-library/user-event'; import CreateTeamModal from './CreateTeamModal'; -jest.mock('../../../lib/utils/goToRoomById', () => ({ - goToRoomById: jest.fn(), -})); +jest.mock('../../../lib/rooms/roomCoordinator', () => ({})); describe('CreateTeamModal', () => { it('should render with encryption option disabled and set to off when E2E_Enable=false and E2E_Enabled_Default_PrivateRooms=false', async () => { diff --git a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.tsx b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.tsx index 87feeea766266..4a9105b2047b3 100644 --- a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.tsx +++ b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.tsx @@ -35,7 +35,7 @@ import { Controller, useForm } from 'react-hook-form'; import { useEncryptedRoomDescription } from './useEncryptedRoomDescription'; import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple'; import { useCreateChannelTypePermission } from '../../../hooks/useCreateChannelTypePermission'; -import { goToRoomById } from '../../../lib/utils/goToRoomById'; +import { useGoToRoom } from '../../../views/room/hooks/useGoToRoom'; type CreateTeamModalInputs = { name: string; @@ -119,6 +119,8 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { const canChangeEncrypted = isPrivate && e2eEnabled; const getEncryptedHint = useEncryptedRoomDescription('team'); + const goToRoom = useGoToRoom(); + const handleCreateTeam = async ({ name, members, @@ -145,11 +147,10 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { try { const { team } = await createTeamAction(params); dispatchToastMessage({ type: 'success', message: t('Team_has_been_created') }); - goToRoomById(team.roomId); + goToRoom(team.roomId); + onClose(); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); - } finally { - onClose(); } }; diff --git a/apps/meteor/client/providers/AvatarUrlProvider.tsx b/apps/meteor/client/providers/AvatarUrlProvider.tsx index 85538a36b9ca0..1db5019afa242 100644 --- a/apps/meteor/client/providers/AvatarUrlProvider.tsx +++ b/apps/meteor/client/providers/AvatarUrlProvider.tsx @@ -11,9 +11,9 @@ type AvatarUrlProviderProps = { const AvatarUrlProvider = ({ children }: AvatarUrlProviderProps) => { const contextValue = useMemo(() => { - function getUserPathAvatar(username: string, etag?: string): string; - function getUserPathAvatar({ userId, etag }: { userId: string; etag?: string }): string; - function getUserPathAvatar({ username, etag }: { username: string; etag?: string }): string; + function getUserPathAvatar(username: string, etag?: string | null): string; + function getUserPathAvatar({ userId, etag }: { userId: string; etag?: string | null }): string; + function getUserPathAvatar({ username, etag }: { username: string; etag?: string | null }): string; function getUserPathAvatar(...args: any): string { if (typeof args[0] === 'string') { const [username, etag] = args; diff --git a/apps/meteor/client/providers/MediaCallProvider.tsx b/apps/meteor/client/providers/MediaCallProvider.tsx index 006b2bb067f22..e741e76d51025 100644 --- a/apps/meteor/client/providers/MediaCallProvider.tsx +++ b/apps/meteor/client/providers/MediaCallProvider.tsx @@ -1,5 +1,6 @@ +import { Emitter } from '@rocket.chat/emitter'; import { usePermission } from '@rocket.chat/ui-contexts'; -import { MediaCallProvider as MediaCallProviderBase, MediaCallContext } from '@rocket.chat/ui-voip'; +import { MediaCallProvider as MediaCallProviderBase, MediaCallInstanceContext } from '@rocket.chat/ui-voip'; import type { ReactNode } from 'react'; import { useMemo } from 'react'; @@ -13,17 +14,20 @@ const MediaCallProvider = ({ children }: { children: ReactNode }) => { const unauthorizedContextValue = useMemo( () => ({ - state: 'unauthorized' as const, - onToggleWidget: undefined, - onEndCall: undefined, - peerInfo: undefined, - setOpenRoomId: undefined, + inRoomView: false, + setInRoomView: () => undefined, + instance: undefined, + signalEmitter: new Emitter(), + audioElement: undefined, + openRoomId: undefined, + setOpenRoomId: () => undefined, + getAutocompleteOptions: () => Promise.resolve([]), }), [], ); if (!hasModule || (!canMakeInternalCall && !canMakeExternalCall)) { - return {children}; + return {children}; } return {children}; diff --git a/apps/meteor/client/sidebar/Item/Condensed.tsx b/apps/meteor/client/sidebar/Item/Condensed.tsx index 3c737cc30e7c9..1f9f522d2844e 100644 --- a/apps/meteor/client/sidebar/Item/Condensed.tsx +++ b/apps/meteor/client/sidebar/Item/Condensed.tsx @@ -24,7 +24,7 @@ const Condensed = ({ icon, title, avatar, actions, unread, menu, badges, ...prop const handlePointerEnter = () => setMenuVisibility(true); return ( - + {avatar && {avatar}} {icon} {title} diff --git a/apps/meteor/client/sidebar/Item/Extended.tsx b/apps/meteor/client/sidebar/Item/Extended.tsx index ce9faea597849..fde211d74f0d0 100644 --- a/apps/meteor/client/sidebar/Item/Extended.tsx +++ b/apps/meteor/client/sidebar/Item/Extended.tsx @@ -55,7 +55,7 @@ const Extended = ({ const handlePointerEnter = () => setMenuVisibility(true); return ( - + {avatar && {avatar}} diff --git a/apps/meteor/client/sidebar/Item/Medium.tsx b/apps/meteor/client/sidebar/Item/Medium.tsx index f26d64b983891..3492d4e55de34 100644 --- a/apps/meteor/client/sidebar/Item/Medium.tsx +++ b/apps/meteor/client/sidebar/Item/Medium.tsx @@ -23,7 +23,7 @@ const Medium = ({ icon, title, avatar, actions, badges, unread, menu, ...props } const handlePointerEnter = () => setMenuVisibility(true); return ( - + {avatar} {icon} {title} diff --git a/apps/meteor/client/sidebar/footer/SidebarFooterDefault.tsx b/apps/meteor/client/sidebar/footer/SidebarFooterDefault.tsx index 291799c361d36..3950f5ca09e7f 100644 --- a/apps/meteor/client/sidebar/footer/SidebarFooterDefault.tsx +++ b/apps/meteor/client/sidebar/footer/SidebarFooterDefault.tsx @@ -1,7 +1,7 @@ import { css } from '@rocket.chat/css-in-js'; import { Box, SidebarDivider, Palette, SidebarFooter as Footer } from '@rocket.chat/fuselage'; +import { useThemeMode } from '@rocket.chat/ui-client'; import { useSetting } from '@rocket.chat/ui-contexts'; -import { useThemeMode } from '@rocket.chat/ui-theming'; import DOMPurify from 'dompurify'; import type { ReactElement } from 'react'; diff --git a/apps/meteor/client/ui.ts b/apps/meteor/client/ui.ts index 033a1027b8524..5716ab1c83299 100644 --- a/apps/meteor/client/ui.ts +++ b/apps/meteor/client/ui.ts @@ -7,6 +7,7 @@ import { useOnHoldChatQuickAction } from './hooks/quickActions/useOnHoldChatQuic import { useTranscriptQuickAction } from './hooks/quickActions/useTranscriptQuickAction'; import { useAppsRoomStarActions } from './hooks/roomActions/useAppsRoomStarActions'; import { useAutotranslateRoomAction } from './hooks/roomActions/useAutotranslateRoomAction'; +import { useBannedUsersRoomAction } from './hooks/roomActions/useBannedUsersRoomAction'; import { useCallsRoomAction } from './hooks/roomActions/useCallsRoomAction'; import { useChannelSettingsRoomAction } from './hooks/roomActions/useChannelSettingsRoomAction'; import { useCleanHistoryRoomAction } from './hooks/roomActions/useCleanHistoryRoomAction'; @@ -52,6 +53,7 @@ export const roomActionHooks = [ useExportMessagesRoomAction, useGameCenterRoomAction, useKeyboardShortcutListRoomAction, + useBannedUsersRoomAction, useMembersListRoomAction, useMentionsRoomAction, useOmnichannelExternalFrameRoomAction, diff --git a/apps/meteor/client/uikit/hooks/useBannerContextValue.ts b/apps/meteor/client/uikit/hooks/useBannerContextValue.ts index 81cb70af6636a..2e1e9fd5796ab 100644 --- a/apps/meteor/client/uikit/hooks/useBannerContextValue.ts +++ b/apps/meteor/client/uikit/hooks/useBannerContextValue.ts @@ -1,6 +1,6 @@ import type { UiKitContext } from '@rocket.chat/fuselage-ui-kit'; import type * as UiKit from '@rocket.chat/ui-kit'; -import { type ContextType } from 'react'; +import type { ContextType } from 'react'; import { useUiKitActionManager } from './useUiKitActionManager'; diff --git a/apps/meteor/client/views/account/profile/AccountProfilePage.tsx b/apps/meteor/client/views/account/profile/AccountProfilePage.tsx index eda29d780c3d9..02fa3622f061f 100644 --- a/apps/meteor/client/views/account/profile/AccountProfilePage.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfilePage.tsx @@ -96,19 +96,23 @@ const AccountProfilePage = (): ReactElement => { try { await deleteOwnAccount({ password: SHA256(passwordOrUsername) }); dispatchToastMessage({ type: 'success', message: t('User_has_been_deleted') }); - logout(); + setModal(null); } catch (error: any) { if (error.error === 'user-last-owner') { const { shouldChangeOwner, shouldBeRemoved } = error.details; return handleConfirmOwnerChange(passwordOrUsername, shouldChangeOwner, shouldBeRemoved); } + if (error.errorType === 'error-invalid-password') { + throw error; + } + dispatchToastMessage({ type: 'error', message: error }); } }; return setModal( setModal(null)} isPassword={hasLocalPassword} />); - }, [dispatchToastMessage, hasLocalPassword, setModal, handleConfirmOwnerChange, deleteOwnAccount, logout, t]); + }, [dispatchToastMessage, hasLocalPassword, setModal, handleConfirmOwnerChange, deleteOwnAccount, t]); const profileFormId = useId(); diff --git a/apps/meteor/client/views/account/profile/ActionConfirmModal.tsx b/apps/meteor/client/views/account/profile/ActionConfirmModal.tsx index 1e5499105bd2a..39cf94affe0fb 100644 --- a/apps/meteor/client/views/account/profile/ActionConfirmModal.tsx +++ b/apps/meteor/client/views/account/profile/ActionConfirmModal.tsx @@ -1,63 +1,87 @@ -import { Box, PasswordInput, TextInput, FieldGroup, Field, FieldRow, FieldError } from '@rocket.chat/fuselage'; +import { Box, PasswordInput, TextInput, FieldGroup, Field, FieldRow, FieldError, FieldLabel } from '@rocket.chat/fuselage'; import { GenericModal } from '@rocket.chat/ui-client'; -import type { ChangeEvent } from 'react'; -import { useState, useCallback, useId } from 'react'; +import { useId } from 'react'; +import { useForm, Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; type ActionConfirmModalProps = { isPassword: boolean; - onConfirm: (input: string) => void; + onConfirm: (input: string) => Promise; onCancel: () => void; }; -// TODO: Use react-hook-form const ActionConfirmModal = ({ isPassword, onConfirm, onCancel }: ActionConfirmModalProps) => { const { t } = useTranslation(); - const [inputText, setInputText] = useState(''); - const [inputError, setInputError] = useState(); + const credentialFieldId = useId(); + const credentialFieldError = `${credentialFieldId}-error`; - const handleChange = useCallback( - (e: ChangeEvent) => { - e.target.value !== '' && setInputError(undefined); - setInputText(e.currentTarget.value); - }, - [setInputText], - ); + const { + control, + handleSubmit, + setError, + formState: { errors }, + } = useForm({ + defaultValues: { credential: '' }, + mode: 'onBlur', + }); - const handleSave = useCallback( - (e: ChangeEvent) => { - e.preventDefault(); - if (inputText === '') { - setInputError(t('Invalid_field')); - return; + const handleSave = async ({ credential }: { credential: string }) => { + try { + await onConfirm(credential); + } catch (error: any) { + if (error.errorType === 'error-invalid-password') { + setError('credential', { message: t('Invalid_password') }); } - onConfirm(inputText); - onCancel(); - }, - [inputText, onConfirm, onCancel, t], - ); + } + }; - const actionTextId = useId(); return ( } - onClose={onCancel} - onConfirm={handleSave} + wrapperFunction={(props) => } onCancel={onCancel} variant='danger' title={t('Delete_account?')} confirmText={t('Delete_account')} > - - {isPassword ? t('Enter_your_password_to_delete_your_account') : t('Enter_your_username_to_delete_your_account')} - + + {isPassword ? t('Enter_your_password_to_delete_your_account') : t('Enter_your_username_to_delete_your_account')} + - {isPassword && } - {!isPassword && } + + isPassword ? ( + + ) : ( + + ) + } + /> - {inputError} + {errors.credential && ( + + {errors.credential.message} + + )} diff --git a/apps/meteor/client/views/account/security/ChangePassphrase.tsx b/apps/meteor/client/views/account/security/ChangePassphrase.tsx index 8dab8d387b866..00a45db9db5e1 100644 --- a/apps/meteor/client/views/account/security/ChangePassphrase.tsx +++ b/apps/meteor/client/views/account/security/ChangePassphrase.tsx @@ -2,7 +2,6 @@ import { Box, Field, FieldError, FieldGroup, FieldHint, FieldLabel, FieldRow, Pa import { PasswordVerifierList } from '@rocket.chat/ui-client'; import { useToastMessageDispatch, usePasswordPolicy } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; -import DOMPurify from 'dompurify'; import { useEffect, useId } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { Trans, useTranslation } from 'react-i18next'; @@ -99,12 +98,9 @@ export const ChangePassphrase = (): JSX.Element => { return ( <> - + + + {t('Change_E2EE_password')} diff --git a/apps/meteor/client/views/account/tokens/AccountTokensTable/AccountTokensTable.tsx b/apps/meteor/client/views/account/tokens/AccountTokensTable/AccountTokensTable.tsx index 49810b0762f66..5da4ff48563e6 100644 --- a/apps/meteor/client/views/account/tokens/AccountTokensTable/AccountTokensTable.tsx +++ b/apps/meteor/client/views/account/tokens/AccountTokensTable/AccountTokensTable.tsx @@ -10,10 +10,9 @@ import { } from '@rocket.chat/ui-client'; import { useSetModal, useToastMessageDispatch, useUserId, useMethod, useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import DOMPurify from 'dompurify'; import type { ReactElement, RefObject } from 'react'; import { useMemo, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import AccountTokensRow from './AccountTokensRow'; import AddToken from './AddToken'; @@ -74,16 +73,9 @@ const AccountTokensTable = (): ReactElement => { setModal( - + + + , ); diff --git a/apps/meteor/client/views/account/tokens/AccountTokensTable/AddToken.tsx b/apps/meteor/client/views/account/tokens/AccountTokensTable/AddToken.tsx index fc90da26260c7..d790a66089ac1 100644 --- a/apps/meteor/client/views/account/tokens/AccountTokensTable/AddToken.tsx +++ b/apps/meteor/client/views/account/tokens/AccountTokensTable/AddToken.tsx @@ -2,10 +2,9 @@ import type { SelectOption } from '@rocket.chat/fuselage'; import { Box, TextInput, Button, Margins, Select, FieldError, FieldGroup, Field, FieldRow } from '@rocket.chat/fuselage'; import { GenericModal } from '@rocket.chat/ui-client'; import { useSetModal, useToastMessageDispatch, useUserId, useMethod } from '@rocket.chat/ui-contexts'; -import DOMPurify from 'dompurify'; import { useCallback, useId, useMemo } from 'react'; import { Controller, useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; type AddTokenFormData = { name: string; @@ -53,16 +52,9 @@ const AddToken = ({ reload }: AddTokenProps) => { setModal( - + + + , ); } catch (error) { diff --git a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx index 3bc46c8742f1d..b3a75763c0932 100644 --- a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx @@ -63,6 +63,7 @@ const AddCustomSound = ({ goToNew, close, onChange, ...props }: AddCustomSoundPr (typeof error === 'string' || error instanceof Error) && dispatchToastMessage({ type: 'error', message: error }); } }; + close(); return soundId; } catch (error) { (typeof error === 'string' || error instanceof Error) && dispatchToastMessage({ type: 'error', message: error }); diff --git a/apps/meteor/client/views/admin/customSounds/EditCustomSound.tsx b/apps/meteor/client/views/admin/customSounds/EditCustomSound.tsx index a16c6e476505f..0319ab6db8346 100644 --- a/apps/meteor/client/views/admin/customSounds/EditCustomSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/EditCustomSound.tsx @@ -1,3 +1,4 @@ +import { ContextualbarEmptyContent } from '@rocket.chat/ui-client'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import type { ReactElement } from 'react'; @@ -9,41 +10,37 @@ import { FormSkeleton } from '../../../components/Skeleton'; type EditCustomSoundProps = { _id: string | undefined; onChange?: () => void; - close?: () => void; + close: () => void; }; -function EditCustomSound({ _id, onChange, ...props }: EditCustomSoundProps): ReactElement | null { +function EditCustomSound({ _id, onChange, close, ...props }: EditCustomSoundProps): ReactElement | null { + const getSound = useEndpoint('GET', '/v1/custom-sounds.getOne'); const { t } = useTranslation(); - const getSounds = useEndpoint('GET', '/v1/custom-sounds.list'); - const { data, isPending, refetch } = useQuery({ - queryKey: ['custom-sounds', _id], - - queryFn: async () => { - const { sounds } = await getSounds({ query: JSON.stringify({ _id }) }); - - if (sounds.length === 0) { - throw new Error(t('No_results_found')); + const { data, isLoading } = useQuery({ + queryKey: ['custom-sound', _id], + queryFn: () => { + if (!_id) { + throw new Error('Cannot fetch custom sound: missing _id in query.'); } - return sounds[0]; + return getSound({ _id }); }, - meta: { apiErrorToastMessage: true }, + enabled: !!_id, }); - if (isPending) { + if (isLoading) { return ; } if (!data) { - return null; + return ; } const handleChange: () => void = () => { onChange?.(); - refetch?.(); }; - return ; + return ; } export default EditCustomSound; diff --git a/apps/meteor/client/views/admin/customSounds/EditSound.tsx b/apps/meteor/client/views/admin/customSounds/EditSound.tsx index 9f72df02ca7bd..f46ce0e175b61 100644 --- a/apps/meteor/client/views/admin/customSounds/EditSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/EditSound.tsx @@ -9,7 +9,7 @@ import { validate, createSoundData } from './lib'; import { useSingleFileInput } from '../../../hooks/useSingleFileInput'; type EditSoundProps = { - close?: () => void; + close: () => void; onChange: () => void; data: { _id: string; @@ -82,6 +82,7 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl } }; } + close(); } validation.forEach((invalidFieldName) => @@ -95,7 +96,7 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl ); const handleSave = useCallback(async () => { - saveAction(sound); + await saveAction(sound); onChange(); }, [saveAction, sound, onChange]); diff --git a/apps/meteor/client/views/admin/integrations/NewBot.tsx b/apps/meteor/client/views/admin/integrations/NewBot.tsx index efa50938534fb..360732038af0a 100644 --- a/apps/meteor/client/views/admin/integrations/NewBot.tsx +++ b/apps/meteor/client/views/admin/integrations/NewBot.tsx @@ -1,23 +1,14 @@ import { Box } from '@rocket.chat/fuselage'; -import DOMPurify from 'dompurify'; -import { useTranslation } from 'react-i18next'; +import { ExternalLink } from '@rocket.chat/ui-client'; +import { Trans } from 'react-i18next'; -const NewBot = () => { - const { t } = useTranslation(); - - return ( - ( + + }} /> - ); -}; + +); export default NewBot; diff --git a/apps/meteor/client/views/admin/integrations/incoming/IncomingWebhookForm.tsx b/apps/meteor/client/views/admin/integrations/incoming/IncomingWebhookForm.tsx index aa8e77f5b2746..166fef8202e74 100644 --- a/apps/meteor/client/views/admin/integrations/incoming/IncomingWebhookForm.tsx +++ b/apps/meteor/client/views/admin/integrations/incoming/IncomingWebhookForm.tsx @@ -21,7 +21,7 @@ import { useAbsoluteUrl } from '@rocket.chat/ui-contexts'; import DOMPurify from 'dompurify'; import { useId, useMemo } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import type { EditIncomingWebhookFormData } from './EditIncomingWebhook'; import useClipboardWithToast from '../../../../hooks/useClipboardWithToast'; @@ -177,17 +177,9 @@ const IncomingWebhookForm = ({ webhookData }: { webhookData?: Serialized {t('Messages_that_are_sent_to_the_Incoming_WebHook_will_be_posted_here')} - + + }} /> + {errors?.channel && ( {errors?.channel.message} @@ -202,7 +194,7 @@ const IncomingWebhookForm = ({ webhookData }: { webhookData?: Serialized ( {t('You_can_use_an_emoji_as_avatar')} - + + }} /> + diff --git a/apps/meteor/client/views/admin/integrations/outgoing/OutgoingWebhookForm.tsx b/apps/meteor/client/views/admin/integrations/outgoing/OutgoingWebhookForm.tsx index 00e8d6aea8e78..ad603c04a3c82 100644 --- a/apps/meteor/client/views/admin/integrations/outgoing/OutgoingWebhookForm.tsx +++ b/apps/meteor/client/views/admin/integrations/outgoing/OutgoingWebhookForm.tsx @@ -21,7 +21,7 @@ import type { TranslationKey } from '@rocket.chat/ui-contexts'; import DOMPurify from 'dompurify'; import { useId, useMemo } from 'react'; import { useFormContext, Controller } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import { outgoingEvents } from '../../../../../app/integrations/lib/outgoingEvents'; import { useHighlightedCode } from '../../../../hooks/useHighlightedCode'; @@ -177,21 +177,12 @@ const OutgoingWebhookForm = () => { /> {t('Channel_to_listen_on')} - - + + }} /> + + + + )} {showTriggerWords && ( @@ -229,17 +220,9 @@ const OutgoingWebhookForm = () => { /> {t('TargetRoom_Description')} - + + }} /> + )} @@ -365,10 +348,9 @@ const OutgoingWebhookForm = () => { /> {t('You_can_use_an_emoji_as_avatar')} - + + }} /> + @@ -495,10 +477,9 @@ const OutgoingWebhookForm = () => { )} /> - + + }} /> + {event === 'sendMessage' && ( diff --git a/apps/meteor/client/views/admin/mailer/MailerPage.tsx b/apps/meteor/client/views/admin/mailer/MailerPage.tsx index 4a4f87dbc6461..a8db94a2926a6 100644 --- a/apps/meteor/client/views/admin/mailer/MailerPage.tsx +++ b/apps/meteor/client/views/admin/mailer/MailerPage.tsx @@ -16,10 +16,9 @@ import { validateEmail } from '@rocket.chat/tools'; import { Page, PageHeader, PageScrollableContentWithShadow, PageFooter } from '@rocket.chat/ui-client'; import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; -import DOMPurify from 'dompurify'; import { useId } from 'react'; import { Controller, useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import { isJSON } from '../../../../lib/utils/isJSON'; @@ -176,7 +175,9 @@ const MailerPage = () => { {errors.emailBody.message} )} - + + }} /> + diff --git a/apps/meteor/client/views/admin/moderation/ModerationConsoleTable.tsx b/apps/meteor/client/views/admin/moderation/ModerationConsoleTable.tsx index 61e2af1211690..51f646d7ab0af 100644 --- a/apps/meteor/client/views/admin/moderation/ModerationConsoleTable.tsx +++ b/apps/meteor/client/views/admin/moderation/ModerationConsoleTable.tsx @@ -105,7 +105,7 @@ const ModerationConsoleTable = () => { {isLoading && ( {headers} - {isLoading && } + {isLoading && } )} {isSuccess && data.reports.length > 0 && ( diff --git a/apps/meteor/client/views/admin/moderation/ModerationConsoleTableRow.tsx b/apps/meteor/client/views/admin/moderation/ModerationConsoleTableRow.tsx index 5b3d6484306f8..7532e500f4392 100644 --- a/apps/meteor/client/views/admin/moderation/ModerationConsoleTableRow.tsx +++ b/apps/meteor/client/views/admin/moderation/ModerationConsoleTableRow.tsx @@ -3,6 +3,7 @@ import { GenericTableCell, GenericTableRow } from '@rocket.chat/ui-client'; import ModerationConsoleActions from './ModerationConsoleActions'; import UserColumn from './helpers/UserColumn'; +import { normalizeUsername } from '../../../../lib/utils/normalizeUsername'; import { useFormatDateAndTime } from '../../../hooks/useFormatDateAndTime'; export type ModerationConsoleRowProps = { @@ -12,7 +13,8 @@ export type ModerationConsoleRowProps = { }; const ModerationConsoleTableRow = ({ report, onClick, isDesktopOrLarger }: ModerationConsoleRowProps): JSX.Element => { - const { userId: _id, rooms, name, count, username, ts } = report; + const { userId: _id, rooms, name, count, ts } = report; + const username = report.username ? normalizeUsername(report.username) : undefined; const roomNames = rooms.map((room) => { if (room.t === 'd') { diff --git a/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUserTableRow.tsx b/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUserTableRow.tsx index b70fdfc49ab35..18828fafbea55 100644 --- a/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUserTableRow.tsx +++ b/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUserTableRow.tsx @@ -2,6 +2,7 @@ import type { IUser, UserReport, Serialized } from '@rocket.chat/core-typings'; import { GenericTableCell, GenericTableRow } from '@rocket.chat/ui-client'; import ModConsoleUserActions from './ModConsoleUserActions'; +import { normalizeUsername } from '../../../../../lib/utils/normalizeUsername'; import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime'; import UserColumn from '../helpers/UserColumn'; @@ -13,7 +14,8 @@ export type ModConsoleUserRowProps = { const ModConsoleUserTableRow = ({ report, onClick, isDesktopOrLarger }: ModConsoleUserRowProps): JSX.Element => { const { reportedUser, count, ts } = report; - const { _id, username, name, createdAt, emails } = reportedUser; + const { _id, name, createdAt, emails } = reportedUser; + const username = reportedUser.username ? normalizeUsername(reportedUser.username) : undefined; const formatDateAndTime = useFormatDateAndTime(); diff --git a/apps/meteor/client/views/admin/moderation/UserReports/UserReportInfo.tsx b/apps/meteor/client/views/admin/moderation/UserReports/UserReportInfo.tsx index 0525e16a52bef..5de2e062130e1 100644 --- a/apps/meteor/client/views/admin/moderation/UserReports/UserReportInfo.tsx +++ b/apps/meteor/client/views/admin/moderation/UserReports/UserReportInfo.tsx @@ -17,6 +17,7 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import UserContextFooter from './UserContextFooter'; +import { normalizeUsername } from '../../../../../lib/utils/normalizeUsername'; import GenericNoResults from '../../../../components/GenericNoResults'; import { FormSkeleton } from '../../../../components/Skeleton'; import { UserCardRole } from '../../../../components/UserCard'; @@ -47,7 +48,8 @@ const UserReportInfo = ({ userId }: { userId: string }) => { } const { username, name } = report.user; - return ; + const normalizedUsername = username ? normalizeUsername(username) : undefined; + return ; }, [report?.user, dataUpdatedAt]); const userEmails = useMemo(() => { diff --git a/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/__snapshots__/UsersInRoleTable.spec.tsx.snap b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/__snapshots__/UsersInRoleTable.spec.tsx.snap index e1a1e54446fca..6a47d55c4a414 100644 --- a/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/__snapshots__/UsersInRoleTable.spec.tsx.snap +++ b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/__snapshots__/UsersInRoleTable.spec.tsx.snap @@ -88,7 +88,7 @@ exports[`renders Default without crashing 1`] = ` class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x40" > - alert && , - [alert, i18n, t], + alert && ( + , + strong: , + br:
, + ul: