diff --git a/.changeset/blue-ladybugs-raise.md b/.changeset/blue-ladybugs-raise.md deleted file mode 100644 index 44d7a06b4111..000000000000 --- a/.changeset/blue-ladybugs-raise.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Translation files are requested multiple times diff --git a/.changeset/brave-snakes-scream.md b/.changeset/brave-snakes-scream.md new file mode 100644 index 000000000000..914f248cd821 --- /dev/null +++ b/.changeset/brave-snakes-scream.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed an issue where broadcasted events were published twice within the same instance diff --git a/.changeset/brown-clouds-add.md b/.changeset/brown-clouds-add.md deleted file mode 100644 index 6b69289177b5..000000000000 --- a/.changeset/brown-clouds-add.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fix: Performance issue on `Messages.countByType` aggregation caused by unindexed property on messages collection diff --git a/.changeset/brown-comics-cheat.md b/.changeset/brown-comics-cheat.md new file mode 100644 index 000000000000..a7907979881b --- /dev/null +++ b/.changeset/brown-comics-cheat.md @@ -0,0 +1,8 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-typings": patch +"@rocket.chat/model-typings": patch +--- + +chore: Calculate & Store MAC stats +Added new info to the stats: `omnichannelContactsBySource`, `uniqueContactsOfLastMonth`, `uniqueContactsOfLastWeek`, `uniqueContactsOfYesterday` diff --git a/.changeset/cool-zoos-move.md b/.changeset/cool-zoos-move.md new file mode 100644 index 000000000000..dda6fbe2b02e --- /dev/null +++ b/.changeset/cool-zoos-move.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fixed threads breaking when sending messages too fast diff --git a/.changeset/cuddly-houses-tie.md b/.changeset/cuddly-houses-tie.md deleted file mode 100644 index 76d86a690388..000000000000 --- a/.changeset/cuddly-houses-tie.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Hide Reset TOTP option if 2FA is disabled diff --git a/.changeset/cuddly-ties-bake.md b/.changeset/cuddly-ties-bake.md deleted file mode 100644 index d912d2969d75..000000000000 --- a/.changeset/cuddly-ties-bake.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/fuselage-ui-kit": minor -"@rocket.chat/uikit-playground": minor ---- - -feat: Add missing variants to UIKit button diff --git a/.changeset/curly-shoes-burn.md b/.changeset/curly-shoes-burn.md deleted file mode 100644 index 67d453ab7245..000000000000 --- a/.changeset/curly-shoes-burn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Added ability to freeze or completely disable integration scripts through envvars diff --git a/.changeset/custom-emoji-fs.md b/.changeset/custom-emoji-fs.md deleted file mode 100644 index a9a797f35bc8..000000000000 --- a/.changeset/custom-emoji-fs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fix: custom emoji upload with FileSystem method diff --git a/.changeset/dropdown.md b/.changeset/dropdown.md deleted file mode 100644 index 935c12aebe85..000000000000 --- a/.changeset/dropdown.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -New filters to the Rooms Table at `Workspace > Rooms` diff --git a/.changeset/dull-trainers-drive.md b/.changeset/dull-trainers-drive.md new file mode 100644 index 000000000000..f5a673cd8c30 --- /dev/null +++ b/.changeset/dull-trainers-drive.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +fix: Remove model-level query restrictions for monitors diff --git a/.changeset/eleven-gorillas-deliver.md b/.changeset/eleven-gorillas-deliver.md new file mode 100644 index 000000000000..403bd294828b --- /dev/null +++ b/.changeset/eleven-gorillas-deliver.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fix trying to upload same file again and again. diff --git a/.changeset/empty-ants-enjoy.md b/.changeset/empty-ants-enjoy.md deleted file mode 100644 index 4a55f82d0abf..000000000000 --- a/.changeset/empty-ants-enjoy.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': minor ---- - -fix: Wrong toast message while creating a new custom sound with an existing name diff --git a/.changeset/fast-yaks-collect.md b/.changeset/fast-yaks-collect.md deleted file mode 100644 index 60dd92030163..000000000000 --- a/.changeset/fast-yaks-collect.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -fixed an issue where 2fa was not working after an OAuth redirect diff --git a/.changeset/fluffy-lions-rage.md b/.changeset/fluffy-lions-rage.md deleted file mode 100644 index 09437a2cb88e..000000000000 --- a/.changeset/fluffy-lions-rage.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/livechat": patch ---- - -chore: (Livechat) Replace all `dangerouslySetInnerHTML` with `gazzodown` diff --git a/.changeset/fluffy-monkeys-sing.md b/.changeset/fluffy-monkeys-sing.md new file mode 100644 index 000000000000..db93491b0ecd --- /dev/null +++ b/.changeset/fluffy-monkeys-sing.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Changed the name of the administration Logs page to "Records", implemented a tab layout in this page and added a new tab called "Analytic reports" that shows the most recent result of the statistics endpoint. diff --git a/.changeset/friendly-glasses-mate.md b/.changeset/friendly-glasses-mate.md deleted file mode 100644 index 6a7a7b4f8546..000000000000 --- a/.changeset/friendly-glasses-mate.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': minor ---- - -fix: Time format of Retention Policy diff --git a/.changeset/gentle-radios-relate.md b/.changeset/gentle-radios-relate.md new file mode 100644 index 000000000000..8d5f12b3a286 --- /dev/null +++ b/.changeset/gentle-radios-relate.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed DM room with "guest" user kept as "read only" after reactivating user diff --git a/.changeset/heavy-ads-carry.md b/.changeset/heavy-ads-carry.md new file mode 100644 index 000000000000..c04e52fb48a0 --- /dev/null +++ b/.changeset/heavy-ads-carry.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fix: Change plan name from Enterprise to Premium on marketplace filtering diff --git a/.changeset/heavy-cougars-marry.md b/.changeset/heavy-cougars-marry.md deleted file mode 100644 index 893f53352114..000000000000 --- a/.changeset/heavy-cougars-marry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fix performance issue on Engagement Dashboard aggregation diff --git a/.changeset/hip-mugs-promise.md b/.changeset/hip-mugs-promise.md deleted file mode 100644 index 7100fec026e3..000000000000 --- a/.changeset/hip-mugs-promise.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -improve: System messages for omni-visitor abandonment feature diff --git a/.changeset/honest-numbers-compete.md b/.changeset/honest-numbers-compete.md deleted file mode 100644 index 1fd017e7fc16..000000000000 --- a/.changeset/honest-numbers-compete.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixes SAML full name updates not being mirrored to DM rooms. diff --git a/.changeset/khaki-feet-dance.md b/.changeset/khaki-feet-dance.md new file mode 100644 index 000000000000..a419afa34143 --- /dev/null +++ b/.changeset/khaki-feet-dance.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +feat: Save visitor's activity on agent's interaction diff --git a/.changeset/kind-books-love.md b/.changeset/kind-books-love.md new file mode 100644 index 000000000000..40ce15453ff4 --- /dev/null +++ b/.changeset/kind-books-love.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed message disappearing from room after erased even if "Show Deleted Status" is enabled diff --git a/.changeset/large-pandas-beam.md b/.changeset/large-pandas-beam.md new file mode 100644 index 000000000000..19f1eade9a9b --- /dev/null +++ b/.changeset/large-pandas-beam.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": minor +--- + +New setting to automatically enable autotranslate when joining rooms diff --git a/.changeset/lazy-ghosts-design.md b/.changeset/lazy-ghosts-design.md deleted file mode 100644 index 080e9986cebb..000000000000 --- a/.changeset/lazy-ghosts-design.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fixed misleading of 'total' in team members list inside Channel diff --git a/.changeset/loud-sheep-try.md b/.changeset/loud-sheep-try.md deleted file mode 100644 index f82d0d069554..000000000000 --- a/.changeset/loud-sheep-try.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/fuselage-ui-kit": minor -"@rocket.chat/uikit-playground": minor ---- - -feat: Adding new UIKit components: Callout, Checkbox, Radio Button, Time Picker, Toast Bar, Toggle Switch, Tab Navigation diff --git a/.changeset/lovely-snails-drop.md b/.changeset/lovely-snails-drop.md deleted file mode 100644 index 4e28c6a43c20..000000000000 --- a/.changeset/lovely-snails-drop.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/meteor": patch -"@rocket.chat/model-typings": patch ---- - -Fix spotlight search does not find rooms with special or non-latin characters diff --git a/.changeset/lucky-vans-develop.md b/.changeset/lucky-vans-develop.md new file mode 100644 index 000000000000..e57b7a1e68d5 --- /dev/null +++ b/.changeset/lucky-vans-develop.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed issue with file attachments in rooms' messages export having no content diff --git a/.changeset/nice-chairs-add.md b/.changeset/nice-chairs-add.md new file mode 100644 index 000000000000..dfc9d763e1c0 --- /dev/null +++ b/.changeset/nice-chairs-add.md @@ -0,0 +1,13 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/core-typings": minor +--- + +Added `push` statistic, containing three bits. Each bit represents a boolean: +``` +1 1 1 +| | | +| | +- push enabled = 0b1 = 1 +| +--- push gateway enabled = 0b10 = 2 ++----- push gateway changed = 0b100 = 4 +``` diff --git a/.changeset/odd-hounds-thank.md b/.changeset/odd-hounds-thank.md new file mode 100644 index 000000000000..aaddc5d51a38 --- /dev/null +++ b/.changeset/odd-hounds-thank.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +chore: Change plan name Enterprise to Premium on marketplace diff --git a/.changeset/old-federation-card.md b/.changeset/old-federation-card.md deleted file mode 100644 index fa9879d84426..000000000000 --- a/.changeset/old-federation-card.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Removed old/deprecated Rocket.Chat Federation card from Info page diff --git a/.changeset/old-zoos-hang.md b/.changeset/old-zoos-hang.md new file mode 100644 index 000000000000..eb39a6c9d83c --- /dev/null +++ b/.changeset/old-zoos-hang.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +fix: mobile ringing notification missing call id diff --git a/.changeset/perfect-adults-travel.md b/.changeset/perfect-adults-travel.md deleted file mode 100644 index 61ae4ab6dad5..000000000000 --- a/.changeset/perfect-adults-travel.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/fuselage-ui-kit": patch -"@rocket.chat/uikit-playground": patch ---- - -feat(fuselage-ui-kit): Introduce `TabsNavigationBlock` diff --git a/.changeset/pink-zoos-join.md b/.changeset/pink-zoos-join.md deleted file mode 100644 index dcc1088de0b5..000000000000 --- a/.changeset/pink-zoos-join.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fix the code that was setting email URL to an invalid value when SMTP was not set diff --git a/.changeset/popular-actors-cheat.md b/.changeset/popular-actors-cheat.md new file mode 100644 index 000000000000..aad5ec6ae638 --- /dev/null +++ b/.changeset/popular-actors-cheat.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/model-typings": patch +--- + +Do not allow auto-translation to be enabled in E2E rooms diff --git a/.changeset/proud-shrimps-cheat.md b/.changeset/proud-shrimps-cheat.md new file mode 100644 index 000000000000..cad8bc8bfa32 --- /dev/null +++ b/.changeset/proud-shrimps-cheat.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fix: Unable to send attachments via email as an omni-agent diff --git a/.changeset/quick-emus-march.md b/.changeset/quick-emus-march.md deleted file mode 100644 index 7a6d7b444654..000000000000 --- a/.changeset/quick-emus-march.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@rocket.chat/ddp-client': minor -'@rocket.chat/core-services': minor -'@rocket.chat/meteor': minor ---- - -Add new event to notify users directly about new banners diff --git a/.changeset/quiet-phones-reply.md b/.changeset/quiet-phones-reply.md new file mode 100644 index 000000000000..f2735e615491 --- /dev/null +++ b/.changeset/quiet-phones-reply.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Search users using full name too on share message modal diff --git a/.changeset/quiet-phones-sell.md b/.changeset/quiet-phones-sell.md deleted file mode 100644 index a6222cba16c9..000000000000 --- a/.changeset/quiet-phones-sell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -fixed an issue where oauth login was not working with some providers diff --git a/.changeset/rare-sheep-yawn.md b/.changeset/rare-sheep-yawn.md deleted file mode 100644 index 86c2d7283223..000000000000 --- a/.changeset/rare-sheep-yawn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/livechat": patch ---- - -fix: Issue caused by spaces in the `config.url` setting diff --git a/.changeset/selfish-hounds-pay.md b/.changeset/selfish-hounds-pay.md new file mode 100644 index 000000000000..3ca321bd392f --- /dev/null +++ b/.changeset/selfish-hounds-pay.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +fix: Monitors now able to forward a chat without taking it first diff --git a/.changeset/serious-garlics-clean.md b/.changeset/serious-garlics-clean.md deleted file mode 100644 index ccdc3c94dda4..000000000000 --- a/.changeset/serious-garlics-clean.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@rocket.chat/core-services': minor -'@rocket.chat/rest-typings': minor -'@rocket.chat/meteor': minor ---- - -New AddUser workflow for Federated Rooms diff --git a/.changeset/seven-carpets-march.md b/.changeset/seven-carpets-march.md new file mode 100644 index 000000000000..46fd1b7ddb62 --- /dev/null +++ b/.changeset/seven-carpets-march.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Add new permission to allow kick users from rooms without being a member diff --git a/.changeset/shaggy-beans-poke.md b/.changeset/shaggy-beans-poke.md deleted file mode 100644 index 31a480638952..000000000000 --- a/.changeset/shaggy-beans-poke.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fix `mention-here` and `mention-all` permissions not being honored diff --git a/.changeset/gold-horses-pretend.md b/.changeset/shiny-pillows-run.md similarity index 52% rename from .changeset/gold-horses-pretend.md rename to .changeset/shiny-pillows-run.md index a8908b68a23e..9a85d37a2f9d 100644 --- a/.changeset/gold-horses-pretend.md +++ b/.changeset/shiny-pillows-run.md @@ -2,4 +2,4 @@ "@rocket.chat/meteor": patch --- -Fixed CAS login after popup closes +fix: cloud alerts not working diff --git a/.changeset/shiny-tools-worry.md b/.changeset/shiny-tools-worry.md deleted file mode 100644 index f024eca38d04..000000000000 --- a/.changeset/shiny-tools-worry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": minor ---- - -feat: remove enforce password fallback dependency diff --git a/.changeset/silver-mugs-unite.md b/.changeset/silver-mugs-unite.md deleted file mode 100644 index be74b1bef215..000000000000 --- a/.changeset/silver-mugs-unite.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fix: show requested filters only on requested apps view diff --git a/.changeset/slimy-wasps-double.md b/.changeset/slimy-wasps-double.md deleted file mode 100644 index b28de342b274..000000000000 --- a/.changeset/slimy-wasps-double.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@rocket.chat/meteor': minor -'@rocket.chat/ui-contexts': minor ---- - -UX improvement for the Moderation Console Context bar for viewing the reported messages. The Report reason is now displayed in the reported messages context bar. -The Moderation Action Modal confirmation description is updated to be more clear and concise. diff --git a/.changeset/small-rice-repair.md b/.changeset/small-rice-repair.md deleted file mode 100644 index 67fdff5ca758..000000000000 --- a/.changeset/small-rice-repair.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fix validation in app status call that allowed Enterprise apps to be enabled in invalid environments diff --git a/.changeset/smooth-planes-cough.md b/.changeset/smooth-planes-cough.md deleted file mode 100644 index 9ad4239f0342..000000000000 --- a/.changeset/smooth-planes-cough.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@rocket.chat/ui-theming': minor -'@rocket.chat/rest-typings': minor -'@rocket.chat/meteor': minor ---- - -feat: high-contrast theme diff --git a/.changeset/soft-cows-juggle.md b/.changeset/soft-cows-juggle.md new file mode 100644 index 000000000000..6fcb20506483 --- /dev/null +++ b/.changeset/soft-cows-juggle.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +download translation files through CDN diff --git a/.changeset/soft-yaks-matter.md b/.changeset/soft-yaks-matter.md deleted file mode 100644 index c326eb7dca70..000000000000 --- a/.changeset/soft-yaks-matter.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": minor ---- - -feat: return all broken password policies at once diff --git a/.changeset/sour-parrots-nail.md b/.changeset/sour-parrots-nail.md deleted file mode 100644 index 1c1eaa3173a8..000000000000 --- a/.changeset/sour-parrots-nail.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixed "teams" icon not being displayed on spotlight sidebar search diff --git a/.changeset/stale-masks-learn.md b/.changeset/stale-masks-learn.md new file mode 100644 index 000000000000..1523b02b0c95 --- /dev/null +++ b/.changeset/stale-masks-learn.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/server-fetch': patch +--- + +Fixed an issue where the payload of an HTTP request made by an app wouldn't be correctly encoded in some cases diff --git a/.changeset/strange-papayas-yell.md b/.changeset/strange-papayas-yell.md new file mode 100644 index 000000000000..ca194dd2f9d4 --- /dev/null +++ b/.changeset/strange-papayas-yell.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +fix: Disables GenericMenu without any sections or items diff --git a/.changeset/sweet-chefs-exist.md b/.changeset/sweet-chefs-exist.md new file mode 100644 index 000000000000..6ceee63dd762 --- /dev/null +++ b/.changeset/sweet-chefs-exist.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Check for room scoped permissions for starting discussions diff --git a/.changeset/sweet-feet-relate.md b/.changeset/sweet-feet-relate.md new file mode 100644 index 000000000000..f7da740ebcc0 --- /dev/null +++ b/.changeset/sweet-feet-relate.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fix: user dropdown menu position on RTL layout diff --git a/.changeset/swift-birds-build.md b/.changeset/swift-birds-build.md deleted file mode 100644 index 4af3bddd875b..000000000000 --- a/.changeset/swift-birds-build.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fixed unable to create admin user using ADMIN\_\* environment variables diff --git a/.changeset/swift-walls-protect.md b/.changeset/swift-walls-protect.md deleted file mode 100644 index 6e3057775c32..000000000000 --- a/.changeset/swift-walls-protect.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fixed failing user data exports diff --git a/.changeset/thirty-jokes-compete.md b/.changeset/thirty-jokes-compete.md new file mode 100644 index 000000000000..9d4095e7771b --- /dev/null +++ b/.changeset/thirty-jokes-compete.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +chore: Deprecate un-used meteor method for omnichannel analytics diff --git a/.changeset/thirty-pumpkins-fix.md b/.changeset/thirty-pumpkins-fix.md new file mode 100644 index 000000000000..11b92b064e15 --- /dev/null +++ b/.changeset/thirty-pumpkins-fix.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/tools': minor +'@rocket.chat/meteor': minor +--- + +Added option to select between two script engine options for the integrations diff --git a/.changeset/tidy-bears-applaud.md b/.changeset/tidy-bears-applaud.md new file mode 100644 index 000000000000..cff12f3dc7d3 --- /dev/null +++ b/.changeset/tidy-bears-applaud.md @@ -0,0 +1,10 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/core-typings": minor +"@rocket.chat/model-typings": minor +"@rocket.chat/rest-typings": minor +--- + +Create a deployment fingerprint to identify possible deployment changes caused by database cloning. A question to the admin will confirm if it's a regular deployment change or an intent of a new deployment and correct identification values as needed. +The fingerprint is composed by `${siteUrl}${dbConnectionString}` and hashed via `sha256` in `base64`. +An environment variable named `AUTO_ACCEPT_FINGERPRINT`, when set to `true`, can be used to auto-accept an expected fingerprint change as a regular deployment update. diff --git a/.changeset/tidy-bears-camp.md b/.changeset/tidy-bears-camp.md deleted file mode 100644 index 3c2013f79023..000000000000 --- a/.changeset/tidy-bears-camp.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": minor ---- - -Introduced upsells for the engagement dashboard and device management admin sidebar items in CE workspaces. Additionally, restructured the admin sidebar items to enhance organization. diff --git a/.changeset/tiny-turkeys-burn.md b/.changeset/tiny-turkeys-burn.md deleted file mode 100644 index a146bd6a0eae..000000000000 --- a/.changeset/tiny-turkeys-burn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -fixed an issue on oauth login that caused missing emails to be detected as changed data diff --git a/.changeset/tiny-wolves-deliver.md b/.changeset/tiny-wolves-deliver.md new file mode 100644 index 000000000000..f89564a9b53c --- /dev/null +++ b/.changeset/tiny-wolves-deliver.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/ui-theming': patch +--- + +fix: light-theme font-disabled color diff --git a/.changeset/tough-apples-turn.md b/.changeset/tough-apples-turn.md new file mode 100644 index 000000000000..056a0645186e --- /dev/null +++ b/.changeset/tough-apples-turn.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Forward headers when using proxy for file uploads diff --git a/.changeset/tough-carrots-walk.md b/.changeset/tough-carrots-walk.md new file mode 100644 index 000000000000..2851e697b85e --- /dev/null +++ b/.changeset/tough-carrots-walk.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/license': patch +'@rocket.chat/meteor': patch +--- + +feat: added `licenses.info` endpoint diff --git a/.changeset/twelve-files-deny.md b/.changeset/twelve-files-deny.md new file mode 100644 index 000000000000..123bf0a7764b --- /dev/null +++ b/.changeset/twelve-files-deny.md @@ -0,0 +1,22 @@ +--- +'@rocket.chat/license': minor +'@rocket.chat/jwt': minor +'@rocket.chat/omnichannel-services': minor +'@rocket.chat/omnichannel-transcript': minor +'@rocket.chat/authorization-service': minor +'@rocket.chat/stream-hub-service': minor +'@rocket.chat/presence-service': minor +'@rocket.chat/account-service': minor +'@rocket.chat/core-services': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/ddp-streamer': minor +'@rocket.chat/queue-worker': minor +'@rocket.chat/presence': minor +'@rocket.chat/meteor': minor +--- + +Implemented the License library, it is used to handle the functionality like expiration date, modules, limits, etc. +Also added a version v3 of the license, which contains an extended list of features. +v2 is still supported, since we convert it to v3 on the fly. diff --git a/.changeset/violet-frogs-cheer.md b/.changeset/violet-frogs-cheer.md deleted file mode 100644 index db48243c40ed..000000000000 --- a/.changeset/violet-frogs-cheer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/fuselage-ui-kit': patch ---- - -Handle invalid context on `VideoConferenceBlock` component diff --git a/.changeset/warm-melons-type.md b/.changeset/warm-melons-type.md new file mode 100644 index 000000000000..5b187b8a7f11 --- /dev/null +++ b/.changeset/warm-melons-type.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-typings": patch +"@rocket.chat/omnichannel-services": patch +--- + +feat: Disable and annonimize visitors instead of removing diff --git a/.changeset/kind-students-worry.md b/.changeset/wicked-humans-hang.md similarity index 50% rename from .changeset/kind-students-worry.md rename to .changeset/wicked-humans-hang.md index 554c1c1204ea..e793bc978902 100644 --- a/.changeset/kind-students-worry.md +++ b/.changeset/wicked-humans-hang.md @@ -2,4 +2,4 @@ "@rocket.chat/meteor": patch --- -Make user default role setting public +Improve cache of static files diff --git a/.changeset/wicked-jars-double.md b/.changeset/wicked-jars-double.md new file mode 100644 index 000000000000..23deffe8606f --- /dev/null +++ b/.changeset/wicked-jars-double.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Handle the username update in the background diff --git a/.github/actions/build-docker/action.yml b/.github/actions/build-docker/action.yml new file mode 100644 index 000000000000..284a0985b78e --- /dev/null +++ b/.github/actions/build-docker/action.yml @@ -0,0 +1,75 @@ +name: 'Meteor Docker' + +inputs: + CR_USER: + required: true + CR_PAT: + required: true + node-version: + required: true + description: 'Node version' + type: string + platform: + required: false + description: 'Platform' + type: string + +runs: + using: composite + + steps: + - name: Login to GitHub Container Registry + if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ inputs.CR_USER }} + password: ${{ inputs.CR_PAT }} + + - name: Restore build + uses: actions/download-artifact@v3 + with: + name: build + path: /tmp/build + + - name: Unpack build + shell: bash + run: | + cd /tmp/build + tar xzf Rocket.Chat.tar.gz + rm Rocket.Chat.tar.gz + + - uses: dtinth/setup-github-actions-caching-for-turbo@v1 + + - name: Setup NodeJS + uses: ./.github/actions/setup-node + with: + node-version: ${{ inputs.node-version }} + cache-modules: true + install: true + + - run: yarn build + shell: bash + + - name: Build Docker images + shell: bash + run: | + args=(rocketchat) + + if [[ '${{ inputs.platform }}' = 'alpine' ]]; then + args+=($SERVICES_PUBLISH) + fi; + + docker compose -f docker-compose-ci.yml build "${args[@]}" + + - name: Publish Docker images to GitHub Container Registry + if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') + shell: bash + run: | + args=(rocketchat) + + if [[ '${{ inputs.platform }}' = 'alpine' ]]; then + args+=($SERVICES_PUBLISH) + fi; + + docker compose -f docker-compose-ci.yml push "${args[@]}" diff --git a/.github/actions/meteor-build/action.yml b/.github/actions/meteor-build/action.yml new file mode 100644 index 000000000000..21fec059c8de --- /dev/null +++ b/.github/actions/meteor-build/action.yml @@ -0,0 +1,129 @@ +name: 'Meteor Build' + +inputs: + coverage: + required: false + description: 'Enable coverage' + type: boolean + reset-meteor: + required: false + description: 'Reset Meteor' + type: boolean + node-version: + required: true + description: 'Node version' + type: string + +runs: + using: composite + + steps: + - name: Set Swap Space + uses: pierotofy/set-swap-space@master + with: + swap-size-gb: 4 + + - name: Setup NodeJS + uses: ./.github/actions/setup-node + with: + node-version: ${{ inputs.node-version }} + cache-modules: true + install: true + + # - name: Free disk space + # run: | + # sudo apt clean + # docker rmi $(docker image ls -aq) + # df -h + + - name: Cache vite + uses: actions/cache@v3 + with: + path: ./node_modules/.vite + key: vite-local-cache-${{ runner.OS }}-${{ hashFiles('package.json') }} + restore-keys: | + vite-local-cache-${{ runner.os }}- + + - name: Cache meteor local + uses: actions/cache@v3 + with: + path: ./apps/meteor/.meteor/local + key: meteor-local-cache-${{ runner.OS }}-${{ hashFiles('apps/meteor/.meteor/versions') }} + restore-keys: | + meteor-local-cache-${{ runner.os }}- + + - name: Cache meteor + uses: actions/cache@v3 + with: + path: ~/.meteor + key: meteor-cache-${{ runner.OS }}-${{ hashFiles('apps/meteor/.meteor/release') }} + restore-keys: | + meteor-cache-${{ runner.os }}- + + - name: Install Meteor + shell: bash + run: | + # Restore bin from cache + set +e + METEOR_SYMLINK_TARGET=$(readlink ~/.meteor/meteor) + METEOR_TOOL_DIRECTORY=$(dirname "$METEOR_SYMLINK_TARGET") + set -e + LAUNCHER=$HOME/.meteor/$METEOR_TOOL_DIRECTORY/scripts/admin/launch-meteor + if [ -e $LAUNCHER ] + then + echo "Cached Meteor bin found, restoring it" + sudo cp "$LAUNCHER" "/usr/local/bin/meteor" + else + echo "No cached Meteor bin found." + fi + + # only install meteor if bin isn't found + command -v meteor >/dev/null 2>&1 || curl https://install.meteor.com | sed s/--progress-bar/-sL/g | /bin/sh + + - name: Versions + shell: bash + run: | + npm --versions + yarn -v + node -v + meteor --version + meteor npm --versions + meteor node -v + git version + + - uses: dtinth/setup-github-actions-caching-for-turbo@v1 + + - name: Translation check + shell: bash + run: yarn turbo run translation-check + + - name: Reset Meteor + shell: bash + if: ${{ inputs.reset-meteor == 'true' }} + working-directory: ./apps/meteor + run: meteor reset + + - name: Build Rocket.Chat From Pull Request + shell: bash + if: startsWith(github.ref, 'refs/pull/') == true + env: + METEOR_PROFILE: 1000 + BABEL_ENV: ${{ inputs.coverage == 'true' && 'coverage' || '' }} + run: yarn build:ci -- --directory /tmp/dist + + - name: Build Rocket.Chat + shell: bash + if: startsWith(github.ref, 'refs/pull/') != true + run: yarn build:ci -- --directory /tmp/dist + + - name: Prepare build + shell: bash + run: | + cd /tmp/dist + tar czf /tmp/Rocket.Chat.tar.gz bundle + + - name: Store build + uses: actions/upload-artifact@v3 + with: + name: build + path: /tmp/Rocket.Chat.tar.gz diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml index 815af40226f8..d3a463492cbb 100644 --- a/.github/actions/setup-node/action.yml +++ b/.github/actions/setup-node/action.yml @@ -35,7 +35,7 @@ runs: - name: Use Node.js ${{ inputs.node-version }} id: node-version - uses: actions/setup-node@v3 + uses: actions/setup-node@v3.7.0 with: node-version: ${{ inputs.node-version }} cache: 'yarn' diff --git a/.github/workflows/ci-code-check.yml b/.github/workflows/ci-code-check.yml index 5a556a1a8e29..57cdac047423 100644 --- a/.github/workflows/ci-code-check.yml +++ b/.github/workflows/ci-code-check.yml @@ -44,6 +44,17 @@ jobs: - uses: dtinth/setup-github-actions-caching-for-turbo@v1 + - name: Cache TypeCheck + uses: actions/cache@v3 + if: matrix.check == 'ts' + with: + path: ./apps/meteor/tsconfig.typecheck.tsbuildinfo + key: typecheck-cache-${{ runner.OS }}-${{ hashFiles('yarn.lock') }}-${{ github.event.issue.number }} + restore-keys: | + typecheck-cache-${{ runner.OS }}-${{ hashFiles('yarn.lock') }} + typecheck-cache-${{ runner.OS }} + typecheck-cache + - name: TS TypeCheck if: matrix.check == 'ts' run: yarn turbo run typecheck diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index 960ecf015fa3..d77966f186b3 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -57,6 +57,8 @@ on: required: false REPORTER_ROCKETCHAT_API_KEY: required: false + CODECOV_TOKEN: + required: false env: MONGO_URL: mongodb://localhost:27017/rocketchat?replicaSet=rs0&directConnection=true @@ -81,7 +83,7 @@ jobs: steps: - name: Launch MongoDB - uses: supercharge/mongodb-github-action@1.9.0 + uses: supercharge/mongodb-github-action@v1.10.0 with: mongodb-version: ${{ matrix.mongodb-version }} mongodb-replica-set: rs0 @@ -95,6 +97,14 @@ jobs: cache-modules: true install: true + # if we are testing a PR from a fork, we need to build the docker image at this point + - uses: ./.github/actions/build-docker + if: github.event.pull_request.head.repo.full_name != github.repository + with: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + node-version: ${{ inputs.node-version }} + - uses: dtinth/setup-github-actions-caching-for-turbo@v1 - name: Start httpbin container and wait for it to be ready @@ -142,7 +152,7 @@ jobs: path: | ~/.cache/ms-playwright # This is the version of Playwright that we are using, if you are willing to upgrade, you should update this. - key: playwright-1.23.1 + key: playwright-1.37.1 - name: Install Playwright if: inputs.type == 'ui' && steps.cache-playwright.outputs.cache-hit != 'true' @@ -203,13 +213,17 @@ jobs: IS_EE: ${{ inputs.release == 'ee' && 'true' || '' }} REPORTER_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_ROCKETCHAT_API_KEY }} REPORTER_ROCKETCHAT_URL: ${{ secrets.REPORTER_ROCKETCHAT_URL }} - REPORTER_ROCKETCHAT_REPORT: ${{ github.ref == 'refs/heads/develop' && 'true' || '' }} + REPORTER_ROCKETCHAT_REPORT: ${{ github.event.pull_request.draft != 'true' && 'true' || '' }} + REPORTER_ROCKETCHAT_RUN: ${{ github.run_number }} REPORTER_ROCKETCHAT_BRANCH: ${{ github.ref }} REPORTER_ROCKETCHAT_DRAFT: ${{ github.event.pull_request.draft }} QASE_API_TOKEN: ${{ secrets.QASE_API_TOKEN }} QASE_REPORT: ${{ github.ref == 'refs/heads/develop' && 'true' || '' }} + CI: true working-directory: ./apps/meteor - run: yarn test:e2e --shard=${{ matrix.shard }}/${{ inputs.total-shard }} + run: | + yarn prepare + yarn test:e2e --shard=${{ matrix.shard }}/${{ inputs.total-shard }} - name: Store playwright test trace if: inputs.type == 'ui' && always() @@ -233,6 +247,7 @@ jobs: directory: ./apps/meteor flags: e2e verbose: true + token: ${{ secrets.CODECOV_TOKEN }} - name: Store e2e-ee-coverage if: inputs.type == 'ui' && inputs.release == 'ee' diff --git a/.github/workflows/ci-test-unit.yml b/.github/workflows/ci-test-unit.yml index 03c6bc2352ab..b4ef5cb273ad 100644 --- a/.github/workflows/ci-test-unit.yml +++ b/.github/workflows/ci-test-unit.yml @@ -6,6 +6,9 @@ on: node-version: required: true type: string + secrets: + CODECOV_TOKEN: + required: false env: MONGO_URL: mongodb://localhost:27017/rocketchat?replicaSet=rs0&directConnection=true @@ -36,3 +39,4 @@ jobs: with: flags: unit verbose: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fe46ecf66c3..ec8e905cd803 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,7 +123,7 @@ jobs: run: yarn build build: - name: 📦 Meteor Build + name: 📦 Meteor Build - coverage needs: [release-versions, packages-build] runs-on: ubuntu-20.04 @@ -138,114 +138,40 @@ jobs: echo "github.event_name: ${{ github.event_name }}" cat $GITHUB_EVENT_PATH - - name: Set Swap Space - uses: pierotofy/set-swap-space@master - with: - swap-size-gb: 4 - - uses: actions/checkout@v3 - - name: Setup NodeJS - uses: ./.github/actions/setup-node + - uses: ./.github/actions/meteor-build with: node-version: ${{ needs.release-versions.outputs.node-version }} - cache-modules: true - install: true - - # - name: Free disk space - # run: | - # sudo apt clean - # docker rmi $(docker image ls -aq) - # df -h - - - name: Cache vite - uses: actions/cache@v3 - with: - path: ./node_modules/.vite - key: vite-local-cache-${{ runner.OS }}-${{ hashFiles('package.json') }} - restore-keys: | - vite-local-cache-${{ runner.os }}- - - - name: Cache meteor local - uses: actions/cache@v3 - with: - path: ./apps/meteor/.meteor/local - key: meteor-local-cache-${{ runner.OS }}-${{ hashFiles('apps/meteor/.meteor/versions') }} - restore-keys: | - meteor-local-cache-${{ runner.os }}- + coverage: true - - name: Cache meteor - uses: actions/cache@v3 - with: - path: ~/.meteor - key: meteor-cache-${{ runner.OS }}-${{ hashFiles('apps/meteor/.meteor/release') }} - restore-keys: | - meteor-cache-${{ runner.os }}- - - - name: Install Meteor - run: | - # Restore bin from cache - set +e - METEOR_SYMLINK_TARGET=$(readlink ~/.meteor/meteor) - METEOR_TOOL_DIRECTORY=$(dirname "$METEOR_SYMLINK_TARGET") - set -e - LAUNCHER=$HOME/.meteor/$METEOR_TOOL_DIRECTORY/scripts/admin/launch-meteor - if [ -e $LAUNCHER ] - then - echo "Cached Meteor bin found, restoring it" - sudo cp "$LAUNCHER" "/usr/local/bin/meteor" - else - echo "No cached Meteor bin found." - fi - - # only install meteor if bin isn't found - command -v meteor >/dev/null 2>&1 || curl https://install.meteor.com | sed s/--progress-bar/-sL/g | /bin/sh + build-prod: + name: 📦 Meteor Build - official + needs: [tests-done, release-versions, packages-build] + if: (github.event_name == 'release' || github.ref == 'refs/heads/develop') + runs-on: ubuntu-20.04 - - name: Versions + steps: + - name: Github Info run: | - npm --versions - yarn -v - node -v - meteor --version - meteor npm --versions - meteor node -v - git version - - - uses: dtinth/setup-github-actions-caching-for-turbo@v1 - - - name: Translation check - run: yarn turbo run translation-check - - - name: Reset Meteor - if: startsWith(github.ref, 'refs/tags/') == 'true' || github.ref == 'refs/heads/develop' - working-directory: ./apps/meteor - run: meteor reset - - - name: Build Rocket.Chat From Pull Request - if: startsWith(github.ref, 'refs/pull/') == true - env: - METEOR_PROFILE: 1000 - run: yarn build:ci -- --directory /tmp/dist - - - name: Build Rocket.Chat - if: startsWith(github.ref, 'refs/pull/') != true - run: yarn build:ci -- --directory /tmp/dist + echo "GITHUB_ACTION: $GITHUB_ACTION" + echo "GITHUB_ACTOR: $GITHUB_ACTOR" + echo "GITHUB_REF: $GITHUB_REF" + echo "GITHUB_HEAD_REF: $GITHUB_HEAD_REF" + echo "GITHUB_BASE_REF: $GITHUB_BASE_REF" + echo "github.event_name: ${{ github.event_name }}" + cat $GITHUB_EVENT_PATH - - name: Prepare build - run: | - cd /tmp/dist - tar czf /tmp/Rocket.Chat.tar.gz bundle + - uses: actions/checkout@v3 - - name: Store build - uses: actions/upload-artifact@v3 + - uses: ./.github/actions/meteor-build with: - name: build - path: /tmp/Rocket.Chat.tar.gz + node-version: ${{ needs.release-versions.outputs.node-version }} + coverage: false - build-gh-docker: + build-gh-docker-coverage: name: 🚢 Build Docker Images for Testing needs: [build, release-versions] - if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') runs-on: ubuntu-20.04 env: @@ -263,55 +189,41 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Login to GitHub Container Registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ secrets.CR_USER }} - password: ${{ secrets.CR_PAT }} - - - name: Restore build - uses: actions/download-artifact@v3 - with: - name: build - path: /tmp/build - - - name: Unpack build - run: | - cd /tmp/build - tar xzf Rocket.Chat.tar.gz - rm Rocket.Chat.tar.gz - - - uses: dtinth/setup-github-actions-caching-for-turbo@v1 - - - name: Setup NodeJS - uses: ./.github/actions/setup-node + # we only build and publish the actual docker images if not a PR from a fork + - uses: ./.github/actions/build-docker + if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') with: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} node-version: ${{ needs.release-versions.outputs.node-version }} - cache-modules: true - install: true + platform: ${{ matrix.platform }} - - run: yarn build - - - name: Build Docker images - run: | - args=(rocketchat) - - if [[ '${{ matrix.platform }}' = 'alpine' ]]; then - args+=($SERVICES_PUBLISH) - fi; + build-gh-docker: + name: 🚢 Build Docker Images for Production + needs: [build-prod, release-versions] + runs-on: ubuntu-20.04 - docker compose -f docker-compose-ci.yml build "${args[@]}" + env: + RC_DOCKERFILE: ${{ matrix.platform == 'alpine' && needs.release-versions.outputs.rc-dockerfile-alpine || needs.release-versions.outputs.rc-dockerfile }} + RC_DOCKER_TAG: ${{ matrix.platform == 'alpine' && needs.release-versions.outputs.rc-docker-tag-alpine || needs.release-versions.outputs.rc-docker-tag }} + DOCKER_TAG: ${{ needs.release-versions.outputs.gh-docker-tag }} + LOWERCASE_REPOSITORY: ${{ needs.release-versions.outputs.lowercase-repo }} + SERVICES_PUBLISH: 'authorization-service account-service ddp-streamer-service presence-service stream-hub-service' - - name: Publish Docker images to GitHub Container Registry - run: | - args=(rocketchat) + strategy: + fail-fast: false + matrix: + platform: ['official', 'alpine'] - if [[ '${{ matrix.platform }}' = 'alpine' ]]; then - args+=($SERVICES_PUBLISH) - fi; + steps: + - uses: actions/checkout@v3 - docker compose -f docker-compose-ci.yml push "${args[@]}" + - uses: ./.github/actions/build-docker + with: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + node-version: ${{ needs.release-versions.outputs.node-version }} + platform: ${{ matrix.platform }} - name: Rename official Docker tag to GitHub Container Registry if: matrix.platform == 'official' @@ -337,10 +249,12 @@ jobs: uses: ./.github/workflows/ci-test-unit.yml with: node-version: ${{ needs.release-versions.outputs.node-version }} + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} test-api: name: 🔨 Test API (CE) - needs: [checks, build-gh-docker, release-versions] + needs: [checks, build-gh-docker-coverage, release-versions] uses: ./.github/workflows/ci-test-e2e.yml with: @@ -359,7 +273,7 @@ jobs: test-ui: name: 🔨 Test UI (CE) - needs: [checks, build-gh-docker, release-versions] + needs: [checks, build-gh-docker-coverage, release-versions] uses: ./.github/workflows/ci-test-e2e.yml with: @@ -385,7 +299,7 @@ jobs: test-api-ee: name: 🔨 Test API (EE) - needs: [checks, build-gh-docker, release-versions] + needs: [checks, build-gh-docker-coverage, release-versions] uses: ./.github/workflows/ci-test-e2e.yml with: @@ -407,7 +321,7 @@ jobs: test-ui-ee: name: 🔨 Test UI (EE) - needs: [checks, build-gh-docker, release-versions] + needs: [checks, build-gh-docker-coverage, release-versions] uses: ./.github/workflows/ci-test-e2e.yml with: @@ -431,6 +345,7 @@ jobs: QASE_API_TOKEN: ${{ secrets.QASE_API_TOKEN }} REPORTER_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_ROCKETCHAT_API_KEY }} REPORTER_ROCKETCHAT_URL: ${{ secrets.REPORTER_ROCKETCHAT_URL }} + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} tests-done: name: ✅ Tests Done @@ -443,13 +358,16 @@ jobs: echo finished deploy: - name: 🚀 Publish build and update our registry + name: 🚀 Publish build assets runs-on: ubuntu-20.04 if: github.event_name == 'release' || github.ref == 'refs/heads/develop' - needs: [tests-done, release-versions] + needs: [build-gh-docker, release-versions] steps: - - uses: actions/checkout@v3 + - uses: Bhacaz/checkout-files@v2 + with: + files: package.json + branch: ${{ github.ref }} - name: Restore build uses: actions/download-artifact@v3 @@ -463,32 +381,17 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: 'us-east-1' GPG_PASSWORD: ${{ secrets.GPG_PASSWORD }} - REDHAT_REGISTRY_PID: ${{ secrets.REDHAT_REGISTRY_PID }} - REDHAT_REGISTRY_KEY: ${{ secrets.REDHAT_REGISTRY_KEY }} - UPDATE_TOKEN: ${{ secrets.UPDATE_TOKEN }} run: | REPO_VERSION=$(node -p "require('./package.json').version") + if [[ '${{ github.event_name }}' = 'release' ]]; then GIT_TAG="${GITHUB_REF#*tags/}" - GIT_BRANCH="" ARTIFACT_NAME="${REPO_VERSION}" - RC_VERSION=$GIT_TAG - - if [[ '${{ needs.release-versions.outputs.release }}' = 'release-candidate' ]]; then - SNAP_CHANNEL=candidate - RC_RELEASE=candidate - elif [[ '${{ needs.release-versions.outputs.release }}' = 'latest' ]]; then - SNAP_CHANNEL=stable - RC_RELEASE=stable - fi else GIT_TAG="" - GIT_BRANCH="${GITHUB_REF#*heads/}" ARTIFACT_NAME="${REPO_VERSION}.$GITHUB_SHA" - RC_VERSION="${REPO_VERSION}" - SNAP_CHANNEL=edge - RC_RELEASE=develop fi; + ROCKET_DEPLOY_DIR="/tmp/deploy" FILENAME="$ROCKET_DEPLOY_DIR/rocket.chat-$ARTIFACT_NAME.tgz"; @@ -506,22 +409,6 @@ jobs: aws s3 cp $ROCKET_DEPLOY_DIR/ s3://download.rocket.chat/build/ --recursive - curl -H "Content-Type: application/json" -H "X-Update-Token: $UPDATE_TOKEN" -d \ - "{\"nodeVersion\": \"${{ needs.release-versions.outputs.node-version }}\", \"compatibleMongoVersions\": [\"4.4\", \"5.0\", \"6.0\"], \"commit\": \"$GITHUB_SHA\", \"tag\": \"$RC_VERSION\", \"branch\": \"$GIT_BRANCH\", \"artifactName\": \"$ARTIFACT_NAME\", \"releaseType\": \"$RC_RELEASE\"}" \ - https://releases.rocket.chat/update - - # Makes build fail if the release isn't there - curl --fail https://releases.rocket.chat/$RC_VERSION/info - - if [[ $GIT_TAG ]]; then - curl -X POST \ - https://connect.redhat.com/api/v2/projects/$REDHAT_REGISTRY_PID/build \ - -H "Authorization: Bearer $REDHAT_REGISTRY_KEY" \ - -H 'Cache-Control: no-cache' \ - -H 'Content-Type: application/json' \ - -d '{"tag":"'$GIT_TAG'"}' - fi - build-docker-preview: name: 🚢 Build Docker Image (preview) runs-on: ubuntu-20.04 @@ -752,6 +639,66 @@ jobs: echo "::endgroup::" + notify-services: + name: 🚀 Notify external services + runs-on: ubuntu-20.04 + needs: + - services-docker-image-publish + - docker-image-publish + - release-versions + steps: + - uses: Bhacaz/checkout-files@v2 + with: + files: package.json + branch: ${{ github.ref }} + + - name: Releases service + env: + UPDATE_TOKEN: ${{ secrets.UPDATE_TOKEN }} + run: | + REPO_VERSION=$(node -p "require('./package.json').version") + + if [[ '${{ github.event_name }}' = 'release' ]]; then + GIT_TAG="${GITHUB_REF#*tags/}" + GIT_BRANCH="" + ARTIFACT_NAME="${REPO_VERSION}" + RC_VERSION=$GIT_TAG + + if [[ '${{ needs.release-versions.outputs.release }}' = 'release-candidate' ]]; then + RC_RELEASE=candidate + elif [[ '${{ needs.release-versions.outputs.release }}' = 'latest' ]]; then + RC_RELEASE=stable + fi + else + GIT_TAG="" + GIT_BRANCH="${GITHUB_REF#*heads/}" + ARTIFACT_NAME="${REPO_VERSION}.$GITHUB_SHA" + RC_VERSION="${REPO_VERSION}" + RC_RELEASE=develop + fi; + + curl -H "Content-Type: application/json" -H "X-Update-Token: $UPDATE_TOKEN" -d \ + "{\"nodeVersion\": \"${{ needs.release-versions.outputs.node-version }}\", \"compatibleMongoVersions\": [\"4.4\", \"5.0\", \"6.0\"], \"commit\": \"$GITHUB_SHA\", \"tag\": \"$RC_VERSION\", \"branch\": \"$GIT_BRANCH\", \"artifactName\": \"$ARTIFACT_NAME\", \"releaseType\": \"$RC_RELEASE\"}" \ + https://releases.rocket.chat/update + + # Makes build fail if the release isn't there + curl --fail https://releases.rocket.chat/$RC_VERSION/info + + - name: RedHat Registry + if: github.event_name == 'release' + env: + REDHAT_REGISTRY_PID: ${{ secrets.REDHAT_REGISTRY_PID }} + REDHAT_REGISTRY_KEY: ${{ secrets.REDHAT_REGISTRY_KEY }} + run: | + GIT_TAG="${GITHUB_REF#*tags/}" + + curl -X POST \ + https://connect.redhat.com/api/v2/projects/$REDHAT_REGISTRY_PID/build \ + -H "Authorization: Bearer $REDHAT_REGISTRY_KEY" \ + -H 'Cache-Control: no-cache' \ + -H 'Content-Type: application/json' \ + -d '{"tag":"'$GIT_TAG'"}' + trigger-dependent-workflows: runs-on: ubuntu-latest if: github.event_name == 'release' diff --git a/.github/workflows/vulnerabilities-jira-integration.yml b/.github/workflows/vulnerabilities-jira-integration.yml new file mode 100644 index 000000000000..2daeb533937d --- /dev/null +++ b/.github/workflows/vulnerabilities-jira-integration.yml @@ -0,0 +1,22 @@ +name: Github vulnerabilities and jira board integration + +on: + schedule: + - cron: '0 1 * * *' + +jobs: + IntegrateSecurityVulnerabilities: + runs-on: ubuntu-latest + steps: + - name: "Github vulnerabilities and jira board integration" + uses: RocketChat/github-vulnerabilities-jira-integration@v0.3 + env: + JIRA_URL: https://rocketchat.atlassian.net/ + JIRA_TOKEN: ${{ secrets.JIRA_TOKEN }} + GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }} + JIRA_EMAIL: security-team-accounts@rocket.chat + JIRA_PROJECT_ID: GJIT + UID_CUSTOMFIELD_ID: customfield_10059 + JIRA_COMPLETE_PHASE_ID: 31 + JIRA_START_PHASE_ID: 11 + diff --git a/.yarn/patches/@storybook-react-docgen-typescript-plugin-npm-1.0.2-canary.6.9d540b91e815f8fc2f8829189deb00553559ff63.0-b31cc57c40.patch b/.yarn/patches/@storybook-react-docgen-typescript-plugin-npm-1.0.2-canary.6.9d540b91e815f8fc2f8829189deb00553559ff63.0-b31cc57c40.patch new file mode 100644 index 000000000000..ca069ee350c0 --- /dev/null +++ b/.yarn/patches/@storybook-react-docgen-typescript-plugin-npm-1.0.2-canary.6.9d540b91e815f8fc2f8829189deb00553559ff63.0-b31cc57c40.patch @@ -0,0 +1,130 @@ +diff --git a/dist/generateDocgenCodeBlock.js b/dist/generateDocgenCodeBlock.js +index 0993ac13e4b2aae6d24cf408d6a585b4ddeb7337..1405896291288eb1322d6c42144afd3b4fbd1abf 100644 +--- a/dist/generateDocgenCodeBlock.js ++++ b/dist/generateDocgenCodeBlock.js +@@ -34,7 +34,7 @@ function insertTsIgnoreBeforeStatement(statement) { + * ``` + */ + function setDisplayName(d) { +- return insertTsIgnoreBeforeStatement(typescript_1.default.createExpressionStatement(typescript_1.default.createBinary(typescript_1.default.createPropertyAccess(typescript_1.default.createIdentifier(d.displayName), typescript_1.default.createIdentifier("displayName")), typescript_1.default.SyntaxKind.EqualsToken, typescript_1.default.createLiteral(d.displayName)))); ++ return insertTsIgnoreBeforeStatement(typescript_1.default.factory.createExpressionStatement(typescript_1.default.factory.createBinaryExpression(typescript_1.default.factory.createPropertyAccessExpression(typescript_1.default.factory.createIdentifier(d.displayName), typescript_1.default.factory.createIdentifier("displayName")), typescript_1.default.SyntaxKind.EqualsToken, typescript_1.default.factory.createStringLiteral(d.displayName)))); + } + /** + * Set a component prop description. +@@ -65,7 +65,7 @@ function createPropDefinition(propName, prop, options) { + * + * @param defaultValue Default prop value or null if not set. + */ +- const setDefaultValue = (defaultValue) => typescript_1.default.createPropertyAssignment(typescript_1.default.createLiteral("defaultValue"), ++ const setDefaultValue = (defaultValue) => typescript_1.default.factory.createPropertyAssignment(typescript_1.default.factory.createStringLiteral("defaultValue"), + // Use a more extensive check on defaultValue. Sometimes the parser + // returns an empty object. + defaultValue !== null && +@@ -75,12 +75,19 @@ function createPropDefinition(propName, prop, options) { + (typeof defaultValue.value === "string" || + typeof defaultValue.value === "number" || + typeof defaultValue.value === "boolean") +- ? typescript_1.default.createObjectLiteral([ +- typescript_1.default.createPropertyAssignment(typescript_1.default.createIdentifier("value"), typescript_1.default.createLiteral(defaultValue.value)), ++ ? typescript_1.default.factory.createObjectLiteralExpression([ ++ typescript_1.default.factory.createPropertyAssignment(typescript_1.default.factory.createIdentifier("value"), typeof defaultValue.value === "string" ++ ? typescript_1.default.factory.createStringLiteral(defaultValue.value) ++ : // eslint-disable-next-line no-nested-ternary ++ typeof defaultValue.value === "number" ++ ? typescript_1.default.factory.createNumericLiteral(defaultValue.value) ++ : defaultValue.value ++ ? typescript_1.default.factory.createTrue() ++ : typescript_1.default.factory.createFalse()), + ]) +- : typescript_1.default.createNull()); ++ : typescript_1.default.factory.createNull()); + /** Set a property with a string value */ +- const setStringLiteralField = (fieldName, fieldValue) => typescript_1.default.createPropertyAssignment(typescript_1.default.createLiteral(fieldName), typescript_1.default.createLiteral(fieldValue)); ++ const setStringLiteralField = (fieldName, fieldValue) => typescript_1.default.factory.createPropertyAssignment(typescript_1.default.factory.createStringLiteral(fieldName), typescript_1.default.factory.createStringLiteral(fieldValue)); + /** + * ``` + * SimpleComponent.__docgenInfo.props.someProp.description = "Prop description."; +@@ -101,7 +108,7 @@ function createPropDefinition(propName, prop, options) { + * ``` + * @param required Whether prop is required or not. + */ +- const setRequired = (required) => typescript_1.default.createPropertyAssignment(typescript_1.default.createLiteral("required"), required ? typescript_1.default.createTrue() : typescript_1.default.createFalse()); ++ const setRequired = (required) => typescript_1.default.factory.createPropertyAssignment(typescript_1.default.factory.createStringLiteral("required"), required ? typescript_1.default.factory.createTrue() : typescript_1.default.factory.createFalse()); + /** + * ``` + * SimpleComponent.__docgenInfo.props.someProp.type = { +@@ -113,7 +120,7 @@ function createPropDefinition(propName, prop, options) { + */ + const setValue = (typeValue) => Array.isArray(typeValue) && + typeValue.every((value) => typeof value.value === "string") +- ? typescript_1.default.createPropertyAssignment(typescript_1.default.createLiteral("value"), typescript_1.default.createArrayLiteral(typeValue.map((value) => typescript_1.default.createObjectLiteral([ ++ ? typescript_1.default.factory.createPropertyAssignment(typescript_1.default.factory.createStringLiteral("value"), typescript_1.default.factory.createArrayLiteralExpression(typeValue.map((value) => typescript_1.default.factory.createObjectLiteralExpression([ + setStringLiteralField("value", value.value), + ])))) + : undefined; +@@ -130,9 +137,9 @@ function createPropDefinition(propName, prop, options) { + if (valueField) { + objectFields.push(valueField); + } +- return typescript_1.default.createPropertyAssignment(typescript_1.default.createLiteral(options.typePropName), typescript_1.default.createObjectLiteral(objectFields)); ++ return typescript_1.default.factory.createPropertyAssignment(typescript_1.default.factory.createStringLiteral(options.typePropName), typescript_1.default.factory.createObjectLiteralExpression(objectFields)); + }; +- return typescript_1.default.createPropertyAssignment(typescript_1.default.createLiteral(propName), typescript_1.default.createObjectLiteral([ ++ return typescript_1.default.factory.createPropertyAssignment(typescript_1.default.factory.createStringLiteral(propName), typescript_1.default.factory.createObjectLiteralExpression([ + setDefaultValue(prop.defaultValue), + setDescription(prop.description), + setName(prop.name), +@@ -158,10 +165,10 @@ function createPropDefinition(propName, prop, options) { + * @param relativeFilename Relative file path of the component source file. + */ + function insertDocgenIntoGlobalCollection(d, docgenCollectionName, relativeFilename) { +- return insertTsIgnoreBeforeStatement(typescript_1.default.createIf(typescript_1.default.createBinary(typescript_1.default.createTypeOf(typescript_1.default.createIdentifier(docgenCollectionName)), typescript_1.default.SyntaxKind.ExclamationEqualsEqualsToken, typescript_1.default.createLiteral("undefined")), insertTsIgnoreBeforeStatement(typescript_1.default.createStatement(typescript_1.default.createBinary(typescript_1.default.createElementAccess(typescript_1.default.createIdentifier(docgenCollectionName), typescript_1.default.createLiteral(`${relativeFilename}#${d.displayName}`)), typescript_1.default.SyntaxKind.EqualsToken, typescript_1.default.createObjectLiteral([ +- typescript_1.default.createPropertyAssignment(typescript_1.default.createIdentifier("docgenInfo"), typescript_1.default.createPropertyAccess(typescript_1.default.createIdentifier(d.displayName), typescript_1.default.createIdentifier("__docgenInfo"))), +- typescript_1.default.createPropertyAssignment(typescript_1.default.createIdentifier("name"), typescript_1.default.createLiteral(d.displayName)), +- typescript_1.default.createPropertyAssignment(typescript_1.default.createIdentifier("path"), typescript_1.default.createLiteral(`${relativeFilename}#${d.displayName}`)), ++ return insertTsIgnoreBeforeStatement(typescript_1.default.factory.createIfStatement(typescript_1.default.factory.createBinaryExpression(typescript_1.default.factory.createTypeOfExpression(typescript_1.default.factory.createIdentifier(docgenCollectionName)), typescript_1.default.SyntaxKind.ExclamationEqualsEqualsToken, typescript_1.default.factory.createStringLiteral("undefined")), insertTsIgnoreBeforeStatement(typescript_1.default.factory.createExpressionStatement(typescript_1.default.factory.createBinaryExpression(typescript_1.default.factory.createElementAccessExpression(typescript_1.default.factory.createIdentifier(docgenCollectionName), typescript_1.default.factory.createStringLiteral(`${relativeFilename}#${d.displayName}`)), typescript_1.default.SyntaxKind.EqualsToken, typescript_1.default.factory.createObjectLiteralExpression([ ++ typescript_1.default.factory.createPropertyAssignment(typescript_1.default.factory.createIdentifier("docgenInfo"), typescript_1.default.factory.createPropertyAccessExpression(typescript_1.default.factory.createIdentifier(d.displayName), typescript_1.default.factory.createIdentifier("__docgenInfo"))), ++ typescript_1.default.factory.createPropertyAssignment(typescript_1.default.factory.createIdentifier("name"), typescript_1.default.factory.createStringLiteral(d.displayName)), ++ typescript_1.default.factory.createPropertyAssignment(typescript_1.default.factory.createIdentifier("path"), typescript_1.default.factory.createStringLiteral(`${relativeFilename}#${d.displayName}`)), + ])))))); + } + /** +@@ -180,15 +187,15 @@ function insertDocgenIntoGlobalCollection(d, docgenCollectionName, relativeFilen + * @param options Generator options. + */ + function setComponentDocGen(d, options) { +- return insertTsIgnoreBeforeStatement(typescript_1.default.createStatement(typescript_1.default.createBinary( ++ return insertTsIgnoreBeforeStatement(typescript_1.default.factory.createExpressionStatement(typescript_1.default.factory.createBinaryExpression( + // SimpleComponent.__docgenInfo +- typescript_1.default.createPropertyAccess(typescript_1.default.createIdentifier(d.displayName), typescript_1.default.createIdentifier("__docgenInfo")), typescript_1.default.SyntaxKind.EqualsToken, typescript_1.default.createObjectLiteral([ ++ typescript_1.default.factory.createPropertyAccessExpression(typescript_1.default.factory.createIdentifier(d.displayName), typescript_1.default.factory.createIdentifier("__docgenInfo")), typescript_1.default.SyntaxKind.EqualsToken, typescript_1.default.factory.createObjectLiteralExpression([ + // SimpleComponent.__docgenInfo.description +- typescript_1.default.createPropertyAssignment(typescript_1.default.createLiteral("description"), typescript_1.default.createLiteral(d.description)), ++ typescript_1.default.factory.createPropertyAssignment(typescript_1.default.factory.createStringLiteral("description"), typescript_1.default.factory.createStringLiteral(d.description)), + // SimpleComponent.__docgenInfo.displayName +- typescript_1.default.createPropertyAssignment(typescript_1.default.createLiteral("displayName"), typescript_1.default.createLiteral(d.displayName)), ++ typescript_1.default.factory.createPropertyAssignment(typescript_1.default.factory.createStringLiteral("displayName"), typescript_1.default.factory.createStringLiteral(d.displayName)), + // SimpleComponent.__docgenInfo.props +- typescript_1.default.createPropertyAssignment(typescript_1.default.createLiteral("props"), typescript_1.default.createObjectLiteral(Object.entries(d.props).map(([propName, prop]) => createPropDefinition(propName, prop, options)))), ++ typescript_1.default.factory.createPropertyAssignment(typescript_1.default.factory.createStringLiteral("props"), typescript_1.default.factory.createObjectLiteralExpression(Object.entries(d.props).map(([propName, prop]) => createPropDefinition(propName, prop, options)))), + ])))); + } + function generateDocgenCodeBlock(options) { +@@ -196,7 +203,7 @@ function generateDocgenCodeBlock(options) { + const relativeFilename = path_1.default + .relative("./", path_1.default.resolve("./", options.filename)) + .replace(/\\/g, "/"); +- const wrapInTryStatement = (statements) => typescript_1.default.createTry(typescript_1.default.createBlock(statements, true), typescript_1.default.createCatchClause(typescript_1.default.createVariableDeclaration(typescript_1.default.createIdentifier("__react_docgen_typescript_loader_error")), typescript_1.default.createBlock([])), undefined); ++ const wrapInTryStatement = (statements) => typescript_1.default.factory.createTryStatement(typescript_1.default.factory.createBlock(statements, true), typescript_1.default.factory.createCatchClause(typescript_1.default.factory.createVariableDeclaration(typescript_1.default.factory.createIdentifier("__react_docgen_typescript_loader_error")), typescript_1.default.factory.createBlock([])), undefined); + const codeBlocks = options.componentDocs.map((d) => wrapInTryStatement([ + options.setDisplayName ? setDisplayName(d) : null, + setComponentDocGen(d, options), +@@ -208,7 +215,7 @@ function generateDocgenCodeBlock(options) { + const printer = typescript_1.default.createPrinter({ newLine: typescript_1.default.NewLineKind.LineFeed }); + const printNode = (sourceNode) => printer.printNode(typescript_1.default.EmitHint.Unspecified, sourceNode, sourceFile); + // Concat original source code with code from generated code blocks. +- const result = codeBlocks.reduce((acc, node) => `${acc}\n${printNode(node)}`, ++ const result = codeBlocks.reduce((acc, node) => `${acc}\n${printNode(node)}`, + // Use original source text rather than using printNode on the parsed form + // to prevent issue where literals are stripped within components. + // Ref: https://github.com/strothj/react-docgen-typescript-loader/issues/7 diff --git a/.yarn/patches/mongodb-npm-4.17.1-a2fe811ff1.patch b/.yarn/patches/mongodb-npm-4.17.1-a2fe811ff1.patch new file mode 100644 index 000000000000..501881370244 --- /dev/null +++ b/.yarn/patches/mongodb-npm-4.17.1-a2fe811ff1.patch @@ -0,0 +1,13 @@ +diff --git a/mongodb.d.ts b/mongodb.d.ts +index dd080b553309594c28093365ea101adec5c0a20c..20a616de8c97ec68629c01a848ea8df4fe122bf2 100644 +--- a/mongodb.d.ts ++++ b/mongodb.d.ts +@@ -5539,7 +5539,7 @@ export declare interface MonitorOptions extends Omit = Depth['length'] extends 8 ? [] : Type extends string | number | boolean | Date | RegExp | Buffer | Uint8Array | ((...args: any[]) => any) | { ++export declare type NestedPaths = Depth['length'] extends 1 ? [] : Type extends string | number | boolean | Date | RegExp | Buffer | Uint8Array | ((...args: any[]) => any) | { + _bsontype: string; + } ? [] : Type extends ReadonlyArray ? [] | [number, ...NestedPaths] : Type extends Map ? [string] : Type extends object ? { + [Key in Extract]: Type[Key] extends Type ? [Key] : Type extends Type[Key] ? [Key] : Type[Key] extends ReadonlyArray ? Type extends ArrayType ? [Key] : ArrayType extends Type ? [Key] : [ diff --git a/_templates/package/new/package.json.ejs.t b/_templates/package/new/package.json.ejs.t index 1239ec821348..950e5cb2bf62 100644 --- a/_templates/package/new/package.json.ejs.t +++ b/_templates/package/new/package.json.ejs.t @@ -7,11 +7,11 @@ to: packages/<%= name %>/package.json "version": "0.0.1", "private": true, "devDependencies": { - "@types/jest": "^27.4.1", + "@types/jest": "~29.5.3", "eslint": "~8.45.0", - "jest": "~29.5.0", + "jest": "~29.6.1", "ts-jest": "~29.0.5", - "typescript": "~5.0.2" + "typescript": "~5.1.6" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/_templates/service/new/package.json.ejs.t b/_templates/service/new/package.json.ejs.t index ca4724de539c..2c74278d1ced 100644 --- a/_templates/service/new/package.json.ejs.t +++ b/_templates/service/new/package.json.ejs.t @@ -20,29 +20,29 @@ to: ee/apps/<%= name %>/package.json "dependencies": { "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/emitter": "0.31.22", + "@rocket.chat/emitter": "next", "@rocket.chat/model-typings": "workspace:^", "@rocket.chat/models": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", - "@rocket.chat/string-helpers": "0.31.22", - "@types/node": "^14.18.21", - "ejson": "^2.2.2", + "@rocket.chat/string-helpers": "next", + "@types/node": "^14.18.51", + "ejson": "^2.2.3", "eventemitter3": "^4.0.7", "fibers": "^5.0.3", "mem": "^8.1.1", - "moleculer": "^0.14.21", + "moleculer": "^0.14.29", "mongodb": "^4.12.1", "nats": "^2.4.0", - "pino": "^8.4.2", + "pino": "^8.15.0", "polka": "^0.5.2" }, "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", - "@types/eslint": "^8.4.10", + "@types/eslint": "~8.44.0", "@types/polka": "^0.5.4", "eslint": "~8.45.0", "ts-node": "^10.9.1", - "typescript": "~4.6.4" + "typescript": "~5.1.6" }, "main": "./dist/ee/apps/<%= name %>/src/service.js", "files": [ diff --git a/apps/meteor/.babelrc b/apps/meteor/.babelrc index a8c20b400ca5..382b93318fab 100644 --- a/apps/meteor/.babelrc +++ b/apps/meteor/.babelrc @@ -1,9 +1,15 @@ { - "presets": [ - "@babel/preset-env", - "@babel/preset-react" - ], - "plugins": [ - "babel-plugin-istanbul" - ] + "presets": ["@babel/preset-env", "@babel/preset-react"], + "env": { + "coverage": { + "plugins": [ + [ + "istanbul", + { + "exclude": ["**/*.spec.js", "**/*.test.js"] + } + ] + ] + } + } } diff --git a/apps/meteor/.docker/Dockerfile.alpine b/apps/meteor/.docker/Dockerfile.alpine index 62a0476d9077..003baa57aa8b 100644 --- a/apps/meteor/.docker/Dockerfile.alpine +++ b/apps/meteor/.docker/Dockerfile.alpine @@ -15,6 +15,11 @@ RUN set -x \ && npm install sharp@0.30.4 \ && mv node_modules/sharp npm/node_modules/sharp \ # End hack for sharp + # Start hack for isolated-vm... + && rm -rf npm/node_modules/isolated-vm \ + && npm install isolated-vm@4.4.2 \ + && mv node_modules/isolated-vm npm/node_modules/isolated-vm \ + # End hack for isolated-vm && cd npm \ && npm rebuild bcrypt --build-from-source \ && npm cache clear --force \ diff --git a/apps/meteor/.docker/Dockerfile.rhel b/apps/meteor/.docker/Dockerfile.rhel index 9f03ae9f3db4..1481b0445e45 100644 --- a/apps/meteor/.docker/Dockerfile.rhel +++ b/apps/meteor/.docker/Dockerfile.rhel @@ -1,6 +1,6 @@ FROM registry.access.redhat.com/ubi8/nodejs-12 -ENV RC_VERSION 6.4.0-develop +ENV RC_VERSION 6.5.0-develop MAINTAINER buildmaster@rocket.chat diff --git a/apps/meteor/.gitignore b/apps/meteor/.gitignore index 287cb313c174..a9fd54ab8711 100644 --- a/apps/meteor/.gitignore +++ b/apps/meteor/.gitignore @@ -87,4 +87,5 @@ out.txt dist *-session.json matrix-federation-config/* -.eslintcache \ No newline at end of file +.eslintcache +tsconfig.typecheck.tsbuildinfo diff --git a/apps/meteor/.meteor/packages b/apps/meteor/.meteor/packages index 97836558ab28..ae788af78034 100644 --- a/apps/meteor/.meteor/packages +++ b/apps/meteor/.meteor/packages @@ -25,7 +25,7 @@ accounts-password@2.3.4 accounts-twitter@1.5.0 pauli:accounts-linkedin -google-oauth@1.4.3 +google-oauth@1.4.4 oauth@2.2.0 oauth2@1.3.2 @@ -39,7 +39,7 @@ meteor-base@1.5.1 ddp-common@1.4.0 webapp@1.13.5 -mongo@1.16.6 +mongo@1.16.7 reload@1.3.1 service-configuration@1.3.1 diff --git a/apps/meteor/.meteor/release b/apps/meteor/.meteor/release index e8cfc7ec4c01..6641d0478a10 100644 --- a/apps/meteor/.meteor/release +++ b/apps/meteor/.meteor/release @@ -1 +1 @@ -METEOR@2.12 +METEOR@2.13.3 diff --git a/apps/meteor/.meteor/versions b/apps/meteor/.meteor/versions index da6de9efbde1..66f61e2cd8cc 100644 --- a/apps/meteor/.meteor/versions +++ b/apps/meteor/.meteor/versions @@ -22,7 +22,7 @@ ddp@1.4.1 ddp-client@2.6.1 ddp-common@1.4.0 ddp-rate-limiter@1.2.0 -ddp-server@2.6.1 +ddp-server@2.6.2 diff-sequence@1.1.2 dispatch:run-as-user@1.1.1 dynamic-import@0.7.3 @@ -38,7 +38,7 @@ facts-base@1.0.1 fetch@0.1.3 geojson-utils@1.0.11 github-oauth@1.4.1 -google-oauth@1.4.3 +google-oauth@1.4.4 hot-code-push@1.0.4 http@2.0.0 id-map@1.1.1 @@ -47,7 +47,7 @@ jquery@3.0.0 kadira:flow-router@2.12.1 localstorage@1.2.0 logging@1.3.2 -meteor@1.11.2 +meteor@1.11.3 meteor-base@1.5.1 meteor-developer-oauth@1.3.2 meteorhacks:inject-initial@1.0.5 @@ -56,7 +56,7 @@ minimongo@1.9.3 modern-browsers@0.1.9 modules@0.19.0 modules-runtime@0.13.1 -mongo@1.16.6 +mongo@1.16.7 mongo-decimal@0.1.3 mongo-dev-server@1.1.0 mongo-id@1.0.8 @@ -92,7 +92,7 @@ shell-server@0.5.0 socket-stream-client@0.5.1 standard-minifier-css@1.9.2 tracker@1.3.2 -twitter-oauth@1.3.2 +twitter-oauth@1.3.3 typescript@4.9.4 underscore@1.0.13 url@1.3.2 diff --git a/apps/meteor/.storybook/decorators.tsx b/apps/meteor/.storybook/decorators.tsx index a295c4ab60b8..f0211331e4bc 100644 --- a/apps/meteor/.storybook/decorators.tsx +++ b/apps/meteor/.storybook/decorators.tsx @@ -1,3 +1,4 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; import type { DecoratorFunction } from '@storybook/addons'; import type { ReactElement } from 'react'; import React from 'react'; @@ -8,6 +9,8 @@ import RouterContextMock from '../client/stories/contexts/RouterContextMock'; import ServerContextMock from '../client/stories/contexts/ServerContextMock'; import TranslationContextMock from '../client/stories/contexts/TranslationContextMock'; +const MockedAppRoot = mockAppRoot().build(); + export const rocketChatDecorator: DecoratorFunction> = (fn, { parameters }) => { const linkElement = document.getElementById('theme-styles') || document.createElement('link'); if (linkElement.id !== 'theme-styles') { @@ -25,7 +28,7 @@ export const rocketChatDecorator: DecoratorFunction> = (fn const { default: icons } = require('!!raw-loader!../private/public/icons.svg'); return ( - + @@ -41,6 +44,6 @@ export const rocketChatDecorator: DecoratorFunction> = (fn - + ); }; diff --git a/apps/meteor/.storybook/main.js b/apps/meteor/.storybook/main.js index 223c31d6bb11..0e0b6db7c0e9 100644 --- a/apps/meteor/.storybook/main.js +++ b/apps/meteor/.storybook/main.js @@ -25,6 +25,20 @@ module.exports = { reactDocgen: 'react-docgen-typescript-plugin', }, webpackFinal: async (config) => { + // Those aliases are needed because dependencies in the monorepo use another + // dependencies that are not hoisted on this workspace + config.resolve.alias = { + 'react$': require.resolve('../../../node_modules/react'), + // 'react/jsx-runtime': require.resolve('../../../node_modules/react/jsx-runtime'), + '@tanstack/react-query': require.resolve('../../../node_modules/@tanstack/react-query'), + }; + + config.module.rules.push({ + test: /\.mjs$/, + include: /node_modules/, + type: 'javascript/auto', + }); + const cssRule = config.module.rules.find(({ test }) => test.test('index.css')); cssRule.use[2].options = { diff --git a/apps/meteor/.storybook/preview.ts b/apps/meteor/.storybook/preview.ts index 2b1ed2526995..109a66ee882c 100644 --- a/apps/meteor/.storybook/preview.ts +++ b/apps/meteor/.storybook/preview.ts @@ -1,11 +1,12 @@ -import { DocsPage, DocsContainer } from '@storybook/addon-docs'; -import { addDecorator, addParameters } from '@storybook/react'; +import { DecoratorFn, Parameters } from '@storybook/react'; import { rocketChatDecorator } from './decorators'; -addDecorator(rocketChatDecorator); +export const decorators: DecoratorFn[] = [ + rocketChatDecorator +]; -addParameters({ +export const parameters: Parameters = { backgrounds: { grid: { cellSize: 4, @@ -13,14 +14,10 @@ addParameters({ opacity: 0.5, }, }, - docs: { - container: DocsContainer, - page: DocsPage, - }, options: { storySort: { method: 'alphabetical', order: ['Components', '*', 'Enterprise'], }, }, -}); +}; diff --git a/apps/meteor/CHANGELOG.md b/apps/meteor/CHANGELOG.md index f8a1a24f8430..9c2e0b63e240 100644 --- a/apps/meteor/CHANGELOG.md +++ b/apps/meteor/CHANGELOG.md @@ -1,5 +1,717 @@ # @rocket.chat/meteor +## 6.4.0 + +### Minor Changes + +- 239a34e877: new: ring mobile users on direct conference calls +- 04fe492555: Added new Omnichannel's trigger condition "After starting a chat". +- 4186eecf05: Introduce the ability to report an user +- 92b690d206: fix: Wrong toast message while creating a new custom sound with an existing name +- f83ea5d6e8: Added support for threaded conversation in Federated rooms. +- 682d0bc05a: fix: Time format of Retention Policy +- 1b42dfc6c1: Added a new Roles bridge to RC Apps-Engine for reading and retrieving role details. +- 2db32f0d4a: Add option to select what URL previews should be generated for each message. +- 982ef6f459: Add new event to notify users directly about new banners +- 19aec23cda: New AddUser workflow for Federated Rooms +- ebab8c4dd8: Added Reports Metrics Dashboard to Omnichannel +- 85a936220c: feat: remove enforce password fallback dependency +- 5832be2e1b: Reorganized the message menu +- 074db3b419: UX improvement for the Moderation Console Context bar for viewing the reported messages. The Report reason is now displayed in the reported messages context bar. + The Moderation Action Modal confirmation description is updated to be more clear and concise. +- 357a3a50fa: feat: high-contrast theme +- 7070f00b05: feat: return all broken password policies at once +- ead7c7bef2: Fixed read receipts not getting deleted after corresponding message is deleted +- 1041d4d361: Added option to select between two script engine options for the integrations +- ad08c26b46: Introduced upsells for the engagement dashboard and device management admin sidebar items in CE workspaces. Additionally, restructured the admin sidebar items to enhance organization. +- 93d4912e17: fix: missing params on updateOwnBasicInfo endpoint +- ee3815fce4: feat: add ChangePassword field to Account/Security +- 1000b9b317: Fixed the issue of apps icon uneven alignment in case of missing icons inside message composer toolbar & message toolbar menu. + +### Patch Changes + +- 6d453f71ac: Translation files are requested multiple times +- cada29b6ce: fix: Managers allowed to make deactivated agent's available +- 470c29d7e9: Fixed an issue causing `queue time` to be calculated from current time when a room was closed without being served. + Now: + - For served rooms: queue time = servedBy time - queuedAt + - For not served, but open rooms = now - queuedAt + - For not served and closed rooms = closedAt - queuedAt +- ea8998602b: fix: Performance issue on `Messages.countByType` aggregation caused by unindexed property on messages collection +- f634601d90: Bump @rocket.chat/meteor version. +- f46c1f7b70: Bump @rocket.chat/meteor version. +- 6963cc2d00: Bump @rocket.chat/meteor version. +- 7cc15ac814: Bump @rocket.chat/meteor version. +- 40c5277197: Bump @rocket.chat/meteor version. +- Bump @rocket.chat/meteor version. +- a08006c9f0: feat: add sections to room header and user infos menus with menuV2 +- 203304782f: Fixed `overrideDestinationChannelEnabled` treated as a required param in `integrations.create` and `integration.update` endpoints +- 9edca67b9b: feat(apps): `ActionManagerBusyState` component for apps `ui.interaction` +- ec60dbe8f5: Fixed custom translations not being displayed +- 6fa30ddcd1: Hide Reset TOTP option if 2FA is disabled +- ff7e181464: Added ability to freeze or completely disable integration scripts through envvars +- 4ce8ea89a8: fix: custom emoji upload with FileSystem method +- 87570d0fb7: New filters to the Rooms Table at `Workspace > Rooms` +- 8a59855fcf: When setting a room as read-only, do not allow previously unmuted users to send messages. +- c73f5373b8: fix: finnish translation +- f5a886a144: fixed an issue where 2fa was not working after an OAuth redirect +- 459c8574ed: Fixed issue with custom OAuth services' settings not being be fully removed +- 42644a6e44: fix: Prevent `RoomProvider.useEffect` from subscribing to room-data stream multiple times +- 9bdbc9b086: load sounds right before playing them +- 6154979119: Fix users being created without the `roles` field +- 6bcdd88531: Fixed CAS login after popup closes +- 839789c988: Fix moment timestamps language change +- f0025d4d92: Fixed message fetching method in LivechatBridge for Apps +- 9c957b9d9a: Fix pruning messages in a room results in an incorrect message counter +- 583a3149fe: fix: rejected conference calls continue to ring +- b59fd5d7fb: User information crashing for some locales +- 4349443629: Fix performance issue on Engagement Dashboard aggregation +- 614a9b8fc8: Show correct date for last day time +- 69447e1864: Added ability to disable private app installation via envvar (DISABLE_PRIVATE_APP_INSTALLATION) +- 52a1aa94eb: improve: System messages for omni-visitor abandonment feature +- 7dffec2e2f: chore: Add danger variant to apps action button menus +- f0c8867bb9: Disabled call to tags enterprise endpoint when on community license +- 5e89694bfa: Fixes SAML full name updates not being mirrored to DM rooms. +- d6f0c6afe2: Fixed Importer Progress Bar progress indicator +- 177506ea91: Make user default role setting public +- 3fb2124166: Fixed misleading of 'total' in team members list inside Channel +- 5cee21468e: Fix spotlight search does not find rooms with special or non-latin characters +- cf59c8abe3: Fix engagement dashboard not showing data +- dfb9a075b3: fixed wrong user status displayed during mentioning a user in a channel +- 1fbbb6241a: Don't allow to report self messages +- 53e0c346e2: fixed scrollbar over content in Federated Room List +- 5321e87363: Fix seat counter including bots users +- 7137a193a7: feat: Add flag to disable teams mention via troubleshoot page +- 59e6fe3d2a: fixed layout changing from embedded view when navigating +- 3245a0a318: Fix LinkedIn OAuth broken +- 45a8943ed4: Removed old/deprecated Rocket.Chat Federation card from Info page +- 6eea189ec8: Fix the code that was setting email URL to an invalid value when SMTP was not set +- f5a886a144: fixed an issue where oauth login was not working with some providers +- ba24f3c21f: Fixed `default` field not being returned from the `setDefault` endpoints when setting to false +- a79f61461d: Fixed an issue where timeout for http requests in Apps-Engine bridges was too short +- 51b988b3df: Fix importer filters not working +- 5d857f462c: fix: stop blinking "Room not found" before dm creation +- db26f8a8ee: fixed an issue with the positioning of the message menu +- aaefe865a7: fix: agent role being removed upon user deactivation +- 306a5830c3: Fix `mention-here` and `mention-all` permissions not being honored +- 761cad4382: Fix CORS headers not being set for assets +- 9e5718002a: Fixed Slackbridge was not handling correctly received events from Slack anymore. Events: (Send, edit, delete, react meassages) +- 54ef89c9a7: fix: show requested filters only on requested apps view +- 1589279b79: Fix users not able to login after block time perdiod has passed +- 880ab5689c: Fixed selected departments not being displayed due to pagination +- a81bad24e0: Fixed Apps-Engine event `IPostUserCreated` execution +- 7a4fdf41f8: Fix validation in app status call that allowed Enterprise apps to be enabled in invalid environments +- e28f8d95f0: Fixed inviter not informed when inviting member to room via `/invite` slashcommand +- d47d2021ac: Fixed "teams" icon not being displayed on spotlight sidebar search +- 93d5a5ceb8: fix: User timezone not being respected on Current Chat's filter +- f556518fa1: Change SAU aggregation to consider only sessions from few days ago instead of the whole past. + + This is particularly important for large workspaces in case the cron job did not run for some time, in that case the amount of sessions would accumulate and the aggregation would take a long time to run. + +- b747f3d3bc: Fixed unable to create admin user using ADMIN\_\* environment variables +- 2cf2643399: Fixed failing user data exports +- 61a106fbf2: Increase cron job check delay to 1 min from 5s. + + This reduces MongoDB requests introduced on 6.3. + +- ace35997a6: chore: Increase cache time from 5s to 10s on `getUnits` helpers. This should reduce the number of DB calls made by this method to fetch the unit limitations for a user. +- f5a886a144: fixed an issue on oauth login that caused missing emails to be detected as changed data +- 61128364d6: Fixes a problem where the calculated time for considering the visitor abandonment was the first message from the visitor and not the visitor's reply to the agent. +- 9496f1eb97: Deprecate `livechat:getOverviewData` and `livechat:getAgentOverviewData` methods and create API endpoints `livechat/analytics/overview` and `livechat/analytics/agent-overview` to fetch analytics data +- 01dec055a0: Fixed Accounts profile form name change was not working +- e4837a15ed: Fixed user mentioning when prepending the username with `>` +- d45365436e: Use group filter when set to LDAP sync process +- c536a4a237: fix: Missing padding on Omnichannel contacts Contextualbar loading state +- 87e4a4aa56: Fixes a problem that allowed users to send empty spaces as comment to bypass the "comment required" setting +- 69a5213afc: Fixed an issue where a mailer error was being sent to customers using offline message's form on Omnichannel instead of the translated one +- b8f3d5014f: Fixed the login page language switcher, now the component has a new look, is reactive and the language selection becomes concrete upon login in. Also changed the default language of the login page to be the browser language. +- 22cf158c43: fixed the unread messages mark not showing +- 72a34a02f7: fixed the video recorder window not closing after permission is denied. +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [1246a21648] +- Updated dependencies [4186eecf05] +- Updated dependencies [8a59855fcf] +- Updated dependencies [f9a748526d] +- Updated dependencies [5cee21468e] +- Updated dependencies [dc1d8ce92e] +- Updated dependencies [2db32f0d4a] +- Updated dependencies [982ef6f459] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [19aec23cda] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [aaefe865a7] +- Updated dependencies [074db3b419] +- Updated dependencies [357a3a50fa] +- Updated dependencies [f556518fa1] +- Updated dependencies [d9a150000d] +- Updated dependencies [ead7c7bef2] +- Updated dependencies [1041d4d361] +- Updated dependencies [61a106fbf2] +- Updated dependencies [61128364d6] +- Updated dependencies [9496f1eb97] +- Updated dependencies [dce4a829fa] +- Updated dependencies [d45365436e] +- Updated dependencies [b8f3d5014f] +- Updated dependencies [93d4912e17] +- Updated dependencies [ee3815fce4] + - @rocket.chat/core-typings@6.4.0 + - @rocket.chat/rest-typings@6.4.0 + - @rocket.chat/fuselage-ui-kit@2.0.0 + - @rocket.chat/model-typings@0.1.0 + - @rocket.chat/core-services@0.2.0 + - @rocket.chat/ui-client@2.0.0 + - @rocket.chat/ui-contexts@2.0.0 + - @rocket.chat/ui-theming@0.1.0 + - @rocket.chat/presence@0.0.15 + - @rocket.chat/tools@0.1.0 + - @rocket.chat/cron@0.0.11 + - @rocket.chat/i18n@0.0.2 + - @rocket.chat/web-ui-registration@2.0.0 + - @rocket.chat/api-client@0.1.9 + - @rocket.chat/omnichannel-services@0.0.15 + - @rocket.chat/pdf-worker@0.0.15 + - @rocket.chat/gazzodown@2.0.0 + - @rocket.chat/models@0.0.15 + - @rocket.chat/ui-video-conf@2.0.0 + - @rocket.chat/base64@1.0.12 + - @rocket.chat/instance-status@0.0.15 + - @rocket.chat/random@1.2.1 + - @rocket.chat/sha256@1.0.9 + - @rocket.chat/ui-composer@0.0.1 + +## 6.4.0-rc.5 + +### Minor Changes + +- 1041d4d361: Added option to select between two script engine options for the integrations + +### Patch Changes + +- Bump @rocket.chat/meteor version. +- ec60dbe8f5: Fixed custom translations not being displayed +- Updated dependencies [1041d4d361] + - @rocket.chat/core-typings@6.4.0-rc.5 + - @rocket.chat/rest-typings@6.4.0-rc.5 + - @rocket.chat/tools@0.1.0-rc.0 + - @rocket.chat/api-client@0.1.9-rc.5 + - @rocket.chat/omnichannel-services@0.0.15-rc.5 + - @rocket.chat/pdf-worker@0.0.15-rc.5 + - @rocket.chat/presence@0.0.15-rc.5 + - @rocket.chat/core-services@0.2.0-rc.5 + - @rocket.chat/cron@0.0.11-rc.5 + - @rocket.chat/gazzodown@2.0.0-rc.5 + - @rocket.chat/model-typings@0.1.0-rc.5 + - @rocket.chat/ui-contexts@2.0.0-rc.5 + - @rocket.chat/fuselage-ui-kit@2.0.0-rc.5 + - @rocket.chat/models@0.0.15-rc.5 + - @rocket.chat/ui-theming@0.1.0-rc.0 + - @rocket.chat/ui-client@2.0.0-rc.5 + - @rocket.chat/ui-video-conf@2.0.0-rc.5 + - @rocket.chat/web-ui-registration@2.0.0-rc.5 + - @rocket.chat/instance-status@0.0.15-rc.5 + +## 6.4.0-rc.4 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + - @rocket.chat/core-typings@6.4.0-rc.4 + - @rocket.chat/rest-typings@6.4.0-rc.4 + - @rocket.chat/api-client@0.1.8-rc.4 + - @rocket.chat/omnichannel-services@0.0.14-rc.4 + - @rocket.chat/pdf-worker@0.0.14-rc.4 + - @rocket.chat/presence@0.0.14-rc.4 + - @rocket.chat/core-services@0.2.0-rc.4 + - @rocket.chat/cron@0.0.10-rc.4 + - @rocket.chat/gazzodown@2.0.0-rc.4 + - @rocket.chat/model-typings@0.1.0-rc.4 + - @rocket.chat/ui-contexts@2.0.0-rc.4 + - @rocket.chat/fuselage-ui-kit@2.0.0-rc.4 + - @rocket.chat/models@0.0.14-rc.4 + - @rocket.chat/ui-theming@0.1.0-rc.0 + - @rocket.chat/ui-client@2.0.0-rc.4 + - @rocket.chat/ui-video-conf@2.0.0-rc.4 + - @rocket.chat/web-ui-registration@2.0.0-rc.4 + - @rocket.chat/instance-status@0.0.14-rc.4 + +## 6.4.0-rc.3 + +### Patch Changes + +- Bump @rocket.chat/meteor version. +- 614a9b8fc8: Show correct date for last day time +- 61a106fbf2: Increase cron job check delay to 1 min from 5s. + + This reduces MongoDB requests introduced on 6.3. + +- Updated dependencies [d9a150000d] +- Updated dependencies [61a106fbf2] + - @rocket.chat/presence@0.0.13-rc.3 + - @rocket.chat/cron@0.0.9-rc.3 + - @rocket.chat/core-typings@6.4.0-rc.3 + - @rocket.chat/rest-typings@6.4.0-rc.3 + - @rocket.chat/api-client@0.1.7-rc.3 + - @rocket.chat/omnichannel-services@0.0.13-rc.3 + - @rocket.chat/pdf-worker@0.0.13-rc.3 + - @rocket.chat/core-services@0.2.0-rc.3 + - @rocket.chat/gazzodown@2.0.0-rc.3 + - @rocket.chat/model-typings@0.1.0-rc.3 + - @rocket.chat/ui-contexts@2.0.0-rc.3 + - @rocket.chat/fuselage-ui-kit@2.0.0-rc.3 + - @rocket.chat/models@0.0.13-rc.3 + - @rocket.chat/ui-theming@0.1.0-rc.0 + - @rocket.chat/ui-client@2.0.0-rc.3 + - @rocket.chat/ui-video-conf@2.0.0-rc.3 + - @rocket.chat/web-ui-registration@2.0.0-rc.3 + - @rocket.chat/instance-status@0.0.13-rc.3 + +## 6.4.0-rc.2 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + - @rocket.chat/core-typings@6.4.0-rc.2 + - @rocket.chat/rest-typings@6.4.0-rc.2 + - @rocket.chat/api-client@0.1.7-rc.2 + - @rocket.chat/omnichannel-services@0.0.13-rc.2 + - @rocket.chat/pdf-worker@0.0.13-rc.2 + - @rocket.chat/presence@0.0.13-rc.2 + - @rocket.chat/core-services@0.2.0-rc.2 + - @rocket.chat/cron@0.0.9-rc.2 + - @rocket.chat/gazzodown@2.0.0-rc.2 + - @rocket.chat/model-typings@0.1.0-rc.2 + - @rocket.chat/ui-contexts@2.0.0-rc.2 + - @rocket.chat/fuselage-ui-kit@2.0.0-rc.2 + - @rocket.chat/models@0.0.13-rc.2 + - @rocket.chat/ui-theming@0.1.0-rc.0 + - @rocket.chat/ui-client@2.0.0-rc.2 + - @rocket.chat/ui-video-conf@2.0.0-rc.2 + - @rocket.chat/web-ui-registration@2.0.0-rc.2 + - @rocket.chat/instance-status@0.0.13-rc.2 + +## 6.4.0-rc.1 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + - @rocket.chat/core-typings@6.4.0-rc.1 + - @rocket.chat/rest-typings@6.4.0-rc.1 + - @rocket.chat/api-client@0.1.5-rc.1 + - @rocket.chat/omnichannel-services@0.0.11-rc.1 + - @rocket.chat/pdf-worker@0.0.11-rc.1 + - @rocket.chat/presence@0.0.11-rc.1 + - @rocket.chat/core-services@0.2.0-rc.1 + - @rocket.chat/cron@0.0.7-rc.1 + - @rocket.chat/gazzodown@2.0.0-rc.1 + - @rocket.chat/model-typings@0.1.0-rc.1 + - @rocket.chat/ui-contexts@2.0.0-rc.1 + - @rocket.chat/fuselage-ui-kit@2.0.0-rc.1 + - @rocket.chat/models@0.0.11-rc.1 + - @rocket.chat/ui-theming@0.1.0-rc.0 + - @rocket.chat/ui-client@2.0.0-rc.1 + - @rocket.chat/ui-video-conf@2.0.0-rc.1 + - @rocket.chat/web-ui-registration@2.0.0-rc.1 + - @rocket.chat/instance-status@0.0.11-rc.1 + +## 6.4.0-rc.0 + +### Minor Changes + +- 239a34e877: new: ring mobile users on direct conference calls +- 04fe492555: Added new Omnichannel's trigger condition "After starting a chat". +- 4186eecf05: Introduce the ability to report an user +- 92b690d206: fix: Wrong toast message while creating a new custom sound with an existing name +- f83ea5d6e8: Added support for threaded conversation in Federated rooms. +- 682d0bc05a: fix: Time format of Retention Policy +- 1b42dfc6c1: Added a new Roles bridge to RC Apps-Engine for reading and retrieving role details. +- 2db32f0d4a: Add option to select what URL previews should be generated for each message. +- 982ef6f459: Add new event to notify users directly about new banners +- 19aec23cda: New AddUser workflow for Federated Rooms +- ebab8c4dd8: Added Reports Metrics Dashboard to Omnichannel +- 85a936220c: feat: remove enforce password fallback dependency +- 5832be2e1b: Reorganized the message menu +- 074db3b419: UX improvement for the Moderation Console Context bar for viewing the reported messages. The Report reason is now displayed in the reported messages context bar. + The Moderation Action Modal confirmation description is updated to be more clear and concise. +- 357a3a50fa: feat: high-contrast theme +- 7070f00b05: feat: return all broken password policies at once +- ead7c7bef2: Fixed read receipts not getting deleted after corresponding message is deleted +- ad08c26b46: Introduced upsells for the engagement dashboard and device management admin sidebar items in CE workspaces. Additionally, restructured the admin sidebar items to enhance organization. +- 93d4912e17: fix: missing params on updateOwnBasicInfo endpoint +- ee3815fce4: feat: add ChangePassword field to Account/Security +- 1000b9b317: Fixed the issue of apps icon uneven alignment in case of missing icons inside message composer toolbar & message toolbar menu. + +### Patch Changes + +- 6d453f71ac: Translation files are requested multiple times +- cada29b6ce: fix: Managers allowed to make deactivated agent's available +- 470c29d7e9: Fixed an issue causing `queue time` to be calculated from current time when a room was closed without being served. + Now: + - For served rooms: queue time = servedBy time - queuedAt + - For not served, but open rooms = now - queuedAt + - For not served and closed rooms = closedAt - queuedAt +- ea8998602b: fix: Performance issue on `Messages.countByType` aggregation caused by unindexed property on messages collection +- a08006c9f0: feat: add sections to room header and user infos menus with menuV2 +- 203304782f: Fixed `overrideDestinationChannelEnabled` treated as a required param in `integrations.create` and `integration.update` endpoints +- 9edca67b9b: feat(apps): `ActionManagerBusyState` component for apps `ui.interaction` +- 6fa30ddcd1: Hide Reset TOTP option if 2FA is disabled +- ff7e181464: Added ability to freeze or completely disable integration scripts through envvars +- 4ce8ea89a8: fix: custom emoji upload with FileSystem method +- 87570d0fb7: New filters to the Rooms Table at `Workspace > Rooms` +- 8a59855fcf: When setting a room as read-only, do not allow previously unmuted users to send messages. +- c73f5373b8: fix: finnish translation +- f5a886a144: fixed an issue where 2fa was not working after an OAuth redirect +- 459c8574ed: Fixed issue with custom OAuth services' settings not being be fully removed +- 42644a6e44: fix: Prevent `RoomProvider.useEffect` from subscribing to room-data stream multiple times +- 9bdbc9b086: load sounds right before playing them +- 6154979119: Fix users being created without the `roles` field +- 6bcdd88531: Fixed CAS login after popup closes +- 839789c988: Fix moment timestamps language change +- f0025d4d92: Fixed message fetching method in LivechatBridge for Apps +- 9c957b9d9a: Fix pruning messages in a room results in an incorrect message counter +- 583a3149fe: fix: rejected conference calls continue to ring +- b59fd5d7fb: User information crashing for some locales +- 4349443629: Fix performance issue on Engagement Dashboard aggregation +- 69447e1864: Added ability to disable private app installation via envvar (DISABLE_PRIVATE_APP_INSTALLATION) +- 52a1aa94eb: improve: System messages for omni-visitor abandonment feature +- 7dffec2e2f: chore: Add danger variant to apps action button menus +- f0c8867bb9: Disabled call to tags enterprise endpoint when on community license +- 5e89694bfa: Fixes SAML full name updates not being mirrored to DM rooms. +- d6f0c6afe2: Fixed Importer Progress Bar progress indicator +- 177506ea91: Make user default role setting public +- 3fb2124166: Fixed misleading of 'total' in team members list inside Channel +- 5cee21468e: Fix spotlight search does not find rooms with special or non-latin characters +- cf59c8abe3: Fix engagement dashboard not showing data +- dfb9a075b3: fixed wrong user status displayed during mentioning a user in a channel +- 1fbbb6241a: Don't allow to report self messages +- 53e0c346e2: fixed scrollbar over content in Federated Room List +- 5321e87363: Fix seat counter including bots users +- 7137a193a7: feat: Add flag to disable teams mention via troubleshoot page +- 59e6fe3d2a: fixed layout changing from embedded view when navigating +- 3245a0a318: Fix LinkedIn OAuth broken +- 45a8943ed4: Removed old/deprecated Rocket.Chat Federation card from Info page +- 6eea189ec8: Fix the code that was setting email URL to an invalid value when SMTP was not set +- f5a886a144: fixed an issue where oauth login was not working with some providers +- ba24f3c21f: Fixed `default` field not being returned from the `setDefault` endpoints when setting to false +- a79f61461d: Fixed an issue where timeout for http requests in Apps-Engine bridges was too short +- 51b988b3df: Fix importer filters not working +- 5d857f462c: fix: stop blinking "Room not found" before dm creation +- db26f8a8ee: fixed an issue with the positioning of the message menu +- aaefe865a7: fix: agent role being removed upon user deactivation +- 306a5830c3: Fix `mention-here` and `mention-all` permissions not being honored +- 761cad4382: Fix CORS headers not being set for assets +- 9e5718002a: Fixed Slackbridge was not handling correctly received events from Slack anymore. Events: (Send, edit, delete, react meassages) +- 54ef89c9a7: fix: show requested filters only on requested apps view +- 1589279b79: Fix users not able to login after block time perdiod has passed +- 880ab5689c: Fixed selected departments not being displayed due to pagination +- a81bad24e0: Fixed Apps-Engine event `IPostUserCreated` execution +- 7a4fdf41f8: Fix validation in app status call that allowed Enterprise apps to be enabled in invalid environments +- e28f8d95f0: Fixed inviter not informed when inviting member to room via `/invite` slashcommand +- d47d2021ac: Fixed "teams" icon not being displayed on spotlight sidebar search +- 93d5a5ceb8: fix: User timezone not being respected on Current Chat's filter +- f556518fa1: Change SAU aggregation to consider only sessions from few days ago instead of the whole past. + + This is particularly important for large workspaces in case the cron job did not run for some time, in that case the amount of sessions would accumulate and the aggregation would take a long time to run. + +- b747f3d3bc: Fixed unable to create admin user using ADMIN\_\* environment variables +- 2cf2643399: Fixed failing user data exports +- ace35997a6: chore: Increase cache time from 5s to 10s on `getUnits` helpers. This should reduce the number of DB calls made by this method to fetch the unit limitations for a user. +- f5a886a144: fixed an issue on oauth login that caused missing emails to be detected as changed data +- 61128364d6: Fixes a problem where the calculated time for considering the visitor abandonment was the first message from the visitor and not the visitor's reply to the agent. +- 9496f1eb97: Deprecate `livechat:getOverviewData` and `livechat:getAgentOverviewData` methods and create API endpoints `livechat/analytics/overview` and `livechat/analytics/agent-overview` to fetch analytics data +- 01dec055a0: Fixed Accounts profile form name change was not working +- e4837a15ed: Fixed user mentioning when prepending the username with `>` +- d45365436e: Use group filter when set to LDAP sync process +- c536a4a237: fix: Missing padding on Omnichannel contacts Contextualbar loading state +- 87e4a4aa56: Fixes a problem that allowed users to send empty spaces as comment to bypass the "comment required" setting +- 69a5213afc: Fixed an issue where a mailer error was being sent to customers using offline message's form on Omnichannel instead of the translated one +- b8f3d5014f: Fixed the login page language switcher, now the component has a new look, is reactive and the language selection becomes concrete upon login in. Also changed the default language of the login page to be the browser language. +- 22cf158c43: fixed the unread messages mark not showing +- 72a34a02f7: fixed the video recorder window not closing after permission is denied. +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [1246a21648] +- Updated dependencies [4186eecf05] +- Updated dependencies [8a59855fcf] +- Updated dependencies [f9a748526d] +- Updated dependencies [5cee21468e] +- Updated dependencies [dc1d8ce92e] +- Updated dependencies [2db32f0d4a] +- Updated dependencies [982ef6f459] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [19aec23cda] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [aaefe865a7] +- Updated dependencies [074db3b419] +- Updated dependencies [357a3a50fa] +- Updated dependencies [f556518fa1] +- Updated dependencies [ead7c7bef2] +- Updated dependencies [61128364d6] +- Updated dependencies [9496f1eb97] +- Updated dependencies [dce4a829fa] +- Updated dependencies [d45365436e] +- Updated dependencies [b8f3d5014f] +- Updated dependencies [93d4912e17] +- Updated dependencies [ee3815fce4] + - @rocket.chat/core-typings@6.4.0-rc.0 + - @rocket.chat/rest-typings@6.4.0-rc.0 + - @rocket.chat/fuselage-ui-kit@2.0.0-rc.0 + - @rocket.chat/model-typings@0.1.0-rc.0 + - @rocket.chat/core-services@0.2.0-rc.0 + - @rocket.chat/ui-client@2.0.0-rc.0 + - @rocket.chat/ui-contexts@2.0.0-rc.0 + - @rocket.chat/ui-theming@0.1.0-rc.0 + - @rocket.chat/i18n@0.0.2-rc.0 + - @rocket.chat/web-ui-registration@2.0.0-rc.0 + - @rocket.chat/api-client@0.1.5-rc.0 + - @rocket.chat/omnichannel-services@0.0.11-rc.0 + - @rocket.chat/pdf-worker@0.0.11-rc.0 + - @rocket.chat/presence@0.0.11-rc.0 + - @rocket.chat/cron@0.0.7-rc.0 + - @rocket.chat/gazzodown@2.0.0-rc.0 + - @rocket.chat/models@0.0.11-rc.0 + - @rocket.chat/ui-video-conf@2.0.0-rc.0 + - @rocket.chat/base64@1.0.12 + - @rocket.chat/instance-status@0.0.11-rc.0 + - @rocket.chat/random@1.2.1 + - @rocket.chat/sha256@1.0.9 + - @rocket.chat/ui-composer@0.0.1 + +## 6.3.8 + +### Patch Changes + +- ff8e9d9f54: Bump @rocket.chat/meteor version. +- Bump @rocket.chat/meteor version. + - @rocket.chat/core-typings@6.3.8 + - @rocket.chat/rest-typings@6.3.8 + - @rocket.chat/api-client@0.1.8 + - @rocket.chat/omnichannel-services@0.0.14 + - @rocket.chat/pdf-worker@0.0.14 + - @rocket.chat/presence@0.0.14 + - @rocket.chat/core-services@0.1.8 + - @rocket.chat/cron@0.0.10 + - @rocket.chat/gazzodown@1.0.8 + - @rocket.chat/model-typings@0.0.14 + - @rocket.chat/ui-contexts@1.0.8 + - @rocket.chat/fuselage-ui-kit@1.0.8 + - @rocket.chat/models@0.0.14 + - @rocket.chat/ui-theming@0.0.1 + - @rocket.chat/ui-client@1.0.8 + - @rocket.chat/ui-video-conf@1.0.8 + - @rocket.chat/web-ui-registration@1.0.8 + - @rocket.chat/instance-status@0.0.14 + +## 6.3.7 + +### Patch Changes + +- f1e36a5e46: Bump @rocket.chat/meteor version. +- Bump @rocket.chat/meteor version. +- e1acdda0a3: User information crashing for some locales +- deffcb187c: Increase cron job check delay to 1 min from 5s. + + This reduces MongoDB requests introduced on 6.3. + +- Updated dependencies [c655be17ca] +- Updated dependencies [deffcb187c] + - @rocket.chat/presence@0.0.13 + - @rocket.chat/cron@0.0.9 + - @rocket.chat/core-typings@6.3.7 + - @rocket.chat/rest-typings@6.3.7 + - @rocket.chat/api-client@0.1.7 + - @rocket.chat/omnichannel-services@0.0.13 + - @rocket.chat/pdf-worker@0.0.13 + - @rocket.chat/core-services@0.1.7 + - @rocket.chat/gazzodown@1.0.7 + - @rocket.chat/model-typings@0.0.13 + - @rocket.chat/ui-contexts@1.0.7 + - @rocket.chat/fuselage-ui-kit@1.0.7 + - @rocket.chat/models@0.0.13 + - @rocket.chat/ui-theming@0.0.1 + - @rocket.chat/ui-client@1.0.7 + - @rocket.chat/ui-video-conf@1.0.7 + - @rocket.chat/web-ui-registration@1.0.7 + - @rocket.chat/instance-status@0.0.13 + +## 6.3.6 + +### Patch Changes + +- 3bbe12e850: Bump @rocket.chat/meteor version. +- Bump @rocket.chat/meteor version. +- 285e591a73: Fix engagement dashboard not showing data + - @rocket.chat/core-typings@6.3.6 + - @rocket.chat/rest-typings@6.3.6 + - @rocket.chat/api-client@0.1.6 + - @rocket.chat/omnichannel-services@0.0.12 + - @rocket.chat/pdf-worker@0.0.12 + - @rocket.chat/presence@0.0.12 + - @rocket.chat/core-services@0.1.6 + - @rocket.chat/cron@0.0.8 + - @rocket.chat/gazzodown@1.0.6 + - @rocket.chat/model-typings@0.0.12 + - @rocket.chat/ui-contexts@1.0.6 + - @rocket.chat/fuselage-ui-kit@1.0.6 + - @rocket.chat/models@0.0.12 + - @rocket.chat/ui-theming@0.0.1 + - @rocket.chat/ui-client@1.0.6 + - @rocket.chat/ui-video-conf@1.0.6 + - @rocket.chat/web-ui-registration@1.0.6 + - @rocket.chat/instance-status@0.0.12 + +## 6.3.5 + +### Patch Changes + +- 4cb0b6ba6f: Bump @rocket.chat/meteor version. +- Bump @rocket.chat/meteor version. +- f75564c449: Fix a bug that prevented the error message from being shown in the private app installation page +- 03923405e8: Fixed selected departments not being displayed due to pagination +- 92d25b9c7a: Change SAU aggregation to consider only sessions from few days ago instead of the whole past. + + This is particularly important for large workspaces in case the cron job did not run for some time, in that case the amount of sessions would accumulate and the aggregation would take a long time to run. + +- Updated dependencies [92d25b9c7a] + - @rocket.chat/model-typings@0.0.11 + - @rocket.chat/omnichannel-services@0.0.11 + - @rocket.chat/models@0.0.11 + - @rocket.chat/presence@0.0.11 + - @rocket.chat/core-services@0.1.5 + - @rocket.chat/cron@0.0.7 + - @rocket.chat/instance-status@0.0.11 + - @rocket.chat/core-typings@6.3.5 + - @rocket.chat/rest-typings@6.3.5 + - @rocket.chat/api-client@0.1.5 + - @rocket.chat/pdf-worker@0.0.11 + - @rocket.chat/gazzodown@1.0.5 + - @rocket.chat/ui-contexts@1.0.5 + - @rocket.chat/fuselage-ui-kit@1.0.5 + - @rocket.chat/ui-theming@0.0.1 + - @rocket.chat/ui-client@1.0.5 + - @rocket.chat/ui-video-conf@1.0.5 + - @rocket.chat/web-ui-registration@1.0.5 + +## 6.3.4 + +### Patch Changes + +- db919f9b23: Bump @rocket.chat/meteor version. +- Bump @rocket.chat/meteor version. +- ebeb088441: fix: Prevent `RoomProvider.useEffect` from subscribing to room-data stream multiple times +- 8a7d5d3898: fix: agent role being removed upon user deactivation +- 759fe2472a: chore: Increase cache time from 5s to 10s on `getUnits` helpers. This should reduce the number of DB calls made by this method to fetch the unit limitations for a user. +- Updated dependencies [8a7d5d3898] + - @rocket.chat/model-typings@0.0.10 + - @rocket.chat/omnichannel-services@0.0.10 + - @rocket.chat/models@0.0.10 + - @rocket.chat/presence@0.0.10 + - @rocket.chat/core-services@0.1.4 + - @rocket.chat/cron@0.0.6 + - @rocket.chat/instance-status@0.0.10 + - @rocket.chat/core-typings@6.3.4 + - @rocket.chat/rest-typings@6.3.4 + - @rocket.chat/api-client@0.1.4 + - @rocket.chat/pdf-worker@0.0.10 + - @rocket.chat/gazzodown@1.0.4 + - @rocket.chat/ui-contexts@1.0.4 + - @rocket.chat/fuselage-ui-kit@1.0.4 + - @rocket.chat/ui-theming@0.0.1 + - @rocket.chat/ui-client@1.0.4 + - @rocket.chat/ui-video-conf@1.0.4 + - @rocket.chat/web-ui-registration@1.0.4 + +## 6.3.3 + +### Patch Changes + +- bcf147f515: Bump @rocket.chat/meteor version. +- Bump @rocket.chat/meteor version. +- c2fe38cb34: Added ability to disable private app installation via envvar (DISABLE_PRIVATE_APP_INSTALLATION) +- ded9666f27: Fix CORS headers not being set for assets +- f25081bc8a: Removed an unused authentication flow + - @rocket.chat/core-typings@6.3.3 + - @rocket.chat/rest-typings@6.3.3 + - @rocket.chat/api-client@0.1.3 + - @rocket.chat/omnichannel-services@0.0.9 + - @rocket.chat/pdf-worker@0.0.9 + - @rocket.chat/presence@0.0.9 + - @rocket.chat/core-services@0.1.3 + - @rocket.chat/cron@0.0.5 + - @rocket.chat/gazzodown@1.0.3 + - @rocket.chat/model-typings@0.0.9 + - @rocket.chat/ui-contexts@1.0.3 + - @rocket.chat/fuselage-ui-kit@1.0.3 + - @rocket.chat/models@0.0.9 + - @rocket.chat/ui-theming@0.0.1 + - @rocket.chat/ui-client@1.0.3 + - @rocket.chat/ui-video-conf@1.0.3 + - @rocket.chat/web-ui-registration@1.0.3 + - @rocket.chat/instance-status@0.0.9 + +## 6.3.2 + +### Patch Changes + +- 778b155ab4: Bump @rocket.chat/meteor version. +- Bump @rocket.chat/meteor version. +- 5660169ec8: fixed layout changing from embedded view when navigating +- f7b93f2a6a: Fixed an issue where timeout for http requests in Apps-Engine bridges was too short +- 653d97ce22: fix: mobile app unable to detect successful SAML login +- 8a0e36f7b1: fixed the video recorder window not closing after permission is denied. + - @rocket.chat/core-typings@6.3.2 + - @rocket.chat/rest-typings@6.3.2 + - @rocket.chat/api-client@0.1.2 + - @rocket.chat/omnichannel-services@0.0.8 + - @rocket.chat/pdf-worker@0.0.8 + - @rocket.chat/presence@0.0.8 + - @rocket.chat/core-services@0.1.2 + - @rocket.chat/cron@0.0.4 + - @rocket.chat/gazzodown@1.0.2 + - @rocket.chat/model-typings@0.0.8 + - @rocket.chat/ui-contexts@1.0.2 + - @rocket.chat/fuselage-ui-kit@1.0.2 + - @rocket.chat/models@0.0.8 + - @rocket.chat/ui-theming@0.0.1 + - @rocket.chat/ui-client@1.0.2 + - @rocket.chat/ui-video-conf@1.0.2 + - @rocket.chat/web-ui-registration@1.0.2 + - @rocket.chat/instance-status@0.0.8 + +## 6.3.1 + +### Patch Changes + +- a874d5b305: Translation files are requested multiple times +- cf9f16b17c: fix: Performance issue on `Messages.countByType` aggregation caused by unindexed property on messages collection +- be2b5c66cf: Bump @rocket.chat/meteor version. +- Bump @rocket.chat/meteor version. +- ce2f2eaad3: Added ability to freeze or completely disable integration scripts through envvars +- f29c3268ee: fixed an issue where 2fa was not working after an OAuth redirect +- 09a24e59ef: Fix performance issue on Engagement Dashboard aggregation +- f29c3268ee: fixed an issue where oauth login was not working with some providers +- 25d5e3cd9e: Fixed unable to create admin user using ADMIN\_\* environment variables +- 34f08e7c95: Fixed failing user data exports +- f29c3268ee: fixed an issue on oauth login that caused missing emails to be detected as changed data + - @rocket.chat/core-typings@6.3.1 + - @rocket.chat/rest-typings@6.3.1 + - @rocket.chat/api-client@0.1.1 + - @rocket.chat/omnichannel-services@0.0.7 + - @rocket.chat/pdf-worker@0.0.7 + - @rocket.chat/presence@0.0.7 + - @rocket.chat/core-services@0.1.1 + - @rocket.chat/cron@0.0.3 + - @rocket.chat/gazzodown@1.0.1 + - @rocket.chat/model-typings@0.0.7 + - @rocket.chat/ui-contexts@1.0.1 + - @rocket.chat/fuselage-ui-kit@1.0.1 + - @rocket.chat/models@0.0.7 + - @rocket.chat/ui-theming@0.0.1 + - @rocket.chat/ui-client@1.0.1 + - @rocket.chat/ui-video-conf@1.0.1 + - @rocket.chat/web-ui-registration@1.0.1 + - @rocket.chat/instance-status@0.0.7 + ## 6.3.0 ### Minor Changes diff --git a/apps/meteor/app/api/server/api.ts b/apps/meteor/app/api/server/api.ts index 5d9b653d3400..d4e8377a4dd7 100644 --- a/apps/meteor/app/api/server/api.ts +++ b/apps/meteor/app/api/server/api.ts @@ -1,4 +1,5 @@ import type { IMethodConnection, IUser, IRoom } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; import { Users } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import type { JoinPathPattern, Method } from '@rocket.chat/rest-typings'; @@ -17,7 +18,6 @@ import { getRestPayload } from '../../../server/lib/logger/logPayloads'; import { checkCodeForUser } from '../../2fa/server/code'; import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission'; import { apiDeprecationLogger } from '../../lib/server/lib/deprecationWarningLogger'; -import { Logger } from '../../logger/server'; import { metrics } from '../../metrics/server'; import { settings } from '../../settings/server'; import { getDefaultUserFields } from '../../utils/server/functions/getDefaultUserFields'; diff --git a/apps/meteor/app/api/server/default/info.ts b/apps/meteor/app/api/server/default/info.ts index b7806ab08f32..8297f90fffd9 100644 --- a/apps/meteor/app/api/server/default/info.ts +++ b/apps/meteor/app/api/server/default/info.ts @@ -8,7 +8,6 @@ API.default.addRoute( { async get() { const user = await getLoggedInUser(this.request); - return API.v1.success(await getServerInfo(user?._id)); }, }, diff --git a/apps/meteor/app/api/server/definition.ts b/apps/meteor/app/api/server/definition.ts index cce6d38c5552..d2fa248530ff 100644 --- a/apps/meteor/app/api/server/definition.ts +++ b/apps/meteor/app/api/server/definition.ts @@ -1,10 +1,10 @@ import type { IUser } from '@rocket.chat/core-typings'; +import type { Logger } from '@rocket.chat/logger'; import type { Method, MethodOf, OperationParams, OperationResult, PathPattern, UrlParams } from '@rocket.chat/rest-typings'; import type { ValidateFunction } from 'ajv'; import type { Request, Response } from 'express'; import type { ITwoFactorOptions } from '../../2fa/server/code'; -import type { Logger } from '../../logger/server'; export type SuccessResult = { statusCode: 200; diff --git a/apps/meteor/app/api/server/lib/getServerInfo.spec.ts b/apps/meteor/app/api/server/lib/getServerInfo.spec.ts new file mode 100644 index 000000000000..ca55cfa33e3e --- /dev/null +++ b/apps/meteor/app/api/server/lib/getServerInfo.spec.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const hasAllPermissionAsyncMock = sinon.stub(); +const getCachedSupportedVersionsTokenMock = sinon.stub(); + +const { getServerInfo } = proxyquire.noCallThru().load('./getServerInfo', { + '../../../utils/rocketchat.info': { + Info: { + version: '3.0.1', + }, + }, + '../../../authorization/server/functions/hasPermission': { + hasPermissionAsync: hasAllPermissionAsyncMock, + }, + '../../../cloud/server/functions/supportedVersionsToken/supportedVersionsToken': { + getCachedSupportedVersionsToken: getCachedSupportedVersionsTokenMock, + }, + '../../../settings/server': { + settings: new Map(), + }, +}); +describe('#getServerInfo()', () => { + beforeEach(() => { + hasAllPermissionAsyncMock.reset(); + getCachedSupportedVersionsTokenMock.reset(); + }); + + it('should return only the version (without the patch info) when the user is not present', async () => { + expect(await getServerInfo(undefined)).to.be.eql({ version: '3.0' }); + }); + + it('should return only the version (without the patch info) when the user present but they dont have permission', async () => { + hasAllPermissionAsyncMock.resolves(false); + expect(await getServerInfo('userId')).to.be.eql({ version: '3.0' }); + }); + + it('should return the info object + the supportedVersions from the cloud when the request to the cloud was a success', async () => { + const signedJwt = 'signedJwt'; + hasAllPermissionAsyncMock.resolves(true); + getCachedSupportedVersionsTokenMock.resolves(signedJwt); + expect(await getServerInfo('userId')).to.be.eql({ info: { version: '3.0.1', supportedVersions: signedJwt } }); + }); + + it('should return the info object ONLY from the cloud when the request to the cloud was NOT a success', async () => { + hasAllPermissionAsyncMock.resolves(true); + getCachedSupportedVersionsTokenMock.rejects(); + expect(await getServerInfo('userId')).to.be.eql({ info: { version: '3.0.1' } }); + }); +}); diff --git a/apps/meteor/app/api/server/lib/getServerInfo.ts b/apps/meteor/app/api/server/lib/getServerInfo.ts index 39f4b82b350b..53ba3656babe 100644 --- a/apps/meteor/app/api/server/lib/getServerInfo.ts +++ b/apps/meteor/app/api/server/lib/getServerInfo.ts @@ -1,23 +1,37 @@ import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { Info } from '../../../utils/rocketchat.info'; +import { + getCachedSupportedVersionsToken, + wrapPromise, +} from '../../../cloud/server/functions/supportedVersionsToken/supportedVersionsToken'; +import { Info, minimumClientVersions } from '../../../utils/rocketchat.info'; -type ServerInfo = - | { - info: typeof Info; - } - | { - version: string | undefined; - }; +type ServerInfo = { + info?: typeof Info; + supportedVersions?: { signed: string }; + minimumClientVersions: typeof minimumClientVersions; + version: string; +}; const removePatchInfo = (version: string): string => version.replace(/(\d+\.\d+).*/, '$1'); export async function getServerInfo(userId?: string): Promise { - if (userId && (await hasPermissionAsync(userId, 'get-server-info'))) { - return { - info: Info, - }; - } + const hasPermissionToViewStatistics = userId && (await hasPermissionAsync(userId, 'view-statistics')); + const supportedVersionsToken = await wrapPromise(getCachedSupportedVersionsToken()); + return { version: removePatchInfo(Info.version), + + ...(hasPermissionToViewStatistics && { + info: { + ...Info, + }, + version: Info.version, + }), + + minimumClientVersions, + ...(supportedVersionsToken.success && + supportedVersionsToken.result && { + supportedVersions: { signed: supportedVersionsToken.result }, + }), }; } diff --git a/apps/meteor/app/api/server/v1/channels.ts b/apps/meteor/app/api/server/v1/channels.ts index 70b7fc875082..8e0541b8040b 100644 --- a/apps/meteor/app/api/server/v1/channels.ts +++ b/apps/meteor/app/api/server/v1/channels.ts @@ -1,4 +1,4 @@ -import { Team } from '@rocket.chat/core-services'; +import { Team, Room } from '@rocket.chat/core-services'; import type { IRoom, ISubscription, IUser, RoomType } from '@rocket.chat/core-typings'; import { Integrations, Messages, Rooms, Subscriptions, Uploads, Users } from '@rocket.chat/models'; import { @@ -31,7 +31,6 @@ import { saveRoomSettings } from '../../../channel-settings/server/methods/saveR import { mountIntegrationQueryBasedOnPermissions } from '../../../integrations/server/lib/mountQueriesBasedOnPermission'; import { addUsersToRoomMethod } from '../../../lib/server/methods/addUsersToRoom'; import { createChannelMethod } from '../../../lib/server/methods/createChannel'; -import { joinRoomMethod } from '../../../lib/server/methods/joinRoom'; import { leaveRoomMethod } from '../../../lib/server/methods/leaveRoom'; import { settings } from '../../../settings/server'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; @@ -209,7 +208,7 @@ API.v1.addRoute( const { joinCode, ...params } = this.bodyParams; const findResult = await findChannelByIdOrName({ params }); - await joinRoomMethod(this.userId, findResult._id, joinCode); + await Room.join({ room: findResult, user: this.user, joinCode }); return API.v1.success({ channel: await findChannelByIdOrName({ params, userId: this.userId }), @@ -671,7 +670,14 @@ async function createChannelValidator(params: { async function createChannel( userId: string, - params: { name?: string; members?: string[]; customFields?: Record; extraData?: Record; readOnly?: boolean }, + params: { + name?: string; + members?: string[]; + customFields?: Record; + extraData?: Record; + readOnly?: boolean; + excludeSelf?: boolean; + }, ): Promise<{ channel: IRoom }> { const readOnly = typeof params.readOnly !== 'undefined' ? params.readOnly : false; const id = await createChannelMethod( @@ -681,6 +687,7 @@ async function createChannel( readOnly, params.customFields, params.extraData, + params.excludeSelf, ); return { diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index e12e6f0cdd46..75fc98e69c4d 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -14,6 +14,7 @@ import { hasPermissionAsync } from '../../../authorization/server/functions/hasP import { deleteMessageValidatingPermission } from '../../../lib/server/functions/deleteMessage'; import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage'; import { executeSendMessage } from '../../../lib/server/methods/sendMessage'; +import { executeUpdateMessage } from '../../../lib/server/methods/updateMessage'; import { executeSetReaction } from '../../../reactions/server/setReaction'; import { settings } from '../../../settings/server'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; @@ -215,7 +216,7 @@ API.v1.addRoute( throw new Meteor.Error('error-invalid-params', 'The "message" parameter must be provided.'); } - const sent = await executeSendMessage(this.userId, this.bodyParams.message as Pick); + const sent = await executeSendMessage(this.userId, this.bodyParams.message as Pick, this.bodyParams.previewUrls); const [message] = await normalizeMessagesForUser([sent], this.userId); return API.v1.success({ @@ -310,6 +311,7 @@ API.v1.addRoute( roomId: String, msgId: String, text: String, // Using text to be consistant with chat.postMessage + previewUrls: Match.Maybe([String]), }), ); @@ -325,7 +327,7 @@ API.v1.addRoute( } // Permission checks are already done in the updateMessage method, so no need to duplicate them - await Meteor.callAsync('updateMessage', { _id: msg._id, msg: this.bodyParams.text, rid: msg.rid }); + await executeUpdateMessage(this.userId, { _id: msg._id, msg: this.bodyParams.text, rid: msg.rid }, this.bodyParams.previewUrls); const updatedMessage = await Messages.findOneById(msg._id); const [message] = await normalizeMessagesForUser(updatedMessage ? [updatedMessage] : [], this.userId); diff --git a/apps/meteor/app/api/server/v1/federation.ts b/apps/meteor/app/api/server/v1/federation.ts index 02fc30763eeb..7be5b1fc13fe 100644 --- a/apps/meteor/app/api/server/v1/federation.ts +++ b/apps/meteor/app/api/server/v1/federation.ts @@ -1,7 +1,7 @@ import { Federation, FederationEE } from '@rocket.chat/core-services'; +import { License } from '@rocket.chat/license'; import { isFederationVerifyMatrixIdProps } from '@rocket.chat/rest-typings'; -import { isEnterprise } from '../../../../ee/app/license/server'; import { API } from '../api'; API.v1.addRoute( @@ -14,7 +14,7 @@ API.v1.addRoute( async get() { const { matrixIds } = this.queryParams; - const federationService = isEnterprise() ? FederationEE : Federation; + const federationService = License.hasValidLicense() ? FederationEE : Federation; const results = await federationService.verifyMatrixIds(matrixIds); diff --git a/apps/meteor/app/api/server/v1/groups.ts b/apps/meteor/app/api/server/v1/groups.ts index df54b683fda4..8f2999cee71e 100644 --- a/apps/meteor/app/api/server/v1/groups.ts +++ b/apps/meteor/app/api/server/v1/groups.ts @@ -1,4 +1,4 @@ -import { Team } from '@rocket.chat/core-services'; +import { Team, isMeteorError } from '@rocket.chat/core-services'; import type { IIntegration, IUser, IRoom, RoomType } from '@rocket.chat/core-typings'; import { Integrations, Messages, Rooms, Subscriptions, Uploads, Users } from '@rocket.chat/models'; import { check, Match } from 'meteor/check'; @@ -26,29 +26,7 @@ import { getLoggedInUser } from '../helpers/getLoggedInUser'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { getUserFromParams, getUserListFromParams } from '../helpers/getUserFromParams'; -// Returns the private group subscription IF found otherwise it will return the failure of why it didn't. Check the `statusCode` property -async function findPrivateGroupByIdOrName({ - params, - checkedArchived = true, - userId, -}: { - params: - | { - roomId?: string; - } - | { - roomName?: string; - }; - userId: string; - checkedArchived?: boolean; -}): Promise<{ - rid: string; - open: boolean; - ro: boolean; - t: string; - name: string; - broadcast: boolean; -}> { +async function getRoomFromParams(params: { roomId?: string } | { roomName?: string }): Promise { if ( (!('roomId' in params) && !('roomName' in params)) || ('roomId' in params && !(params as { roomId?: string }).roomId && 'roomName' in params && !(params as { roomName?: string }).roomName) @@ -68,17 +46,48 @@ async function findPrivateGroupByIdOrName({ broadcast: 1, }, }; - let room: IRoom | null = null; - if ('roomId' in params) { - room = await Rooms.findOneById(params.roomId || '', roomOptions); - } else if ('roomName' in params) { - room = await Rooms.findOneByName(params.roomName || '', roomOptions); - } + + const room = await (() => { + if ('roomId' in params) { + return Rooms.findOneById(params.roomId || '', roomOptions); + } + if ('roomName' in params) { + return Rooms.findOneByName(params.roomName || '', roomOptions); + } + })(); if (!room || room.t !== 'p') { throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); } + return room; +} + +// Returns the private group subscription IF found otherwise it will return the failure of why it didn't. Check the `statusCode` property +async function findPrivateGroupByIdOrName({ + params, + checkedArchived = true, + userId, +}: { + params: + | { + roomId?: string; + } + | { + roomName?: string; + }; + userId: string; + checkedArchived?: boolean; +}): Promise<{ + rid: string; + open: boolean; + ro: boolean; + t: string; + name: string; + broadcast: boolean; +}> { + const room = await getRoomFromParams(params); + const user = await Users.findOneById(userId, { projections: { username: 1 } }); if (!room || !user || !(await canAccessRoomAsync(room, user))) { @@ -302,10 +311,6 @@ API.v1.addRoute( { authRequired: true }, { async post() { - if (!(await hasPermissionAsync(this.userId, 'create-p'))) { - return API.v1.unauthorized(); - } - if (!this.bodyParams.name) { return API.v1.failure('Body param "name" is required'); } @@ -323,24 +328,32 @@ API.v1.addRoute( const readOnly = typeof this.bodyParams.readOnly !== 'undefined' ? this.bodyParams.readOnly : false; - const result = await createPrivateGroupMethod( - this.userId, - this.bodyParams.name, - this.bodyParams.members ? this.bodyParams.members : [], - readOnly, - this.bodyParams.customFields, - this.bodyParams.extraData, - ); - - const room = await Rooms.findOneById(result.rid, { projection: API.v1.defaultFieldsToExclude }); + try { + const result = await createPrivateGroupMethod( + this.user, + this.bodyParams.name, + this.bodyParams.members ? this.bodyParams.members : [], + readOnly, + this.bodyParams.customFields, + this.bodyParams.extraData, + this.bodyParams.excludeSelf ?? false, + ); + + const room = await Rooms.findOneById(result.rid, { projection: API.v1.defaultFieldsToExclude }); + if (!room) { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } - if (!room) { - throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + return API.v1.success({ + group: await composeRoomWithLastMessage(room, this.userId), + }); + } catch (error: unknown) { + if (isMeteorError(error) && error.reason === 'error-not-allowed') { + return API.v1.unauthorized(); + } } - return API.v1.success({ - group: await composeRoomWithLastMessage(room, this.userId), - }); + return API.v1.internalError(); }, }, ); @@ -581,17 +594,14 @@ API.v1.addRoute( { authRequired: true }, { async post() { - const findResult = await findPrivateGroupByIdOrName({ - params: this.bodyParams, - userId: this.userId, - }); + const room = await getRoomFromParams(this.bodyParams); const user = await getUserFromParams(this.bodyParams); if (!user?.username) { return API.v1.failure('Invalid user'); } - await removeUserFromRoomMethod(this.userId, { rid: findResult.rid, username: user.username }); + await removeUserFromRoomMethod(this.userId, { rid: room._id, username: user.username }); return API.v1.success(); }, diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index dec4da6bf87b..ae5a79719cce 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -1,13 +1,14 @@ import crypto from 'crypto'; import type { IUser } from '@rocket.chat/core-typings'; -import { Users } from '@rocket.chat/models'; +import { Settings, Users } from '@rocket.chat/models'; import { isShieldSvgProps, isSpotlightProps, isDirectoryProps, isMethodCallProps, isMethodCallAnonProps, + isFingerprintProps, isMeteorCall, validateParamsPwGetPolicyRest, } from '@rocket.chat/rest-typings'; @@ -16,6 +17,7 @@ import EJSON from 'ejson'; import { check } from 'meteor/check'; import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; import { Meteor } from 'meteor/meteor'; +import { v4 as uuidv4 } from 'uuid'; import { i18n } from '../../../../server/lib/i18n'; import { SystemLogger } from '../../../../server/lib/logger/system'; @@ -643,3 +645,75 @@ API.v1.addRoute( }, }, ); + +/** + * @openapi + * /api/v1/fingerprint: + * post: + * description: Update Fingerprint definition as a new workspace or update of configuration + * security: + * $ref: '#/security/authenticated' + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * setDeploymentAs: + * type: string + * example: | + * { + * "setDeploymentAs": "new-workspace" + * } + * responses: + * 200: + * description: Workspace successfully configured + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiSuccessV1' + * default: + * description: Unexpected error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiFailureV1' + */ +API.v1.addRoute( + 'fingerprint', + { + authRequired: true, + validateParams: isFingerprintProps, + }, + { + async post() { + check(this.bodyParams, { + setDeploymentAs: String, + }); + + if (this.bodyParams.setDeploymentAs === 'new-workspace') { + await Promise.all([ + Settings.resetValueById('uniqueID', process.env.DEPLOYMENT_ID || uuidv4()), + // Settings.resetValueById('Cloud_Url'), + Settings.resetValueById('Cloud_Service_Agree_PrivacyTerms'), + Settings.resetValueById('Cloud_Workspace_Id'), + Settings.resetValueById('Cloud_Workspace_Name'), + Settings.resetValueById('Cloud_Workspace_Client_Id'), + Settings.resetValueById('Cloud_Workspace_Client_Secret'), + Settings.resetValueById('Cloud_Workspace_Client_Secret_Expires_At'), + Settings.resetValueById('Cloud_Workspace_Registration_Client_Uri'), + Settings.resetValueById('Cloud_Workspace_PublicKey'), + Settings.resetValueById('Cloud_Workspace_License'), + Settings.resetValueById('Cloud_Workspace_Had_Trial'), + Settings.resetValueById('Cloud_Workspace_Access_Token'), + Settings.resetValueById('Cloud_Workspace_Access_Token_Expires_At', new Date(0)), + Settings.resetValueById('Cloud_Workspace_Registration_State'), + ]); + } + + await Settings.updateValueById('Deployment_FingerPrint_Verified', true); + + 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 fe0210aaa217..fe31487bdc47 100644 --- a/apps/meteor/app/api/server/v1/moderation.ts +++ b/apps/meteor/app/api/server/v1/moderation.ts @@ -5,6 +5,7 @@ import { isArchiveReportProps, isReportInfoParams, isReportMessageHistoryParams, + isModerationReportUserPost, isModerationDeleteMsgHistoryParams, isReportsByMsgIdParams, } from '@rocket.chat/rest-typings'; @@ -35,7 +36,11 @@ API.v1.addRoute( const escapedSelector = escapeRegExp(selector); - const reports = await ModerationReports.findReportsGroupedByUser(latest, oldest, escapedSelector, { offset, count, sort }).toArray(); + const reports = await ModerationReports.findMessageReportsGroupedByUser(latest, oldest, escapedSelector, { + offset, + count, + sort, + }).toArray(); if (reports.length === 0) { return API.v1.success({ @@ -46,7 +51,7 @@ API.v1.addRoute( }); } - const total = await ModerationReports.countReportsInRange(latest, oldest, escapedSelector); + const total = await ModerationReports.countMessageReportsInRange(latest, oldest, escapedSelector); return API.v1.success({ reports, @@ -143,7 +148,7 @@ API.v1.addRoute( moderator, ); - await ModerationReports.hideReportsByUserId(userId, this.userId, sanitizedReason, 'DELETE Messages'); + await ModerationReports.hideMessageReportsByUserId(userId, this.userId, sanitizedReason, 'DELETE Messages'); return API.v1.success(); }, @@ -181,9 +186,9 @@ API.v1.addRoute( const { userId: moderatorId } = this; if (userId) { - await ModerationReports.hideReportsByUserId(userId, moderatorId, sanitizedReason, action); + await ModerationReports.hideMessageReportsByUserId(userId, moderatorId, sanitizedReason, action); } else { - await ModerationReports.hideReportsByMessageId(msgId as string, moderatorId, sanitizedReason, action); + await ModerationReports.hideMessageReportsByMessageId(msgId as string, moderatorId, sanitizedReason, action); } return API.v1.success(); @@ -243,3 +248,30 @@ API.v1.addRoute( }, }, ); + +API.v1.addRoute( + 'moderation.reportUser', + { + authRequired: true, + validateParams: isModerationReportUserPost, + }, + { + async post() { + const { userId, description } = this.bodyParams; + + const { + user: { _id, name, username, createdAt }, + } = this; + + 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.'); + } + + await ModerationReports.createWithDescriptionAndUser(reportedUser, description, { _id, name, username, createdAt }); + + return API.v1.success(); + }, + }, +); diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index a4e3f974ac65..b23d41255c3b 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -126,7 +126,9 @@ API.v1.addRoute( realname: this.bodyParams.data.name, username: this.bodyParams.data.username, nickname: this.bodyParams.data.nickname, + bio: this.bodyParams.data.bio, statusText: this.bodyParams.data.statusText, + statusType: this.bodyParams.data.statusType, newPassword: this.bodyParams.data.newPassword, typedPassword: this.bodyParams.data.currentPassword, }; diff --git a/apps/meteor/app/api/server/v1/voip/logger.ts b/apps/meteor/app/api/server/v1/voip/logger.ts index 792cf79427ea..fffa68078480 100644 --- a/apps/meteor/app/api/server/v1/voip/logger.ts +++ b/apps/meteor/app/api/server/v1/voip/logger.ts @@ -1,3 +1,3 @@ -import { Logger } from '../../../../logger/server'; +import { Logger } from '@rocket.chat/logger'; export const logger = new Logger('VoIP'); diff --git a/apps/meteor/app/api/server/v1/voip/omnichannel.ts b/apps/meteor/app/api/server/v1/voip/omnichannel.ts index 6ffd0005c764..e1ee82d72478 100644 --- a/apps/meteor/app/api/server/v1/voip/omnichannel.ts +++ b/apps/meteor/app/api/server/v1/voip/omnichannel.ts @@ -78,7 +78,6 @@ API.v1.addRoute( } try { - logger.debug(`Setting extension ${extension} for agent with id ${user._id}`); await Users.setExtension(user._id, extension); return API.v1.success(); } catch (e) { @@ -146,7 +145,6 @@ API.v1.addRoute( return API.v1.notFound(); } if (!user.extension) { - logger.debug(`User ${user._id} is not associated with any extension. Skipping`); return API.v1.success(); } diff --git a/apps/meteor/app/apps/server/bridges/bridges.js b/apps/meteor/app/apps/server/bridges/bridges.js index 6c67c116ba01..6d52a1d8e56d 100644 --- a/apps/meteor/app/apps/server/bridges/bridges.js +++ b/apps/meteor/app/apps/server/bridges/bridges.js @@ -15,6 +15,7 @@ import { AppMessageBridge } from './messages'; import { AppModerationBridge } from './moderation'; import { AppOAuthAppsBridge } from './oauthApps'; import { AppPersistenceBridge } from './persistence'; +import { AppRoleBridge } from './roles'; import { AppRoomBridge } from './rooms'; import { AppSchedulerBridge } from './scheduler'; import { AppSettingBridge } from './settings'; @@ -51,6 +52,7 @@ export class RealAppBridges extends AppBridges { this._internalFedBridge = new AppInternalFederationBridge(); this._moderationBridge = new AppModerationBridge(orch); this._threadBridge = new AppThreadBridge(orch); + this._roleBridge = new AppRoleBridge(orch); } getCommandBridge() { @@ -144,4 +146,8 @@ export class RealAppBridges extends AppBridges { getModerationBridge() { return this._moderationBridge; } + + getRoleBridge() { + return this._roleBridge; + } } diff --git a/apps/meteor/app/apps/server/bridges/http.ts b/apps/meteor/app/apps/server/bridges/http.ts index 755b830f485f..a4b09e848c90 100644 --- a/apps/meteor/app/apps/server/bridges/http.ts +++ b/apps/meteor/app/apps/server/bridges/http.ts @@ -7,6 +7,11 @@ import type { AppServerOrchestrator } from '../../../../ee/server/apps/orchestra const isGetOrHead = (method: string): boolean => ['GET', 'HEAD'].includes(method.toUpperCase()); +// Previously, there was no timeout for HTTP requests. +// We're setting the default timeout now to 3 minutes as it +// seems to be a good balance +const DEFAULT_TIMEOUT = 3 * 60 * 1000; + export class AppHttpBridge extends HttpBridge { constructor(private readonly orch: AppServerOrchestrator) { super(); @@ -62,6 +67,8 @@ export class AppHttpBridge extends HttpBridge { content = undefined; } + const timeout = request.timeout || DEFAULT_TIMEOUT; + // end comptability with old HTTP.call API this.orch.debugLog(`The App ${info.appId} is requesting from the outter webs:`, info); @@ -73,6 +80,7 @@ export class AppHttpBridge extends HttpBridge { method, body: content, headers, + timeout, }, (request.hasOwnProperty('strictSSL') && !request.strictSSL) || (request.hasOwnProperty('rejectUnauthorized') && request.rejectUnauthorized), diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 6bead3d80a62..76a0545c8801 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -6,7 +6,7 @@ import type { ILivechatTransferData, IDepartment, } from '@rocket.chat/apps-engine/definition/livechat'; -import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IMessage as IAppsEngineMesage } from '@rocket.chat/apps-engine/definition/messages'; import type { IUser } from '@rocket.chat/apps-engine/definition/users'; import { LivechatBridge } from '@rocket.chat/apps-engine/server/bridges/LivechatBridge'; import type { SelectedAgent } from '@rocket.chat/core-typings'; @@ -223,7 +223,7 @@ export class AppLivechatBridge extends LivechatBridge { } return Promise.all( - (await LivechatVisitors.find(query).toArray()).map( + (await LivechatVisitors.findEnabled(query).toArray()).map( async (visitor) => visitor && this.orch.getConverters()?.get('visitors').convertVisitor(visitor), ), ); @@ -280,7 +280,7 @@ export class AppLivechatBridge extends LivechatBridge { return Promise.all((await LivechatDepartment.findEnabledWithAgents().toArray()).map(boundConverter)); } - protected async _fetchLivechatRoomMessages(appId: string, roomId: string): Promise> { + protected async _fetchLivechatRoomMessages(appId: string, roomId: string): Promise> { this.orch.debugLog(`The App ${appId} is getting the transcript for livechat room ${roomId}.`); const messageConverter = this.orch.getConverters()?.get('messages'); @@ -288,9 +288,9 @@ export class AppLivechatBridge extends LivechatBridge { throw new Error('Could not get the message converter to process livechat room messages'); } - const boundMessageConverter = messageConverter.convertMessage.bind(messageConverter); + const livechatMessages = await Livechat.getRoomMessages({ rid: roomId }); - return (await Livechat.getRoomMessages({ rid: roomId })).map(boundMessageConverter); + return Promise.all(livechatMessages.map((message) => messageConverter.convertMessage(message) as Promise)); } protected async setCustomFields( diff --git a/apps/meteor/app/apps/server/bridges/moderation.ts b/apps/meteor/app/apps/server/bridges/moderation.ts index 26a103cd48b1..4581f9a6ac6b 100644 --- a/apps/meteor/app/apps/server/bridges/moderation.ts +++ b/apps/meteor/app/apps/server/bridges/moderation.ts @@ -32,7 +32,7 @@ export class AppModerationBridge extends ModerationBridge { throw new Error('Invalid message id'); } - await ModerationReports.hideReportsByMessageId(messageId, appId, reason, action); + await ModerationReports.hideMessageReportsByMessageId(messageId, appId, reason, action); } protected async dismissReportsByUserId(userId: IUser['id'], reason: string, action: string, appId: string): Promise { @@ -41,6 +41,6 @@ export class AppModerationBridge extends ModerationBridge { if (!userId) { throw new Error('Invalid user id'); } - await ModerationReports.hideReportsByUserId(userId, appId, reason, action); + await ModerationReports.hideMessageReportsByUserId(userId, appId, reason, action); } } diff --git a/apps/meteor/app/apps/server/bridges/roles.ts b/apps/meteor/app/apps/server/bridges/roles.ts new file mode 100644 index 000000000000..f973b7f49ed2 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/roles.ts @@ -0,0 +1,33 @@ +import type { IRole } from '@rocket.chat/apps-engine/definition/roles'; +import { RoleBridge } from '@rocket.chat/apps-engine/server/bridges'; +import { Roles } from '@rocket.chat/models'; + +import type { AppServerOrchestrator } from '../../../../ee/server/apps/orchestrator'; + +export class AppRoleBridge extends RoleBridge { + constructor(private readonly orch: AppServerOrchestrator) { + super(); + } + + protected async getOneByIdOrName(idOrName: IRole['id'] | IRole['name'], appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the roleByIdOrName: "${idOrName}"`); + + const role = await Roles.findOneByIdOrName(idOrName); + return this.orch.getConverters()?.get('roles').convertRole(role); + } + + protected async getCustomRoles(appId: string): Promise> { + this.orch.debugLog(`The App ${appId} is getting the custom roles`); + + const cursor = Roles.findCustomRoles(); + + const roles: IRole[] = []; + + for await (const role of cursor) { + const convRole = await this.orch.getConverters()?.get('roles').convertRole(role); + roles.push(convRole); + } + + return roles; + } +} diff --git a/apps/meteor/app/apps/server/bridges/rooms.ts b/apps/meteor/app/apps/server/bridges/rooms.ts index 481292d61790..91b0049513f0 100644 --- a/apps/meteor/app/apps/server/bridges/rooms.ts +++ b/apps/meteor/app/apps/server/bridges/rooms.ts @@ -55,7 +55,11 @@ export class AppRoomBridge extends RoomBridge { } private async createPrivateGroup(userId: string, room: ICoreRoom, members: string[]): Promise { - return (await createPrivateGroupMethod(userId, room.name || '', members, room.ro, room.customFields, this.prepareExtraData(room))).rid; + const user = await Users.findOneById(userId); + if (!user) { + throw new Error('Invalid user'); + } + return (await createPrivateGroupMethod(user, room.name || '', members, room.ro, room.customFields, this.prepareExtraData(room))).rid; } protected async getById(roomId: string, appId: string): Promise { diff --git a/apps/meteor/app/apps/server/converters/index.ts b/apps/meteor/app/apps/server/converters/index.ts index 39a6a260f69f..96716af03ca7 100644 --- a/apps/meteor/app/apps/server/converters/index.ts +++ b/apps/meteor/app/apps/server/converters/index.ts @@ -1,5 +1,6 @@ import { AppDepartmentsConverter } from './departments'; import { AppMessagesConverter } from './messages'; +import { AppRolesConverter } from './roles'; import { AppRoomsConverter } from './rooms'; import { AppSettingsConverter } from './settings'; import { AppUploadsConverter } from './uploads'; @@ -16,4 +17,5 @@ export { AppDepartmentsConverter, AppUploadsConverter, AppVisitorsConverter, + AppRolesConverter, }; diff --git a/apps/meteor/app/apps/server/converters/roles.ts b/apps/meteor/app/apps/server/converters/roles.ts new file mode 100644 index 000000000000..4ac1f3956420 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/roles.ts @@ -0,0 +1,29 @@ +import type { IRole as AppsEngineRole } from '@rocket.chat/apps-engine/definition/roles'; +import type { IRole } from '@rocket.chat/core-typings'; +import { Roles } from '@rocket.chat/models'; + +import { transformMappedData } from '../../../../ee/lib/misc/transformMappedData'; + +export class AppRolesConverter { + async convertById(roleId: string): Promise { + const role = await Roles.findOneById(roleId); + + if (!role) { + return; + } + return this.convertRole(role); + } + + async convertRole(role: IRole): Promise { + const map = { + id: '_id', + name: 'name', + description: 'description', + mandatory2fa: 'mandatory2fa', + protected: 'protected', + scope: 'scope', + }; + + return (await transformMappedData(role, map)) as unknown as AppsEngineRole; + } +} diff --git a/apps/meteor/app/apps/server/converters/rooms.js b/apps/meteor/app/apps/server/converters/rooms.js index 44d513bd591d..905534212836 100644 --- a/apps/meteor/app/apps/server/converters/rooms.js +++ b/apps/meteor/app/apps/server/converters/rooms.js @@ -37,10 +37,10 @@ export class AppRoomsConverter { let v; if (room.visitor) { - const visitor = await LivechatVisitors.findOneById(room.visitor.id); + const visitor = await LivechatVisitors.findOneEnabledById(room.visitor.id); + + const { lastMessageTs, phone } = room.visitorChannelInfo; - const lastMessageTs = room?.visitor?.lastMessageTs; - const phone = room?.visitor?.channelPhone; v = { _id: visitor._id, username: visitor.username, @@ -53,7 +53,7 @@ export class AppRoomsConverter { let departmentId; if (room.department) { - const department = await LivechatDepartment.findOneById(room.department.id); + const department = await LivechatDepartment.findOneById(room.department.id, { projection: { _id: 1 } }); departmentId = department._id; } @@ -170,26 +170,32 @@ export class AppRoomsConverter { return this.orch.getConverters().get('users').convertById(u._id); }, - visitor: async (room) => { + visitor: (room) => { const { v } = room; if (!v) { return undefined; } - const { lastMessageTs, phone } = v; + return this.orch.getConverters().get('visitors').convertById(v._id); + }, + // Note: room.v is not just visitor, it also contains channel related visitor data + // so we need to pass this data to the converter + // So suppose you have a contact whom we're contacting using SMS via 2 phone no's, + // let's call X and Y. Then if the contact sends a message using X phone number, + // then room.v.phoneNo would be X and correspondingly we'll store the timestamp of + // the last message from this visitor from X phone no on room.v.lastMessageTs + visitorChannelInfo: (room) => { + const { v } = room; - delete room.v; + if (!v) { + return undefined; + } + + const { lastMessageTs, phone } = v; return { - ...(await this.orch.getConverters().get('visitors').convertById(v._id)), - // Note: room.v is not just visitor, it also contains channel related visitor data - // so we need to pass this data to the converter - // So suppose you have a contact whom we're contacting using SMS via 2 phone no's, - // let's call X and Y. Then if the contact sends a message using X phone number, - // then room.v.phoneNo would be X and correspondingly we'll store the timestamp of - // the last message from this visitor from X phone no on room.v.lastMessageTs - ...(phone && { channelPhone: phone }), + ...(phone && { phone }), ...(lastMessageTs && { lastMessageTs }), }; }, diff --git a/apps/meteor/app/apps/server/converters/visitors.js b/apps/meteor/app/apps/server/converters/visitors.js index ba288c96d7b8..a9f5d450efad 100644 --- a/apps/meteor/app/apps/server/converters/visitors.js +++ b/apps/meteor/app/apps/server/converters/visitors.js @@ -9,7 +9,7 @@ export class AppVisitorsConverter { } async convertById(id) { - const visitor = await LivechatVisitors.findOneById(id); + const visitor = await LivechatVisitors.findOneEnabledById(id); return this.convertVisitor(visitor); } diff --git a/apps/meteor/app/authentication/server/ILoginAttempt.ts b/apps/meteor/app/authentication/server/ILoginAttempt.ts index f48aeba7d073..c4c89f71f27c 100644 --- a/apps/meteor/app/authentication/server/ILoginAttempt.ts +++ b/apps/meteor/app/authentication/server/ILoginAttempt.ts @@ -1,3 +1,4 @@ +import type { MeteorError } from '@rocket.chat/core-services'; import type { IUser, IMethodConnection } from '@rocket.chat/core-typings'; interface IMethodArgument { @@ -22,4 +23,5 @@ export interface ILoginAttempt { methodArguments: IMethodArgument[]; connection: IMethodConnection; user?: IUser; + error?: MeteorError; } diff --git a/apps/meteor/app/authentication/server/hooks/login.ts b/apps/meteor/app/authentication/server/hooks/login.ts index 7decdc434173..10b91271c88f 100644 --- a/apps/meteor/app/authentication/server/hooks/login.ts +++ b/apps/meteor/app/authentication/server/hooks/login.ts @@ -6,8 +6,15 @@ import type { ILoginAttempt } from '../ILoginAttempt'; import { logFailedLoginAttempts } from '../lib/logLoginAttempts'; import { saveFailedLoginAttempts, saveSuccessfulLogin } from '../lib/restrictLoginAttempts'; +const ignoredErrorTypes = ['totp-required', 'error-login-blocked-for-user']; + Accounts.onLoginFailure(async (login: ILoginAttempt) => { - if (settings.get('Block_Multiple_Failed_Logins_Enabled')) { + // do not save specific failed login attempts + if ( + settings.get('Block_Multiple_Failed_Logins_Enabled') && + login.error?.error && + !ignoredErrorTypes.includes(String(login.error.error)) + ) { await saveFailedLoginAttempts(login); } diff --git a/apps/meteor/app/authentication/server/lib/restrictLoginAttempts.ts b/apps/meteor/app/authentication/server/lib/restrictLoginAttempts.ts index 716d03ce6097..3b6cd6c89409 100644 --- a/apps/meteor/app/authentication/server/lib/restrictLoginAttempts.ts +++ b/apps/meteor/app/authentication/server/lib/restrictLoginAttempts.ts @@ -1,12 +1,11 @@ import type { IServerEvent } from '@rocket.chat/core-typings'; import { ServerEventType } from '@rocket.chat/core-typings'; -import { Rooms, ServerEvents, Sessions, Users } from '@rocket.chat/models'; -import moment from 'moment'; +import { Logger } from '@rocket.chat/logger'; +import { Rooms, ServerEvents, Users } from '@rocket.chat/models'; import { addMinutesToADate } from '../../../../lib/utils/addMinutesToADate'; import { getClientAddress } from '../../../../server/lib/getClientAddress'; import { sendMessage } from '../../../lib/server/functions/sendMessage'; -import { Logger } from '../../../logger/server'; import { settings } from '../../../settings/server'; import type { ILoginAttempt } from '../ILoginAttempt'; @@ -58,36 +57,42 @@ export const isValidLoginAttemptByIp = async (ip: string): Promise => { return true; } - const lastLogin = await Sessions.findLastLoginByIp(ip); - let failedAttemptsSinceLastLogin; - - if (!lastLogin?.loginAt) { - failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByIp(ip); - } else { - failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByIpSince(ip, new Date(lastLogin.loginAt)); - } - + // misconfigured const attemptsUntilBlock = settings.get('Block_Multiple_Failed_Logins_Attempts_Until_Block_By_Ip'); + if (!attemptsUntilBlock) { + return true; + } - if (attemptsUntilBlock && failedAttemptsSinceLastLogin < attemptsUntilBlock) { + // if user never failed to login, then it's valid + const lastFailedAttemptAt = (await ServerEvents.findLastFailedAttemptByIp(ip))?.ts; + if (!lastFailedAttemptAt) { return true; } - const lastAttemptAt = (await ServerEvents.findLastFailedAttemptByIp(ip))?.ts; + const minutesUntilUnblock = settings.get('Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes'); - if (!lastAttemptAt) { + const lockoutTimeStart = addMinutesToADate(new Date(), minutesUntilUnblock * -1); + const lastSuccessfulAttemptAt = (await ServerEvents.findLastSuccessfulAttemptByIp(ip))?.ts; + + // successful logins should reset the counter + const startTime = lastSuccessfulAttemptAt + ? new Date(Math.max(lockoutTimeStart.getTime(), lastSuccessfulAttemptAt.getTime())) + : lockoutTimeStart; + + const failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByIpSince(ip, startTime); + + // if user didn't reach the threshold, then it's valid + if (failedAttemptsSinceLastLogin < attemptsUntilBlock) { return true; } - const minutesUntilUnblock = settings.get('Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes') as number; - const willBeBlockedUntil = addMinutesToADate(new Date(lastAttemptAt), minutesUntilUnblock); - const isValid = moment(new Date()).isSameOrAfter(willBeBlockedUntil); + if (settings.get('Block_Multiple_Failed_Logins_Notify_Failed')) { + const willBeBlockedUntil = addMinutesToADate(new Date(lastFailedAttemptAt), minutesUntilUnblock); - if (settings.get('Block_Multiple_Failed_Logins_Notify_Failed') && !isValid) { await notifyFailedLogin(ip, willBeBlockedUntil, failedAttemptsSinceLastLogin); } - return isValid; + return false; }; export const isValidAttemptByUser = async (login: ILoginAttempt): Promise => { @@ -96,41 +101,47 @@ export const isValidAttemptByUser = async (login: ILoginAttempt): Promise('Block_Multiple_Failed_Logins_Attempts_Until_Block_by_User'); + if (!attemptsUntilBlock) { + return true; + } - if (attemptsUntilBlock && failedAttemptsSinceLastLogin < attemptsUntilBlock) { + // if user never failed to login, then it's valid + const lastFailedAttemptAt = (await ServerEvents.findLastFailedAttemptByUsername(loginUsername))?.ts; + if (!lastFailedAttemptAt) { return true; } - const lastAttemptAt = (await ServerEvents.findLastFailedAttemptByUsername(user.username as string))?.ts; + const minutesUntilUnblock = settings.get('Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes'); + + const lockoutTimeStart = addMinutesToADate(new Date(), minutesUntilUnblock * -1); + const lastSuccessfulAttemptAt = (await ServerEvents.findLastSuccessfulAttemptByUsername(loginUsername))?.ts; + + // succesful logins should reset the counter + const startTime = lastSuccessfulAttemptAt + ? new Date(Math.max(lockoutTimeStart.getTime(), lastSuccessfulAttemptAt.getTime())) + : lockoutTimeStart; + + // get total failed attempts during the lockout time + const failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByUsernameSince(loginUsername, startTime); - if (!lastAttemptAt) { + // if user didn't reach the threshold, then it's valid + if (failedAttemptsSinceLastLogin < attemptsUntilBlock) { return true; } - const minutesUntilUnblock = settings.get('Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes') as number; - const willBeBlockedUntil = addMinutesToADate(new Date(lastAttemptAt), minutesUntilUnblock); - const isValid = moment(new Date()).isSameOrAfter(willBeBlockedUntil); + if (settings.get('Block_Multiple_Failed_Logins_Notify_Failed')) { + const willBeBlockedUntil = addMinutesToADate(new Date(lastFailedAttemptAt), minutesUntilUnblock); - if (settings.get('Block_Multiple_Failed_Logins_Notify_Failed') && !isValid) { - await notifyFailedLogin(user.username, willBeBlockedUntil, failedAttemptsSinceLastLogin); + await notifyFailedLogin(loginUsername, willBeBlockedUntil, failedAttemptsSinceLastLogin); } - return isValid; + return false; }; export const saveFailedLoginAttempts = async (login: ILoginAttempt): Promise => { diff --git a/apps/meteor/app/authentication/server/startup/index.js b/apps/meteor/app/authentication/server/startup/index.js index c86059274cad..e3b97c1aae88 100644 --- a/apps/meteor/app/authentication/server/startup/index.js +++ b/apps/meteor/app/authentication/server/startup/index.js @@ -249,11 +249,6 @@ const onCreateUserAsync = async function (options, user = {}) { await callbacks.run('onCreateUser', options, user); } - if (!options.skipAppsEngineEvent) { - // App IPostUserCreated event hook - await Apps.triggerEvent(AppEvents.IPostUserCreated, { user, performedBy: await safeGetMeteorUser() }); - } - if (!options.skipEmailValidation && !validateEmailDomain(user)) { throw new Meteor.Error(403, 'User validation failed'); } @@ -298,6 +293,11 @@ const insertUserDocAsync = async function (options, user) { }; } + // Make sure that the user has the field 'roles' + if (!user.roles) { + user.roles = []; + } + const _id = insertUserDoc.call(Accounts, options, user); user = await Users.findOne({ @@ -348,6 +348,13 @@ const insertUserDocAsync = async function (options, user) { } } + if (!options.skipAppsEngineEvent) { + // `post` triggered events don't need to wait for the promise to resolve + Apps.triggerEvent(AppEvents.IPostUserCreated, { user, performedBy: await safeGetMeteorUser() }).catch((e) => { + Apps.getRocketChatLogger().error('Error while executing post user created event:', e); + }); + } + return _id; }; diff --git a/apps/meteor/app/authorization/server/constant/permissions.ts b/apps/meteor/app/authorization/server/constant/permissions.ts index fc917028c33f..7b5f1594e5c3 100644 --- a/apps/meteor/app/authorization/server/constant/permissions.ts +++ b/apps/meteor/app/authorization/server/constant/permissions.ts @@ -10,6 +10,8 @@ export const permissions = [ { _id: 'add-user-to-joined-room', roles: ['admin', 'owner', 'moderator'] }, { _id: 'add-user-to-any-c-room', roles: ['admin'] }, { _id: 'add-user-to-any-p-room', roles: [] }, + { _id: 'kick-user-from-any-c-room', roles: ['admin'] }, + { _id: 'kick-user-from-any-p-room', roles: [] }, { _id: 'api-bypass-rate-limit', roles: ['admin', 'bot', 'app'] }, { _id: 'archive-room', roles: ['admin', 'owner'] }, { _id: 'assign-admin-role', roles: ['admin'] }, diff --git a/apps/meteor/app/authorization/server/functions/upsertPermissions.ts b/apps/meteor/app/authorization/server/functions/upsertPermissions.ts index 41b1ffbc2c52..bb908916c885 100644 --- a/apps/meteor/app/authorization/server/functions/upsertPermissions.ts +++ b/apps/meteor/app/authorization/server/functions/upsertPermissions.ts @@ -35,9 +35,7 @@ export const upsertPermissions = async (): Promise => { [key: string]: IPermission; } = {}; - const selector = { level: 'settings' as const, ...(settingId && { settingId }) }; - - await Permissions.find(selector).forEach((permission: IPermission) => { + await Permissions.findByLevel('settings', settingId).forEach((permission: IPermission) => { previousSettingPermissions[permission._id] = permission; }); return previousSettingPermissions; diff --git a/apps/meteor/app/autotranslate/client/lib/actionButton.ts b/apps/meteor/app/autotranslate/client/lib/actionButton.ts index 29c4cfb4778f..24cea2d8d28a 100644 --- a/apps/meteor/app/autotranslate/client/lib/actionButton.ts +++ b/apps/meteor/app/autotranslate/client/lib/actionButton.ts @@ -24,6 +24,7 @@ Meteor.startup(() => { icon: 'language', label: 'Translate', context: ['message', 'message-mobile', 'threads'], + type: 'interaction', action(_, props) { const { message = messageArgs(this).msg } = props; const language = AutoTranslate.getLanguage(message.rid); @@ -58,6 +59,7 @@ Meteor.startup(() => { icon: 'language', label: 'View_original', context: ['message', 'message-mobile', 'threads'], + type: 'interaction', action(_, props) { const { message = messageArgs(this).msg } = props; const language = AutoTranslate.getLanguage(message.rid); diff --git a/apps/meteor/app/autotranslate/server/autotranslate.ts b/apps/meteor/app/autotranslate/server/autotranslate.ts index 8acef65b167f..076c7c52818a 100644 --- a/apps/meteor/app/autotranslate/server/autotranslate.ts +++ b/apps/meteor/app/autotranslate/server/autotranslate.ts @@ -7,6 +7,7 @@ import type { ISupportedLanguage, ITranslationResult, } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; import { Messages, Subscriptions } from '@rocket.chat/models'; import { escapeHTML } from '@rocket.chat/string-helpers'; import { Meteor } from 'meteor/meteor'; @@ -14,7 +15,6 @@ import _ from 'underscore'; import { callbacks } from '../../../lib/callbacks'; import { isTruthy } from '../../../lib/isTruthy'; -import { Logger } from '../../logger/server'; import { Markdown } from '../../markdown/server'; import { settings } from '../../settings/server'; diff --git a/apps/meteor/app/autotranslate/server/logger.ts b/apps/meteor/app/autotranslate/server/logger.ts index 70ca0cbf97b6..9fbd129acd73 100644 --- a/apps/meteor/app/autotranslate/server/logger.ts +++ b/apps/meteor/app/autotranslate/server/logger.ts @@ -1,4 +1,4 @@ -import { Logger } from '../../logger/server'; +import { Logger } from '@rocket.chat/logger'; const logger = new Logger('AutoTranslate'); diff --git a/apps/meteor/app/autotranslate/server/methods/saveSettings.ts b/apps/meteor/app/autotranslate/server/methods/saveSettings.ts index 1ba5bcdfcd76..e396d78887a9 100644 --- a/apps/meteor/app/autotranslate/server/methods/saveSettings.ts +++ b/apps/meteor/app/autotranslate/server/methods/saveSettings.ts @@ -1,4 +1,4 @@ -import { Subscriptions } from '@rocket.chat/models'; +import { Subscriptions, Rooms } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -46,6 +46,13 @@ Meteor.methods({ switch (field) { case 'autoTranslate': + const room = await Rooms.findE2ERoomById(rid, { projection: { _id: 1 } }); + if (room && value === '1') { + throw new Meteor.Error('error-e2e-enabled', 'Enabling auto-translation in E2E encrypted rooms is not allowed', { + method: 'saveAutoTranslateSettings', + }); + } + await Subscriptions.updateAutoTranslateById(subscription._id, value === '1'); if (!subscription.autoTranslateLanguage && options.defaultLanguage) { await Subscriptions.updateAutoTranslateLanguageById(subscription._id, options.defaultLanguage); diff --git a/apps/meteor/app/autotranslate/server/msTranslate.ts b/apps/meteor/app/autotranslate/server/msTranslate.ts index 3e9c9dbd8a35..f885a23b8e6b 100644 --- a/apps/meteor/app/autotranslate/server/msTranslate.ts +++ b/apps/meteor/app/autotranslate/server/msTranslate.ts @@ -87,7 +87,7 @@ class MsAutoTranslate extends AutoTranslate { if (this.supportedLanguages[target]) { return this.supportedLanguages[target]; } - const request = await fetch(this.apiEndPointUrl); + const request = await fetch(this.apiGetLanguages); if (!request.ok) { throw new Error(request.statusText); } diff --git a/apps/meteor/app/cas/server/cas_rocketchat.js b/apps/meteor/app/cas/server/cas_rocketchat.js index 08e0c5679046..f0b62b6ccb8a 100644 --- a/apps/meteor/app/cas/server/cas_rocketchat.js +++ b/apps/meteor/app/cas/server/cas_rocketchat.js @@ -1,6 +1,6 @@ +import { Logger } from '@rocket.chat/logger'; import { ServiceConfiguration } from 'meteor/service-configuration'; -import { Logger } from '../../logger/server'; import { settings } from '../../settings/server'; export const logger = new Logger('CAS'); diff --git a/apps/meteor/app/channel-settings/server/functions/saveRoomEncrypted.ts b/apps/meteor/app/channel-settings/server/functions/saveRoomEncrypted.ts index dc57307b1c4c..ed07540ba2b0 100644 --- a/apps/meteor/app/channel-settings/server/functions/saveRoomEncrypted.ts +++ b/apps/meteor/app/channel-settings/server/functions/saveRoomEncrypted.ts @@ -1,7 +1,7 @@ import { Message } from '@rocket.chat/core-services'; import type { IUser } from '@rocket.chat/core-typings'; import { isRegisterUser } from '@rocket.chat/core-typings'; -import { Rooms } from '@rocket.chat/models'; +import { Rooms, Subscriptions } from '@rocket.chat/models'; import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import type { UpdateResult } from 'mongodb'; @@ -25,5 +25,9 @@ export const saveRoomEncrypted = async function (rid: string, encrypted: boolean await Message.saveSystemMessage(type, rid, user.username, user); } + + if (encrypted) { + await Subscriptions.disableAutoTranslateByRoomId(rid); + } return update; }; diff --git a/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts b/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts index d65897b72094..c2bd91e82dd8 100644 --- a/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts +++ b/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts @@ -1,50 +1,59 @@ -import type { SettingValue } from '@rocket.chat/core-typings'; import { Statistics, Users } from '@rocket.chat/models'; import { settings } from '../../../settings/server'; import { statistics } from '../../../statistics/server'; +import { Info } from '../../../utils/rocketchat.info'; import { LICENSE_VERSION } from '../license'; -type WorkspaceRegistrationData = { +export type WorkspaceRegistrationData = { uniqueId: string; - workspaceId: SettingValue; - address: SettingValue; + deploymentFingerprintHash: string; + deploymentFingerprintVerified: boolean; + workspaceId: string; + address: string; contactName: string; contactEmail: T; seats: number; - allowMarketing: SettingValue; - accountName: SettingValue; - organizationType: unknown; - industry: unknown; - orgSize: unknown; - country: unknown; - language: unknown; - agreePrivacyTerms: SettingValue; - website: SettingValue; - siteName: SettingValue; + + organizationType: string; + industry: string; + orgSize: string; + country: string; + language: string; + allowMarketing: string; + accountName: string; + agreePrivacyTerms: string; + website: string; + siteName: string; workspaceType: unknown; deploymentMethod: string; deploymentPlatform: string; - version: unknown; + version: string; licenseVersion: number; enterpriseReady: boolean; setupComplete: boolean; connectionDisable: boolean; - npsEnabled: SettingValue; + npsEnabled: string; + // TODO: Evaluate naming + MAC: number; + // activeContactsBillingMonth: number; + // activeContactsYesterday: number; }; export async function buildWorkspaceRegistrationData(contactEmail: T): Promise> { const stats = (await Statistics.findLast()) || (await statistics.get()); - const address = settings.get('Site_Url'); - const siteName = settings.get('Site_Name'); - const workspaceId = settings.get('Cloud_Workspace_Id'); - const allowMarketing = settings.get('Allow_Marketing_Emails'); - const accountName = settings.get('Organization_Name'); - const website = settings.get('Website'); - const npsEnabled = settings.get('NPS_survey_enabled'); - const agreePrivacyTerms = settings.get('Cloud_Service_Agree_PrivacyTerms'); - const setupWizardState = settings.get('Show_Setup_Wizard'); + const address = settings.get('Site_Url'); + const siteName = settings.get('Site_Name'); + const workspaceId = settings.get('Cloud_Workspace_Id'); + const allowMarketing = settings.get('Allow_Marketing_Emails'); + const accountName = settings.get('Organization_Name'); + const website = settings.get('Website'); + const npsEnabled = settings.get('NPS_survey_enabled'); + const agreePrivacyTerms = settings.get('Cloud_Service_Agree_PrivacyTerms'); + const setupWizardState = settings.get('Show_Setup_Wizard'); + const deploymentFingerprintHash = settings.get('Deployment_FingerPrint_Hash'); + const deploymentFingerprintVerified = settings.get('Deployment_FingerPrint_Verified'); const firstUser = await Users.getOldest({ projection: { name: 1, emails: 1 } }); const contactName = firstUser?.name || ''; @@ -54,6 +63,8 @@ export async function buildWorkspaceRegistrationData { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/oauth/clients`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body, + }); + + if (!response.ok) { + try { + const { error } = await response.json(); + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`); + } catch (error) { + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${response.statusText}`); + } } - // shouldn't get here due to checking this on the method - // but this is just to double check - if (!token) { - return new Error('Invalid token; the registration token is required.'); + const payload = await response.json(); + + if (!payload) { + return undefined; } - const redirectUri = getRedirectUri(); + return payload; +}; - const regInfo = { - email: settings.get('Organization_Email'), - client_name: settings.get('Site_Name'), - redirect_uris: [redirectUri], - }; +export async function connectWorkspace(token: string) { + if (!token) { + throw new CloudWorkspaceConnectionError('Invalid registration token'); + } - const cloudUrl = settings.get('Cloud_Url'); - let result; try { - const request = await fetch(`${cloudUrl}/api/oauth/clients`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - }, - body: regInfo, - }); + const redirectUri = getRedirectUri(); + + const body = { + email: settings.get('Organization_Email'), + client_name: settings.get('Site_Name'), + redirect_uris: [redirectUri], + }; + + const payload = await fetchRegistrationDataPayload({ token, body }); - if (!request.ok) { - throw new Error((await request.json()).error); + if (!payload) { + return false; } - result = await request.json(); - } catch (err: any) { + await saveRegistrationData(payload); + + return true; + } catch (err) { SystemLogger.error({ msg: 'Failed to Connect with Rocket.Chat Cloud', url: '/api/oauth/clients', @@ -52,12 +76,4 @@ export async function connectWorkspace(token: string) { return false; } - - if (!result) { - return false; - } - - await saveRegistrationData(result); - - return true; } diff --git a/apps/meteor/app/cloud/server/functions/disconnectWorkspace.ts b/apps/meteor/app/cloud/server/functions/disconnectWorkspace.ts deleted file mode 100644 index c72a96297f37..000000000000 --- a/apps/meteor/app/cloud/server/functions/disconnectWorkspace.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Settings } from '@rocket.chat/models'; - -import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; -import { syncWorkspace } from './syncWorkspace'; - -export async function disconnectWorkspace() { - const { connectToCloud } = await retrieveRegistrationStatus(); - if (!connectToCloud) { - return true; - } - - await Settings.updateValueById('Register_Server', false); - - await syncWorkspace(true); - - return true; -} diff --git a/apps/meteor/app/cloud/server/functions/finishOAuthAuthorization.ts b/apps/meteor/app/cloud/server/functions/finishOAuthAuthorization.ts index 780aa5c67a99..61b3a77966e7 100644 --- a/apps/meteor/app/cloud/server/functions/finishOAuthAuthorization.ts +++ b/apps/meteor/app/cloud/server/functions/finishOAuthAuthorization.ts @@ -14,15 +14,15 @@ export async function finishOAuthAuthorization(code: string, state: string) { }); } - const cloudUrl = settings.get('Cloud_Url'); const clientId = settings.get('Cloud_Workspace_Client_Id'); const clientSecret = settings.get('Cloud_Workspace_Client_Secret'); const scope = userScopes.join(' '); - let result; + let payload; try { - const request = await fetch(`${cloudUrl}/api/oauth/token`, { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, params: new URLSearchParams({ @@ -35,11 +35,11 @@ export async function finishOAuthAuthorization(code: string, state: string) { }), }); - if (!request.ok) { - throw new Error((await request.json()).error); + if (!response.ok) { + throw new Error((await response.json()).error); } - result = await request.json(); + payload = await response.json(); } catch (err) { SystemLogger.error({ msg: 'Failed to finish OAuth authorization with Rocket.Chat Cloud', @@ -51,7 +51,7 @@ export async function finishOAuthAuthorization(code: string, state: string) { } const expiresAt = new Date(); - expiresAt.setSeconds(expiresAt.getSeconds() + result.expires_in); + expiresAt.setSeconds(expiresAt.getSeconds() + payload.expires_in); const uid = Meteor.userId(); if (!uid) { @@ -65,11 +65,11 @@ export async function finishOAuthAuthorization(code: string, state: string) { { $set: { 'services.cloud': { - accessToken: result.access_token, + accessToken: payload.access_token, expiresAt, - scope: result.scope, - tokenType: result.token_type, - refreshToken: result.refresh_token, + scope: payload.scope, + tokenType: payload.token_type, + refreshToken: payload.refresh_token, }, }, }, diff --git a/apps/meteor/app/cloud/server/functions/getConfirmationPoll.ts b/apps/meteor/app/cloud/server/functions/getConfirmationPoll.ts index 4a35c9834ba5..2c5d9dec77dc 100644 --- a/apps/meteor/app/cloud/server/functions/getConfirmationPoll.ts +++ b/apps/meteor/app/cloud/server/functions/getConfirmationPoll.ts @@ -5,16 +5,16 @@ import { SystemLogger } from '../../../../server/lib/logger/system'; import { settings } from '../../../settings/server'; export async function getConfirmationPoll(deviceCode: string): Promise { - const cloudUrl = settings.get('Cloud_Url'); - - let result; + let payload; try { - const request = await fetch(`${cloudUrl}/api/v2/register/workspace/poll`, { params: { token: deviceCode } }); - if (!request.ok) { - throw new Error((await request.json()).error); + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/v2/register/workspace/poll`, { params: { token: deviceCode } }); + + if (!response.ok) { + throw new Error((await response.json()).error); } - result = await request.json(); + payload = await response.json(); } catch (err: any) { SystemLogger.error({ msg: 'Failed to get confirmation poll from Rocket.Chat Cloud', @@ -25,9 +25,9 @@ export async function getConfirmationPoll(deviceCode: string): Promise>(userId, { projection: { 'services.cloud': 1 } }); - if (!user?.services?.cloud?.accessToken || !user?.services?.cloud?.refreshToken) { - return ''; - } - - const { accessToken, refreshToken, expiresAt } = user.services.cloud; - - const clientId = settings.get('Cloud_Workspace_Client_Id'); - if (!clientId) { - return ''; - } - - const clientSecret = settings.get('Cloud_Workspace_Client_Secret'); - if (!clientSecret) { - return ''; - } - - const now = new Date(); - - if (now < expiresAt && !forceNew) { - return accessToken; - } - - const cloudUrl = settings.get('Cloud_Url'); - const redirectUri = getRedirectUri(); - - if (scope === '') { - scope = userScopes.join(' '); - } - - let authTokenResult; - try { - const request = await fetch(`${cloudUrl}/api/oauth/token`, { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - method: 'POST', - params: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - refresh_token: refreshToken, - scope, - grant_type: 'refresh_token', - redirect_uri: redirectUri, - }), - }); - - if (!request.ok) { - throw new Error((await request.json()).error); - } - - authTokenResult = await request.json(); - } catch (err: any) { - SystemLogger.error({ - msg: 'Failed to get User AccessToken from Rocket.Chat Cloud', - url: '/api/oauth/token', - err, - }); - - if (err) { - if (err.message.includes('oauth_invalid_client_credentials')) { - SystemLogger.error('Server has been unregistered from cloud'); - await removeWorkspaceRegistrationInfo(); - } - - if (err.message.includes('unauthorized')) { - await userLoggedOut(userId); - } - } - - return ''; - } - - if (save) { - const willExpireAt = new Date(); - willExpireAt.setSeconds(willExpireAt.getSeconds() + authTokenResult.expires_in); - - await Users.updateOne( - { _id: user._id }, - { - $set: { - 'services.cloud': { - accessToken: authTokenResult.access_token, - expiresAt: willExpireAt, - }, - }, - }, - ); - } - - return authTokenResult.access_token; -} diff --git a/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts b/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts index 1a69d108ae4c..b495e3342d4b 100644 --- a/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts +++ b/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts @@ -10,10 +10,10 @@ import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; * @param {boolean} save * @returns string */ -export async function getWorkspaceAccessToken(forceNew = false, scope = '', save = true) { - const { connectToCloud, workspaceRegistered } = await retrieveRegistrationStatus(); +export async function getWorkspaceAccessToken(forceNew = false, scope = '', save = true): Promise { + const { workspaceRegistered } = await retrieveRegistrationStatus(); - if (!connectToCloud || !workspaceRegistered) { + if (!workspaceRegistered) { return ''; } @@ -22,10 +22,11 @@ export async function getWorkspaceAccessToken(forceNew = false, scope = '', save if (expires === null) { throw new Error('Cloud_Workspace_Access_Token_Expires_At is not set'); } + const now = new Date(); if (expires.value && now < expires.value && !forceNew) { - return settings.get('Cloud_Workspace_Access_Token'); + return settings.get('Cloud_Workspace_Access_Token'); } const accessToken = await getWorkspaceAccessTokenWithScope(scope); @@ -39,3 +40,46 @@ export async function getWorkspaceAccessToken(forceNew = false, scope = '', save return accessToken.token; } + +export class CloudWorkspaceAccessTokenError extends Error { + constructor() { + super('Could not get workspace access token'); + } +} + +export async function getWorkspaceAccessTokenOrThrow(forceNew = false, scope = '', save = true): Promise { + const token = await getWorkspaceAccessToken(forceNew, scope, save); + + if (!token) { + throw new CloudWorkspaceAccessTokenError(); + } + + return token; +} + +export const generateWorkspaceBearerHttpHeaderOrThrow = async ( + forceNew = false, + scope = '', + save = true, +): Promise<{ Authorization: string }> => { + const token = await getWorkspaceAccessTokenOrThrow(forceNew, scope, save); + return { + Authorization: `Bearer ${token}`, + }; +}; + +export const generateWorkspaceBearerHttpHeader = async ( + forceNew = false, + scope = '', + save = true, +): Promise<{ Authorization: string } | undefined> => { + const token = await getWorkspaceAccessToken(forceNew, scope, save); + + if (!token) { + return undefined; + } + + return { + Authorization: `Bearer ${token}`, + }; +}; diff --git a/apps/meteor/app/cloud/server/functions/getWorkspaceAccessTokenWithScope.ts b/apps/meteor/app/cloud/server/functions/getWorkspaceAccessTokenWithScope.ts index 4a0c4b5fe394..88509902cb6d 100644 --- a/apps/meteor/app/cloud/server/functions/getWorkspaceAccessTokenWithScope.ts +++ b/apps/meteor/app/cloud/server/functions/getWorkspaceAccessTokenWithScope.ts @@ -8,11 +8,11 @@ import { removeWorkspaceRegistrationInfo } from './removeWorkspaceRegistrationIn import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; export async function getWorkspaceAccessTokenWithScope(scope = '') { - const { connectToCloud, workspaceRegistered } = await retrieveRegistrationStatus(); + const { workspaceRegistered } = await retrieveRegistrationStatus(); const tokenResponse = { token: '', expiresAt: new Date() }; - if (!connectToCloud || !workspaceRegistered) { + if (!workspaceRegistered) { return tokenResponse; } @@ -26,12 +26,11 @@ export async function getWorkspaceAccessTokenWithScope(scope = '') { scope = workspaceScopes.join(' '); } - const cloudUrl = settings.get('Cloud_Url'); // eslint-disable-next-line @typescript-eslint/naming-convention const client_secret = settings.get('Cloud_Workspace_Client_Secret'); const redirectUri = getRedirectUri(); - let authTokenResult; + let payload; try { const body = new URLSearchParams(); body.append('client_id', client_id); @@ -40,12 +39,13 @@ export async function getWorkspaceAccessTokenWithScope(scope = '') { body.append('grant_type', 'client_credentials'); body.append('redirect_uri', redirectUri); - const result = await fetch(`${cloudUrl}/api/oauth/token`, { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/oauth/token`, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, method: 'POST', body, }); - authTokenResult = await result.json(); + payload = await response.json(); } catch (err: any) { SystemLogger.error({ msg: 'Failed to get Workspace AccessToken from Rocket.Chat Cloud', @@ -64,10 +64,10 @@ export async function getWorkspaceAccessTokenWithScope(scope = '') { } const expiresAt = new Date(); - expiresAt.setSeconds(expiresAt.getSeconds() + authTokenResult.expires_in); + expiresAt.setSeconds(expiresAt.getSeconds() + payload.expires_in); tokenResponse.expiresAt = expiresAt; - tokenResponse.token = authTokenResult.access_token; + tokenResponse.token = payload.access_token; return tokenResponse; } diff --git a/apps/meteor/app/cloud/server/functions/getWorkspaceKey.ts b/apps/meteor/app/cloud/server/functions/getWorkspaceKey.ts index f3b6dfc4238a..639f29402fe9 100644 --- a/apps/meteor/app/cloud/server/functions/getWorkspaceKey.ts +++ b/apps/meteor/app/cloud/server/functions/getWorkspaceKey.ts @@ -2,9 +2,9 @@ import { settings } from '../../../settings/server'; import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; export async function getWorkspaceKey() { - const { connectToCloud, workspaceRegistered } = await retrieveRegistrationStatus(); + const { workspaceRegistered } = await retrieveRegistrationStatus(); - if (!connectToCloud || !workspaceRegistered) { + if (!workspaceRegistered) { return false; } diff --git a/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts b/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts index 275e646e5343..f9f0cfadc669 100644 --- a/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts +++ b/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts @@ -1,68 +1,94 @@ +import type { Cloud, Serialized } from '@rocket.chat/core-typings'; import { Settings } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { v, compile } from 'suretype'; import { callbacks } from '../../../../lib/callbacks'; +import { CloudWorkspaceConnectionError } from '../../../../lib/errors/CloudWorkspaceConnectionError'; +import { CloudWorkspaceLicenseError } from '../../../../lib/errors/CloudWorkspaceLicenseError'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { settings } from '../../../settings/server'; import { LICENSE_VERSION } from '../license'; import { getWorkspaceAccessToken } from './getWorkspaceAccessToken'; +const workspaceLicensePayloadSchema = v.object({ + version: v.number().required(), + address: v.string().required(), + license: v.string().required(), + updatedAt: v.string().format('date-time').required(), + modules: v.string().required(), + expireAt: v.string().format('date-time').required(), +}); + +const assertWorkspaceLicensePayload = compile(workspaceLicensePayloadSchema); + +const fetchCloudWorkspaceLicensePayload = async ({ token }: { token: string }): Promise> => { + const workspaceRegistrationClientUri = settings.get('Cloud_Workspace_Registration_Client_Uri'); + const response = await fetch(`${workspaceRegistrationClientUri}/license`, { + headers: { + Authorization: `Bearer ${token}`, + }, + params: { + version: LICENSE_VERSION, + }, + }); + + if (!response.ok) { + try { + const { error } = await response.json(); + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`); + } catch (error) { + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${response.statusText}`); + } + } + + const payload = await response.json(); + + assertWorkspaceLicensePayload(payload); + + return payload; +}; + export async function getWorkspaceLicense(): Promise<{ updated: boolean; license: string }> { const currentLicense = await Settings.findOne('Cloud_Workspace_License'); + // it should never happen, since even if the license is not found, it will return an empty settings + if (!currentLicense?._updatedAt) { + throw new CloudWorkspaceLicenseError('Failed to retrieve current license'); + } - const cachedLicenseReturn = async () => { - const license = currentLicense?.value as string; + const fromCurrentLicense = async () => { + const license = currentLicense?.value as string | undefined; if (license) { await callbacks.run('workspaceLicenseChanged', license); } - return { updated: false, license }; + return { updated: false, license: license ?? '' }; }; - const token = await getWorkspaceAccessToken(); - if (!token) { - return cachedLicenseReturn(); - } - - let licenseResult; try { - const request = await fetch(`${settings.get('Cloud_Workspace_Registration_Client_Uri')}/license`, { - headers: { - Authorization: `Bearer ${token}`, - }, - params: { - version: LICENSE_VERSION, - }, - }); + const token = await getWorkspaceAccessToken(); + if (!token) { + return fromCurrentLicense(); + } + + const payload = await fetchCloudWorkspaceLicensePayload({ token }); - if (!request.ok) { - throw new Error((await request.json()).error); + if (currentLicense.value && Date.parse(payload.updatedAt) <= currentLicense._updatedAt.getTime()) { + return fromCurrentLicense(); } - licenseResult = await request.json(); - } catch (err: any) { + await Settings.updateValueById('Cloud_Workspace_License', payload.license); + + await callbacks.run('workspaceLicenseChanged', payload.license); + + return { updated: true, license: payload.license }; + } catch (err) { SystemLogger.error({ msg: 'Failed to update license from Rocket.Chat Cloud', url: '/license', err, }); - return cachedLicenseReturn(); - } - - const remoteLicense = licenseResult; - - if (!currentLicense || !currentLicense._updatedAt) { - throw new Error('Failed to retrieve current license'); - } - - if (remoteLicense.updatedAt <= currentLicense._updatedAt) { - return cachedLicenseReturn(); + return fromCurrentLicense(); } - - await Settings.updateValueById('Cloud_Workspace_License', remoteLicense.license); - - await callbacks.run('workspaceLicenseChanged', remoteLicense.license); - - return { updated: true, license: remoteLicense.license }; } diff --git a/apps/meteor/app/cloud/server/functions/reconnectWorkspace.ts b/apps/meteor/app/cloud/server/functions/reconnectWorkspace.ts index db425d2e8a30..7ee02a5e5de4 100644 --- a/apps/meteor/app/cloud/server/functions/reconnectWorkspace.ts +++ b/apps/meteor/app/cloud/server/functions/reconnectWorkspace.ts @@ -11,7 +11,7 @@ export async function reconnectWorkspace() { await Settings.updateValueById('Register_Server', true); - await syncWorkspace(true); + await syncWorkspace(); return true; } diff --git a/apps/meteor/app/cloud/server/functions/registerPreIntentWorkspaceWizard.ts b/apps/meteor/app/cloud/server/functions/registerPreIntentWorkspaceWizard.ts index 2a04aa54cfe7..ce415d2aa983 100644 --- a/apps/meteor/app/cloud/server/functions/registerPreIntentWorkspaceWizard.ts +++ b/apps/meteor/app/cloud/server/functions/registerPreIntentWorkspaceWizard.ts @@ -15,16 +15,16 @@ export async function registerPreIntentWorkspaceWizard(): Promise { } const regInfo = await buildWorkspaceRegistrationData(email); - const cloudUrl = settings.get('Cloud_Url'); try { - const request = await fetch(`${cloudUrl}/api/v2/register/workspace/pre-intent`, { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/v2/register/workspace/pre-intent`, { + method: 'POST', body: regInfo, timeout: 10 * 1000, - method: 'POST', }); - if (!request.ok) { - throw new Error((await request.json()).error); + if (!response.ok) { + throw new Error((await response.json()).error); } return true; diff --git a/apps/meteor/app/cloud/server/functions/retrieveRegistrationStatus.ts b/apps/meteor/app/cloud/server/functions/retrieveRegistrationStatus.ts index 55698f4d27af..0291534ac637 100644 --- a/apps/meteor/app/cloud/server/functions/retrieveRegistrationStatus.ts +++ b/apps/meteor/app/cloud/server/functions/retrieveRegistrationStatus.ts @@ -3,7 +3,6 @@ import { Users } from '@rocket.chat/models'; import { settings } from '../../../settings/server'; export async function retrieveRegistrationStatus(): Promise<{ - connectToCloud: boolean; workspaceRegistered: boolean; workspaceId: string; uniqueId: string; @@ -11,7 +10,6 @@ export async function retrieveRegistrationStatus(): Promise<{ email: string; }> { const info = { - connectToCloud: settings.get('Register_Server'), workspaceRegistered: !!settings.get('Cloud_Workspace_Client_Id') && !!settings.get('Cloud_Workspace_Client_Secret'), workspaceId: settings.get('Cloud_Workspace_Id'), uniqueId: settings.get('uniqueID'), diff --git a/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts b/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts index de9fafc99065..5f5df80d0d3d 100644 --- a/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts +++ b/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts @@ -8,9 +8,9 @@ import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; import { syncWorkspace } from './syncWorkspace'; export async function startRegisterWorkspace(resend = false) { - const { workspaceRegistered, connectToCloud } = await retrieveRegistrationStatus(); - if ((workspaceRegistered && connectToCloud) || process.env.TEST_MODE) { - await syncWorkspace(true); + const { workspaceRegistered } = await retrieveRegistrationStatus(); + if (workspaceRegistered || process.env.TEST_MODE) { + await syncWorkspace(); return true; } @@ -19,22 +19,21 @@ export async function startRegisterWorkspace(resend = false) { const regInfo = await buildWorkspaceRegistrationData(undefined); - const cloudUrl = settings.get('Cloud_Url'); - - let result; + let payload; try { - const request = await fetch(`${cloudUrl}/api/v2/register/workspace`, { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/v2/register/workspace`, { method: 'POST', body: regInfo, params: { resend, }, }); - if (!request.ok) { - throw new Error((await request.json()).error); + if (!response.ok) { + throw new Error((await response.json()).error); } - result = await request.json(); + payload = await response.json(); } catch (err: any) { SystemLogger.error({ msg: 'Failed to register with Rocket.Chat Cloud', @@ -44,11 +43,11 @@ export async function startRegisterWorkspace(resend = false) { return false; } - if (!result) { + if (!payload) { return false; } - await Settings.updateValueById('Cloud_Workspace_Id', result.id); + await Settings.updateValueById('Cloud_Workspace_Id', payload.id); return true; } diff --git a/apps/meteor/app/cloud/server/functions/startRegisterWorkspaceSetupWizard.ts b/apps/meteor/app/cloud/server/functions/startRegisterWorkspaceSetupWizard.ts index 3afe84c409ec..382478db61c7 100644 --- a/apps/meteor/app/cloud/server/functions/startRegisterWorkspaceSetupWizard.ts +++ b/apps/meteor/app/cloud/server/functions/startRegisterWorkspaceSetupWizard.ts @@ -7,22 +7,22 @@ import { buildWorkspaceRegistrationData } from './buildRegistrationData'; export async function startRegisterWorkspaceSetupWizard(resend = false, email: string): Promise { const regInfo = await buildWorkspaceRegistrationData(email); - const cloudUrl = settings.get('Cloud_Url'); - let result; + let payload; try { - const request = await fetch(`${cloudUrl}/api/v2/register/workspace/intent`, { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/v2/register/workspace/intent`, { body: regInfo, method: 'POST', params: { resent: resend, }, }); - if (!request.ok) { - throw new Error((await request.json()).error); + if (!response.ok) { + throw new Error((await response.json()).error); } - result = await request.json(); + payload = await response.json(); } catch (err: any) { SystemLogger.error({ msg: 'Failed to register workspace intent with Rocket.Chat Cloud', @@ -33,9 +33,9 @@ export async function startRegisterWorkspaceSetupWizard(resend = false, email: s throw err; } - if (!result) { + if (!payload) { throw new Error('Failed to fetch registration intent endpoint'); } - return result; + return payload; } diff --git a/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsChooseLatest.spec.ts b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsChooseLatest.spec.ts new file mode 100644 index 000000000000..183065fd92a6 --- /dev/null +++ b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsChooseLatest.spec.ts @@ -0,0 +1,23 @@ +import type { SignedSupportedVersions } from '@rocket.chat/server-cloud-communication'; + +import { supportedVersionsChooseLatest } from './supportedVersionsChooseLatest'; + +describe('supportedVersionsChooseLatest', () => { + test('should return the latest version', async () => { + const versionFromLicense: SignedSupportedVersions = { + signed: 'signed____', + timestamp: '2021-08-31T18:00:00.000Z', + versions: [], + }; + + const versionFromCloud: SignedSupportedVersions = { + signed: 'signed_------', + timestamp: '2021-08-31T19:00:00.000Z', + versions: [], + }; + + const result = await supportedVersionsChooseLatest(versionFromLicense, versionFromCloud); + + expect(result.timestamp).toBe(versionFromCloud.timestamp); + }); +}); diff --git a/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsChooseLatest.ts b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsChooseLatest.ts new file mode 100644 index 000000000000..f0683535de6b --- /dev/null +++ b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsChooseLatest.ts @@ -0,0 +1,9 @@ +import type { SignedSupportedVersions } from '@rocket.chat/server-cloud-communication'; + +export const supportedVersionsChooseLatest = async (...tokens: (SignedSupportedVersions | undefined)[]) => { + const [token] = (tokens.filter(Boolean) as SignedSupportedVersions[]).sort((a, b) => { + return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(); + }); + + return token; +}; diff --git a/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts new file mode 100644 index 000000000000..577abd4383d0 --- /dev/null +++ b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts @@ -0,0 +1,130 @@ +import type { SettingValue } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import { Settings } from '@rocket.chat/models'; +import type { SignedSupportedVersions, SupportedVersions } from '@rocket.chat/server-cloud-communication'; +import type { Response } from '@rocket.chat/server-fetch'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { SystemLogger } from '../../../../../server/lib/logger/system'; +import { settings } from '../../../../settings/server'; +import { generateWorkspaceBearerHttpHeader } from '../getWorkspaceAccessToken'; +import { supportedVersionsChooseLatest } from './supportedVersionsChooseLatest'; + +declare module '@rocket.chat/license' { + interface ILicenseV3 { + supportedVersions?: SignedSupportedVersions; + } +} + +/** HELPERS */ + +export const wrapPromise = ( + promise: Promise, +): Promise< + | { + success: true; + result: T; + } + | { + success: false; + error: any; + } +> => + promise + .then((result) => ({ success: true, result } as const)) + .catch((error) => ({ + success: false, + error, + })); + +export const handleResponse = async (promise: Promise) => { + return wrapPromise( + (async () => { + const request = await promise; + if (!request.ok) { + if (request.size > 0) { + throw new Error((await request.json()).error); + } + throw new Error(request.statusText); + } + + return request.json(); + })(), + ); +}; + +const cacheValueInSettings = ( + key: string, + fn: () => Promise, +): (() => Promise) & { + reset: () => Promise; +} => { + const reset = async () => { + const value = await fn(); + + await Settings.updateValueById(key, value); + + return value; + }; + + return Object.assign( + async () => { + const storedValue = settings.get(key); + + if (storedValue) { + return storedValue; + } + + return reset(); + }, + { + reset, + }, + ); +}; + +/** CODE */ + +const getSupportedVersionsFromCloud = async () => { + if (process.env.CLOUD_SUPPORTED_VERSIONS_TOKEN) { + return { + success: true, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + result: JSON.parse(process.env.CLOUD_SUPPORTED_VERSIONS!), + }; + } + + const headers = await generateWorkspaceBearerHttpHeader(); + + const response = await handleResponse( + fetch('https://releases.rocket.chat/v2/server/supportedVersions', { + headers, + }), + ); + + if (!response.success) { + SystemLogger.error({ + msg: 'Failed to communicate with Rocket.Chat Cloud', + url: 'https://releases.rocket.chat/v2/server/supportedVersions', + err: response.error, + }); + } + + return response; +}; + +const getSupportedVersionsToken = async () => { + /** + * Gets the supported versions from the license + * Gets the supported versions from the cloud + * Gets the latest version + * return the token + */ + + const [versionsFromLicense, response] = await Promise.all([License.getLicense(), getSupportedVersionsFromCloud()]); + + return (await supportedVersionsChooseLatest(versionsFromLicense?.supportedVersions, (response.success && response.result) || undefined)) + ?.signed; +}; + +export const getCachedSupportedVersionsToken = cacheValueInSettings('Cloud_Workspace_Supported_Versions_Token', getSupportedVersionsToken); diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace.ts deleted file mode 100644 index 9337fd0a0172..000000000000 --- a/apps/meteor/app/cloud/server/functions/syncWorkspace.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { NPS, Banner } from '@rocket.chat/core-services'; -import { Settings } from '@rocket.chat/models'; -import { serverFetch as fetch } from '@rocket.chat/server-fetch'; - -import { SystemLogger } from '../../../../server/lib/logger/system'; -import { getAndCreateNpsSurvey } from '../../../../server/services/nps/getAndCreateNpsSurvey'; -import { settings } from '../../../settings/server'; -import { buildWorkspaceRegistrationData } from './buildRegistrationData'; -import { getWorkspaceAccessToken } from './getWorkspaceAccessToken'; -import { getWorkspaceLicense } from './getWorkspaceLicense'; -import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; - -export async function syncWorkspace(reconnectCheck = false) { - const { workspaceRegistered, connectToCloud } = await retrieveRegistrationStatus(); - if (!workspaceRegistered || (!connectToCloud && !reconnectCheck)) { - return false; - } - - const info = await buildWorkspaceRegistrationData(undefined); - - const workspaceUrl = settings.get('Cloud_Workspace_Registration_Client_Uri'); - - let result; - try { - const headers: Record = {}; - const token = await getWorkspaceAccessToken(true); - - if (token) { - headers.Authorization = `Bearer ${token}`; - } else { - return false; - } - - const request = await fetch(`${workspaceUrl}/client`, { - headers, - body: info, - method: 'POST', - }); - - if (!request.ok) { - throw new Error((await request.json()).error); - } - - result = await request.json(); - } catch (err: any) { - SystemLogger.error({ - msg: 'Failed to sync with Rocket.Chat Cloud', - url: '/client', - err, - }); - - return false; - } finally { - // aways fetch the license - await getWorkspaceLicense(); - } - - const data = result; - if (!data) { - return true; - } - - if (data.publicKey) { - await Settings.updateValueById('Cloud_Workspace_PublicKey', data.publicKey); - } - - if (data.trial?.trialId) { - await Settings.updateValueById('Cloud_Workspace_Had_Trial', true); - } - - if (data.nps) { - const { id: npsId, expireAt } = data.nps; - - const startAt = new Date(data.nps.startAt); - - await NPS.create({ - npsId, - startAt, - expireAt: new Date(expireAt), - createdBy: { - _id: 'rocket.cat', - username: 'rocket.cat', - }, - }); - - const now = new Date(); - - if (startAt.getFullYear() === now.getFullYear() && startAt.getMonth() === now.getMonth() && startAt.getDate() === now.getDate()) { - await getAndCreateNpsSurvey(npsId); - } - } - - // add banners - if (data.banners) { - for await (const banner of data.banners) { - const { createdAt, expireAt, startAt } = banner; - - await Banner.create({ - ...banner, - createdAt: new Date(createdAt), - expireAt: new Date(expireAt), - startAt: new Date(startAt), - }); - } - } - - return true; -} diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/announcementSync.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/announcementSync.ts new file mode 100644 index 000000000000..f3885c1e95c2 --- /dev/null +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/announcementSync.ts @@ -0,0 +1,116 @@ +import { type Cloud, type Serialized } from '@rocket.chat/core-typings'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { v, compile } from 'suretype'; + +import { CloudWorkspaceAccessError } from '../../../../../lib/errors/CloudWorkspaceAccessError'; +import { CloudWorkspaceConnectionError } from '../../../../../lib/errors/CloudWorkspaceConnectionError'; +import { CloudWorkspaceRegistrationError } from '../../../../../lib/errors/CloudWorkspaceRegistrationError'; +import { SystemLogger } from '../../../../../server/lib/logger/system'; +import { settings } from '../../../../settings/server'; +import { buildWorkspaceRegistrationData } from '../buildRegistrationData'; +import { getWorkspaceAccessToken } from '../getWorkspaceAccessToken'; +import { retrieveRegistrationStatus } from '../retrieveRegistrationStatus'; +import { handleAnnouncementsOnWorkspaceSync, handleNpsOnWorkspaceSync } from './handleCommsSync'; +import { legacySyncWorkspace } from './legacySyncWorkspace'; + +const workspaceCommPayloadSchema = v.object({ + workspaceId: v.string().required(), + publicKey: v.string(), + nps: v.object({ + id: v.string().required(), + startAt: v.string().format('date-time').required(), + expireAt: v.string().format('date-time').required(), + }), + announcements: v.object({ + create: v.array( + v.object({ + _id: v.string().required(), + _updatedAt: v.string().format('date-time').required(), + selector: v.object({ + roles: v.array(v.string()), + }), + platform: v.array(v.string().enum('web', 'mobile')).required(), + expireAt: v.string().format('date-time').required(), + startAt: v.string().format('date-time').required(), + createdBy: v.string().enum('cloud', 'system').required(), + createdAt: v.string().format('date-time').required(), + dictionary: v.object({}).additional(v.object({}).additional(v.string())), + view: v.any(), + surface: v.string().enum('banner', 'modal').required(), + }), + ), + delete: v.array(v.string()), + }), +}); + +const assertWorkspaceCommPayload = compile(workspaceCommPayloadSchema); + +const fetchCloudAnnouncementsSync = async ({ + token, + data, +}: { + token: string; + data: Cloud.WorkspaceSyncRequestPayload; +}): Promise> => { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/v3/comms/workspace`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: data, + }); + + if (!response.ok) { + try { + const { error } = await response.json(); + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`); + } catch (error) { + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${response.statusText}`); + } + } + + const payload = await response.json(); + + assertWorkspaceCommPayload(payload); + return payload; +}; + +export async function announcementSync() { + try { + const { workspaceRegistered } = await retrieveRegistrationStatus(); + if (!workspaceRegistered) { + throw new CloudWorkspaceRegistrationError('Workspace is not registered'); + } + + const token = await getWorkspaceAccessToken(true); + if (!token) { + throw new CloudWorkspaceAccessError('Workspace does not have a valid access token'); + } + + const workspaceRegistrationData = await buildWorkspaceRegistrationData(undefined); + + const { nps, announcements } = await fetchCloudAnnouncementsSync({ + token, + data: workspaceRegistrationData, + }); + + if (nps) { + await handleNpsOnWorkspaceSync(nps); + } + + if (announcements) { + await handleAnnouncementsOnWorkspaceSync(announcements); + } + + return true; + } catch (err) { + SystemLogger.error({ + msg: 'Failed to sync with Rocket.Chat Cloud', + url: '/comms/workspace', + err, + }); + } + + await legacySyncWorkspace(); +} diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/handleCommsSync.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/handleCommsSync.ts new file mode 100644 index 000000000000..c8b07f8826cf --- /dev/null +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/handleCommsSync.ts @@ -0,0 +1,65 @@ +import { NPS, Banner } from '@rocket.chat/core-services'; +import { type Cloud, type Serialized } from '@rocket.chat/core-typings'; +import { CloudAnnouncements } from '@rocket.chat/models'; + +import { getAndCreateNpsSurvey } from '../../../../../server/services/nps/getAndCreateNpsSurvey'; + +export const handleNpsOnWorkspaceSync = async (nps: Exclude['nps'], undefined>) => { + const { id: npsId, expireAt } = nps; + + const startAt = new Date(nps.startAt); + + await NPS.create({ + npsId, + startAt, + expireAt: new Date(expireAt), + createdBy: { + _id: 'rocket.cat', + username: 'rocket.cat', + }, + }); + + const now = new Date(); + + if (startAt.getFullYear() === now.getFullYear() && startAt.getMonth() === now.getMonth() && startAt.getDate() === now.getDate()) { + await getAndCreateNpsSurvey(npsId); + } +}; + +export const handleBannerOnWorkspaceSync = async (banners: Exclude['banners'], undefined>) => { + for await (const banner of banners) { + const { createdAt, expireAt, startAt, inactivedAt, _updatedAt, ...rest } = banner; + + await Banner.create({ + ...rest, + createdAt: new Date(createdAt), + expireAt: new Date(expireAt), + startAt: new Date(startAt), + ...(inactivedAt && { inactivedAt: new Date(inactivedAt) }), + }); + } +}; + +const deserializeAnnouncement = (announcement: Serialized): Cloud.Announcement => ({ + ...announcement, + _updatedAt: new Date(announcement._updatedAt), + expireAt: new Date(announcement.expireAt), + startAt: new Date(announcement.startAt), + createdAt: new Date(announcement.createdAt), +}); + +export const handleAnnouncementsOnWorkspaceSync = async ( + announcements: Exclude['announcements'], undefined>, +) => { + const { create, delete: deleteIds } = announcements; + + if (deleteIds) { + await CloudAnnouncements.deleteMany({ _id: { $in: deleteIds } }); + } + + for await (const announcement of create.map(deserializeAnnouncement)) { + const { _id, ...rest } = announcement; + + await CloudAnnouncements.updateOne({ _id }, { $set: rest }, { upsert: true }); + } +}; diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/index.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/index.ts new file mode 100644 index 000000000000..bdd898b510f7 --- /dev/null +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/index.ts @@ -0,0 +1,19 @@ +import { SystemLogger } from '../../../../../server/lib/logger/system'; +import { CloudWorkspaceAccessTokenError } from '../getWorkspaceAccessToken'; +import { getCachedSupportedVersionsToken } from '../supportedVersionsToken/supportedVersionsToken'; +import { announcementSync } from './announcementSync'; +import { syncCloudData } from './syncCloudData'; + +export async function syncWorkspace() { + try { + await syncCloudData(); + await announcementSync(); + } catch (err) { + if (err instanceof CloudWorkspaceAccessTokenError) { + // TODO: Remove License if there is no access token + } + SystemLogger.error({ msg: 'Error during workspace sync', err }); + } + + await getCachedSupportedVersionsToken.reset(); +} diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/legacySyncWorkspace.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/legacySyncWorkspace.ts new file mode 100644 index 000000000000..d5f86fad8409 --- /dev/null +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/legacySyncWorkspace.ts @@ -0,0 +1,182 @@ +import { type Cloud, type Serialized } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { v, compile } from 'suretype'; + +import { CloudWorkspaceAccessError } from '../../../../../lib/errors/CloudWorkspaceAccessError'; +import { CloudWorkspaceConnectionError } from '../../../../../lib/errors/CloudWorkspaceConnectionError'; +import { CloudWorkspaceRegistrationError } from '../../../../../lib/errors/CloudWorkspaceRegistrationError'; +import { SystemLogger } from '../../../../../server/lib/logger/system'; +import { settings } from '../../../../settings/server'; +import type { WorkspaceRegistrationData } from '../buildRegistrationData'; +import { buildWorkspaceRegistrationData } from '../buildRegistrationData'; +import { getWorkspaceAccessToken } from '../getWorkspaceAccessToken'; +import { getWorkspaceLicense } from '../getWorkspaceLicense'; +import { retrieveRegistrationStatus } from '../retrieveRegistrationStatus'; +import { handleBannerOnWorkspaceSync, handleNpsOnWorkspaceSync } from './handleCommsSync'; + +const workspaceClientPayloadSchema = v.object({ + workspaceId: v.string().required(), + publicKey: v.string(), + trial: v.object({ + trialing: v.boolean().required(), + trialID: v.string().required(), + endDate: v.string().format('date-time').required(), + marketing: v + .object({ + utmContent: v.string().required(), + utmMedium: v.string().required(), + utmSource: v.string().required(), + utmCampaign: v.string().required(), + }) + .required(), + DowngradesToPlan: v + .object({ + id: v.string().required(), + }) + .required(), + trialRequested: v.boolean().required(), + }), + nps: v.object({ + id: v.string().required(), + startAt: v.string().format('date-time').required(), + expireAt: v.string().format('date-time').required(), + }), + banners: v.array( + v.object({ + _id: v.string().required(), + _updatedAt: v.string().format('date-time').required(), + platform: v.array(v.string()).required(), + expireAt: v.string().format('date-time').required(), + startAt: v.string().format('date-time').required(), + roles: v.array(v.string()), + createdBy: v.object({ + _id: v.string().required(), + username: v.string(), + }), + createdAt: v.string().format('date-time').required(), + view: v.any(), + active: v.boolean(), + inactivedAt: v.string().format('date-time'), + snapshot: v.string(), + }), + ), + announcements: v.object({ + create: v.array( + v.object({ + _id: v.string().required(), + _updatedAt: v.string().format('date-time').required(), + selector: v.object({ + roles: v.array(v.string()), + }), + platform: v.array(v.string().enum('web', 'mobile')).required(), + expireAt: v.string().format('date-time').required(), + startAt: v.string().format('date-time').required(), + createdBy: v.string().enum('cloud', 'system').required(), + createdAt: v.string().format('date-time').required(), + dictionary: v.object({}).additional(v.object({}).additional(v.string())), + view: v.any(), + surface: v.string().enum('banner', 'modal').required(), + }), + ), + delete: v.array(v.string()), + }), +}); + +const assertWorkspaceClientPayload = compile(workspaceClientPayloadSchema); + +/** @deprecated */ +const fetchWorkspaceClientPayload = async ({ + token, + workspaceRegistrationData, +}: { + token: string; + workspaceRegistrationData: WorkspaceRegistrationData; +}): Promise | undefined> => { + const workspaceRegistrationClientUri = settings.get('Cloud_Workspace_Registration_Client_Uri'); + const response = await fetch(`${workspaceRegistrationClientUri}/client`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: workspaceRegistrationData, + }); + + if (!response.ok) { + try { + const { error } = await response.json(); + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`); + } catch (error) { + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${response.statusText}`); + } + } + + const payload = await response.json(); + + if (!payload) { + return undefined; + } + + if (!assertWorkspaceClientPayload(payload)) { + throw new CloudWorkspaceConnectionError('Invalid response from Rocket.Chat Cloud'); + } + + return payload; +}; + +/** @deprecated */ +const consumeWorkspaceSyncPayload = async (result: Serialized) => { + if (result.publicKey) { + await Settings.updateValueById('Cloud_Workspace_PublicKey', result.publicKey); + } + + if (result.trial?.trialID) { + await Settings.updateValueById('Cloud_Workspace_Had_Trial', true); + } + + // add banners + if (result.banners) { + await handleBannerOnWorkspaceSync(result.banners); + } + + if (result.nps) { + await handleNpsOnWorkspaceSync(result.nps); + } +}; + +/** @deprecated */ +export async function legacySyncWorkspace() { + try { + const { workspaceRegistered } = await retrieveRegistrationStatus(); + if (!workspaceRegistered) { + throw new CloudWorkspaceRegistrationError('Workspace is not registered'); + } + + const token = await getWorkspaceAccessToken(true); + if (!token) { + throw new CloudWorkspaceAccessError('Workspace does not have a valid access token'); + } + + const workspaceRegistrationData = await buildWorkspaceRegistrationData(undefined); + + const payload = await fetchWorkspaceClientPayload({ token, workspaceRegistrationData }); + + if (!payload) { + return true; + } + + await consumeWorkspaceSyncPayload(payload); + + return true; + } catch (err) { + SystemLogger.error({ + msg: 'Failed to sync with Rocket.Chat Cloud', + url: '/client', + err, + }); + + return false; + } finally { + await getWorkspaceLicense(); + } +} diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/syncCloudData.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/syncCloudData.ts new file mode 100644 index 000000000000..5f529a4892ec --- /dev/null +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/syncCloudData.ts @@ -0,0 +1,87 @@ +import type { Cloud, Serialized } from '@rocket.chat/core-typings'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { v, compile } from 'suretype'; + +import { callbacks } from '../../../../../lib/callbacks'; +import { CloudWorkspaceAccessError } from '../../../../../lib/errors/CloudWorkspaceAccessError'; +import { CloudWorkspaceConnectionError } from '../../../../../lib/errors/CloudWorkspaceConnectionError'; +import { CloudWorkspaceRegistrationError } from '../../../../../lib/errors/CloudWorkspaceRegistrationError'; +import { SystemLogger } from '../../../../../server/lib/logger/system'; +import { settings } from '../../../../settings/server'; +import { buildWorkspaceRegistrationData } from '../buildRegistrationData'; +import { getWorkspaceAccessToken } from '../getWorkspaceAccessToken'; +import { retrieveRegistrationStatus } from '../retrieveRegistrationStatus'; +import { legacySyncWorkspace } from './legacySyncWorkspace'; + +const workspaceSyncPayloadSchema = v.object({ + workspaceId: v.string().required(), + publicKey: v.string(), + license: v.string().required(), +}); + +const assertWorkspaceSyncPayload = compile(workspaceSyncPayloadSchema); + +const fetchWorkspaceSyncPayload = async ({ + token, + data, +}: { + token: string; + data: Cloud.WorkspaceSyncRequestPayload; +}): Promise> => { + const workspaceRegistrationClientUri = settings.get('Cloud_Workspace_Registration_Client_Uri'); + const response = await fetch(`${workspaceRegistrationClientUri}/sync`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: data, + }); + + if (!response.ok) { + try { + const { error } = await response.json(); + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`); + } catch (error) { + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${response.statusText}`); + } + } + + const payload = await response.json(); + + assertWorkspaceSyncPayload(payload); + + return payload; +}; + +export async function syncCloudData() { + try { + const { workspaceRegistered } = await retrieveRegistrationStatus(); + if (!workspaceRegistered) { + throw new CloudWorkspaceRegistrationError('Workspace is not registered'); + } + + const token = await getWorkspaceAccessToken(true); + if (!token) { + throw new CloudWorkspaceAccessError('Workspace does not have a valid access token'); + } + + const workspaceRegistrationData = await buildWorkspaceRegistrationData(undefined); + + const { license } = await fetchWorkspaceSyncPayload({ + token, + data: workspaceRegistrationData, + }); + + await callbacks.run('workspaceLicenseChanged', license); + + return true; + } catch (err) { + SystemLogger.error({ + msg: 'Failed to sync with Rocket.Chat Cloud', + url: '/sync', + err, + }); + } + + await legacySyncWorkspace(); +} diff --git a/apps/meteor/app/cloud/server/functions/userLogout.ts b/apps/meteor/app/cloud/server/functions/userLogout.ts index e03f96df679d..386137ced604 100644 --- a/apps/meteor/app/cloud/server/functions/userLogout.ts +++ b/apps/meteor/app/cloud/server/functions/userLogout.ts @@ -7,9 +7,9 @@ import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; import { userLoggedOut } from './userLoggedOut'; export async function userLogout(userId: string): Promise { - const { connectToCloud, workspaceRegistered } = await retrieveRegistrationStatus(); + const { workspaceRegistered } = await retrieveRegistrationStatus(); - if (!connectToCloud || !workspaceRegistered) { + if (!workspaceRegistered) { return ''; } @@ -26,10 +26,11 @@ export async function userLogout(userId: string): Promise { return ''; } - const cloudUrl = settings.get('Cloud_Url'); const clientSecret = settings.get('Cloud_Workspace_Client_Secret'); const { refreshToken } = user.services.cloud; + + const cloudUrl = settings.get('Cloud_Url'); await fetch(`${cloudUrl}/api/oauth/revoke`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, diff --git a/apps/meteor/app/cloud/server/index.ts b/apps/meteor/app/cloud/server/index.ts index 4bb1f634978e..c7e783d4d5aa 100644 --- a/apps/meteor/app/cloud/server/index.ts +++ b/apps/meteor/app/cloud/server/index.ts @@ -2,7 +2,6 @@ import { cronJobs } from '@rocket.chat/cron'; import { Meteor } from 'meteor/meteor'; import { SystemLogger } from '../../../server/lib/logger/system'; -import { settings } from '../../settings/server'; import { connectWorkspace } from './functions/connectWorkspace'; import { getWorkspaceAccessToken } from './functions/getWorkspaceAccessToken'; import { getWorkspaceAccessTokenWithScope } from './functions/getWorkspaceAccessTokenWithScope'; @@ -13,24 +12,6 @@ import './methods'; const licenseCronName = 'Cloud Workspace Sync'; Meteor.startup(async () => { - // run token/license sync if registered - let TroubleshootDisableWorkspaceSync: boolean; - settings.watch('Troubleshoot_Disable_Workspace_Sync', async (value) => { - if (TroubleshootDisableWorkspaceSync === value) { - return; - } - TroubleshootDisableWorkspaceSync = value; - - if (value) { - return cronJobs.remove(licenseCronName); - } - - setImmediate(() => syncWorkspace()); - await cronJobs.add(licenseCronName, '0 */12 * * *', async () => { - await syncWorkspace(); - }); - }); - const { workspaceRegistered } = await retrieveRegistrationStatus(); if (process.env.REG_TOKEN && process.env.REG_TOKEN !== '' && !workspaceRegistered) { @@ -43,9 +24,14 @@ Meteor.startup(async () => { console.log('Successfully registered with token provided by REG_TOKEN!'); } catch (e: any) { - SystemLogger.error('An error occured registering with token.', e.message); + SystemLogger.error('An error occurred registering with token.', e.message); } } + + setImmediate(() => syncWorkspace()); + await cronJobs.add(licenseCronName, '0 */12 * * *', async () => { + await syncWorkspace(); + }); }); export { getWorkspaceAccessToken, getWorkspaceAccessTokenWithScope }; diff --git a/apps/meteor/app/cloud/server/methods.ts b/apps/meteor/app/cloud/server/methods.ts index d2fbac1af881..1d328d0c213e 100644 --- a/apps/meteor/app/cloud/server/methods.ts +++ b/apps/meteor/app/cloud/server/methods.ts @@ -6,7 +6,6 @@ import { hasPermissionAsync } from '../../authorization/server/functions/hasPerm import { buildWorkspaceRegistrationData } from './functions/buildRegistrationData'; import { checkUserHasCloudLogin } from './functions/checkUserHasCloudLogin'; import { connectWorkspace } from './functions/connectWorkspace'; -import { disconnectWorkspace } from './functions/disconnectWorkspace'; import { finishOAuthAuthorization } from './functions/finishOAuthAuthorization'; import { getOAuthAuthorizationUrl } from './functions/getOAuthAuthorizationUrl'; import { reconnectWorkspace } from './functions/reconnectWorkspace'; @@ -19,7 +18,6 @@ declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { 'cloud:checkRegisterStatus': () => { - connectToCloud: boolean; workspaceRegistered: boolean; workspaceId: string; uniqueId: string; @@ -110,7 +108,9 @@ Meteor.methods({ }); } - return syncWorkspace(); + await syncWorkspace(); + + return true; }, async 'cloud:connectWorkspace'(token) { check(token, String); @@ -137,22 +137,6 @@ Meteor.methods({ return connectWorkspace(token); }, - async 'cloud:disconnectWorkspace'() { - const uid = Meteor.userId(); - if (!uid) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { - method: 'cloud:connectServer', - }); - } - - if (!(await hasPermissionAsync(uid, 'manage-cloud'))) { - throw new Meteor.Error('error-not-authorized', 'Not authorized', { - method: 'cloud:connectServer', - }); - } - - return disconnectWorkspace(); - }, async 'cloud:reconnectWorkspace'() { const uid = Meteor.userId(); if (!uid) { diff --git a/apps/meteor/app/cors/server/cors.ts b/apps/meteor/app/cors/server/cors.ts index 29b08261cc4c..cb6fa94273a2 100644 --- a/apps/meteor/app/cors/server/cors.ts +++ b/apps/meteor/app/cors/server/cors.ts @@ -1,12 +1,14 @@ +import { createHash } from 'crypto'; import type http from 'http'; +import type { UrlWithParsedQuery } from 'url'; import url from 'url'; +import { Logger } from '@rocket.chat/logger'; import { Meteor } from 'meteor/meteor'; import type { StaticFiles } from 'meteor/webapp'; import { WebApp, WebAppInternals } from 'meteor/webapp'; import _ from 'underscore'; -import { Logger } from '../../logger/server'; import { settings } from '../../settings/server'; // Taken from 'connect' types @@ -77,15 +79,54 @@ WebApp.rawConnectHandlers.use((_req: http.IncomingMessage, res: http.ServerRespo }); const _staticFilesMiddleware = WebAppInternals.staticFilesMiddleware; +declare module 'meteor/webapp' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace WebApp { + function categorizeRequest( + req: http.IncomingMessage, + ): { arch: string; path: string; url: UrlWithParsedQuery } & Record; + } +} + +// These routes already handle cache control on their own +const cacheControlledRoutes: Array = ['/assets', '/custom-sounds', '/emoji-custom', '/avatar', '/file-upload'].map( + (route) => new RegExp(`^${route}`, 'i'), +); // @ts-expect-error - accessing internal property of webapp -WebAppInternals._staticFilesMiddleware = function ( +WebAppInternals.staticFilesMiddleware = function ( staticFiles: StaticFiles, req: http.IncomingMessage, res: http.ServerResponse, next: NextFunction, ) { res.setHeader('Access-Control-Allow-Origin', '*'); + const { arch, path, url } = WebApp.categorizeRequest(req); + + if (Meteor.isProduction && !cacheControlledRoutes.some((regexp) => regexp.test(path))) { + res.setHeader('Cache-Control', 'public, max-age=31536000'); + } + + // Prevent meteor_runtime_config.js to load from a different expected hash possibly causing + // a cache of the file for the wrong hash and start a client loop due to the mismatch + // of the hashes of ui versions which would be checked against a websocket response + if (path === '/meteor_runtime_config.js') { + const program = WebApp.clientPrograms[arch] as (typeof WebApp.clientPrograms)[string] & { + meteorRuntimeConfigHash?: string; + meteorRuntimeConfig: string; + }; + + if (!program?.meteorRuntimeConfigHash) { + program.meteorRuntimeConfigHash = createHash('sha1') + .update(JSON.stringify(encodeURIComponent(program.meteorRuntimeConfig))) + .digest('hex'); + } + + if (program.meteorRuntimeConfigHash !== url.query.hash) { + res.writeHead(404); + return res.end(); + } + } return _staticFilesMiddleware(staticFiles, req, res, next); }; diff --git a/apps/meteor/app/crowd/server/logger.ts b/apps/meteor/app/crowd/server/logger.ts index 6dfa29c4095b..b241746e294b 100644 --- a/apps/meteor/app/crowd/server/logger.ts +++ b/apps/meteor/app/crowd/server/logger.ts @@ -1,3 +1,3 @@ -import { Logger } from '../../logger/server'; +import { Logger } from '@rocket.chat/logger'; export const logger = new Logger('CROWD'); 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 a00e8ecc9d07..bb939febaef8 100644 --- a/apps/meteor/app/custom-oauth/server/custom_oauth_server.js +++ b/apps/meteor/app/custom-oauth/server/custom_oauth_server.js @@ -1,4 +1,5 @@ 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 { Accounts } from 'meteor/accounts-base'; @@ -11,7 +12,6 @@ import _ from 'underscore'; import { callbacks } from '../../../lib/callbacks'; import { isURL } from '../../../lib/utils/isURL'; import { registerAccessTokenService } from '../../lib/server/oauth/oauth'; -import { Logger } from '../../logger/server'; import { settings } from '../../settings/server'; import { normalizers, fromTemplate, renameInvalidProperties } from './transform_helpers'; diff --git a/apps/meteor/app/custom-sounds/client/lib/CustomSounds.ts b/apps/meteor/app/custom-sounds/client/lib/CustomSounds.ts index 4ff220b38b6f..f881c15f9886 100644 --- a/apps/meteor/app/custom-sounds/client/lib/CustomSounds.ts +++ b/apps/meteor/app/custom-sounds/client/lib/CustomSounds.ts @@ -7,21 +7,22 @@ import { getURL } from '../../../utils/client'; import { sdk } from '../../../utils/client/lib/SDKClient'; const getCustomSoundId = (soundId: ICustomSound['_id']) => `custom-sound-${soundId}`; +const getAssetUrl = (asset: string, params?: Record) => getURL(asset, params, undefined, true); const defaultSounds = [ - { _id: 'chime', name: 'Chime', extension: 'mp3', src: getURL('sounds/chime.mp3') }, - { _id: 'door', name: 'Door', extension: 'mp3', src: getURL('sounds/door.mp3') }, - { _id: 'beep', name: 'Beep', extension: 'mp3', src: getURL('sounds/beep.mp3') }, - { _id: 'chelle', name: 'Chelle', extension: 'mp3', src: getURL('sounds/chelle.mp3') }, - { _id: 'ding', name: 'Ding', extension: 'mp3', src: getURL('sounds/ding.mp3') }, - { _id: 'droplet', name: 'Droplet', extension: 'mp3', src: getURL('sounds/droplet.mp3') }, - { _id: 'highbell', name: 'Highbell', extension: 'mp3', src: getURL('sounds/highbell.mp3') }, - { _id: 'seasons', name: 'Seasons', extension: 'mp3', src: getURL('sounds/seasons.mp3') }, - { _id: 'telephone', name: 'Telephone', extension: 'mp3', src: getURL('sounds/telephone.mp3') }, - { _id: 'outbound-call-ringing', name: 'Outbound Call Ringing', extension: 'mp3', src: getURL('sounds/outbound-call-ringing.mp3') }, - { _id: 'call-ended', name: 'Call Ended', extension: 'mp3', src: getURL('sounds/call-ended.mp3') }, - { _id: 'dialtone', name: 'Dialtone', extension: 'mp3', src: getURL('sounds/dialtone.mp3') }, - { _id: 'ringtone', name: 'Ringtone', extension: 'mp3', src: getURL('sounds/ringtone.mp3') }, + { _id: 'chime', name: 'Chime', extension: 'mp3', src: getAssetUrl('sounds/chime.mp3') }, + { _id: 'door', name: 'Door', extension: 'mp3', src: getAssetUrl('sounds/door.mp3') }, + { _id: 'beep', name: 'Beep', extension: 'mp3', src: getAssetUrl('sounds/beep.mp3') }, + { _id: 'chelle', name: 'Chelle', extension: 'mp3', src: getAssetUrl('sounds/chelle.mp3') }, + { _id: 'ding', name: 'Ding', extension: 'mp3', src: getAssetUrl('sounds/ding.mp3') }, + { _id: 'droplet', name: 'Droplet', extension: 'mp3', src: getAssetUrl('sounds/droplet.mp3') }, + { _id: 'highbell', name: 'Highbell', extension: 'mp3', src: getAssetUrl('sounds/highbell.mp3') }, + { _id: 'seasons', name: 'Seasons', extension: 'mp3', src: getAssetUrl('sounds/seasons.mp3') }, + { _id: 'telephone', name: 'Telephone', extension: 'mp3', src: getAssetUrl('sounds/telephone.mp3') }, + { _id: 'outbound-call-ringing', name: 'Outbound Call Ringing', extension: 'mp3', src: getAssetUrl('sounds/outbound-call-ringing.mp3') }, + { _id: 'call-ended', name: 'Call Ended', extension: 'mp3', src: getAssetUrl('sounds/call-ended.mp3') }, + { _id: 'dialtone', name: 'Dialtone', extension: 'mp3', src: getAssetUrl('sounds/dialtone.mp3') }, + { _id: 'ringtone', name: 'Ringtone', extension: 'mp3', src: getAssetUrl('sounds/ringtone.mp3') }, ]; class CustomSoundsClass { @@ -42,7 +43,7 @@ class CustomSoundsClass { const audio = document.createElement('audio'); audio.id = getCustomSoundId(sound._id); - audio.preload = 'auto'; + audio.preload = 'none'; audio.appendChild(source); document.body.appendChild(audio); @@ -85,7 +86,7 @@ class CustomSoundsClass { } getURL(sound: ICustomSound) { - return getURL(`/custom-sounds/${sound._id}.${sound.extension}?_dc=${sound.random || 0}`); + return getAssetUrl(`/custom-sounds/${sound._id}.${sound.extension}`, { _dc: sound.random || 0 }); } getList() { diff --git a/apps/meteor/app/discussion/client/createDiscussionMessageAction.ts b/apps/meteor/app/discussion/client/createDiscussionMessageAction.ts index 170b2895cc61..3ad61c4c42f0 100644 --- a/apps/meteor/app/discussion/client/createDiscussionMessageAction.ts +++ b/apps/meteor/app/discussion/client/createDiscussionMessageAction.ts @@ -19,6 +19,7 @@ Meteor.startup(() => { id: 'start-discussion', icon: 'discussion', label: 'Discussion_start', + type: 'communication', context: ['message', 'message-mobile', 'videoconf'], async action(_, props) { const { message = messageArgs(this).msg, room } = props; @@ -58,7 +59,7 @@ Meteor.startup(() => { return false; } - return uid !== user._id ? hasPermission('start-discussion-other-user') : hasPermission('start-discussion'); + return uid !== user._id ? hasPermission('start-discussion-other-user', room._id) : hasPermission('start-discussion', room._id); }, order: 1, group: 'menu', diff --git a/apps/meteor/app/discussion/server/hooks/joinDiscussionOnMessage.ts b/apps/meteor/app/discussion/server/hooks/joinDiscussionOnMessage.ts deleted file mode 100644 index b953d4658c85..000000000000 --- a/apps/meteor/app/discussion/server/hooks/joinDiscussionOnMessage.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Subscriptions } from '@rocket.chat/models'; - -import { callbacks } from '../../../../lib/callbacks'; -import { joinRoomMethod } from '../../../lib/server/methods/joinRoom'; - -callbacks.add( - 'beforeSaveMessage', - async (message, room) => { - // abort if room is not a discussion - if (!room?.prid) { - return message; - } - - // check if user already joined the discussion - const sub = await Subscriptions.findOneByRoomIdAndUserId(room._id, message.u._id, { - projection: { _id: 1 }, - }); - - if (sub) { - return message; - } - - await joinRoomMethod(message.u._id, room._id); - - return message; - }, - callbacks.priority.MEDIUM, - 'joinDiscussionOnMessage', -); diff --git a/apps/meteor/app/discussion/server/methods/createDiscussion.ts b/apps/meteor/app/discussion/server/methods/createDiscussion.ts index ce5c09947a60..c3869f8ff963 100644 --- a/apps/meteor/app/discussion/server/methods/createDiscussion.ts +++ b/apps/meteor/app/discussion/server/methods/createDiscussion.ts @@ -156,7 +156,7 @@ const create = async ({ const discussion = await createRoom( type, name, - user.username as string, + user, [...new Set(invitedUsers)].filter(Boolean), false, false, @@ -221,7 +221,7 @@ export const createDiscussion = async ( }); } - if (!(await hasAtLeastOnePermissionAsync(userId, ['start-discussion', 'start-discussion-other-user']))) { + if (!(await hasAtLeastOnePermissionAsync(userId, ['start-discussion', 'start-discussion-other-user'], prid))) { throw new Meteor.Error('error-action-not-allowed', 'You are not allowed to create a discussion', { method: 'createDiscussion' }); } const user = await Users.findOneById(userId); diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 35b57815047a..42e9a3a99553 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -466,7 +466,7 @@ class E2E extends Emitter { await Promise.all( urls.map(async (url) => { - if (!url.includes(Meteor.absoluteUrl())) { + if (!url.includes(settings.get('Site_Url'))) { return; } diff --git a/apps/meteor/app/e2e/server/methods/setRoomKeyID.ts b/apps/meteor/app/e2e/server/methods/setRoomKeyID.ts index 76942a681805..005df0bb2a7a 100644 --- a/apps/meteor/app/e2e/server/methods/setRoomKeyID.ts +++ b/apps/meteor/app/e2e/server/methods/setRoomKeyID.ts @@ -31,7 +31,7 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'e2e.setRoomKeyID' }); } - const room = await Rooms.findOneById(rid, { fields: { e2eKeyId: 1 } }); + const room = await Rooms.findOneById>(rid, { projection: { e2eKeyId: 1 } }); if (!room) { throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'e2e.setRoomKeyID' }); diff --git a/apps/meteor/app/federation/server/endpoints/dispatch.js b/apps/meteor/app/federation/server/endpoints/dispatch.js index 7960e87798fd..59ffcb0f342f 100644 --- a/apps/meteor/app/federation/server/endpoints/dispatch.js +++ b/apps/meteor/app/federation/server/endpoints/dispatch.js @@ -1,6 +1,6 @@ import { api } from '@rocket.chat/core-services'; import { eventTypes } from '@rocket.chat/core-typings'; -import { FederationServers, FederationRoomEvents, Rooms, Messages, Subscriptions, Users } from '@rocket.chat/models'; +import { FederationServers, FederationRoomEvents, Rooms, Messages, Subscriptions, Users, ReadReceipts } from '@rocket.chat/models'; import EJSON from 'ejson'; import { API } from '../../../api/server'; @@ -325,6 +325,7 @@ const eventHandlers = { // Remove the message await Messages.removeById(messageId); + await ReadReceipts.removeByMessageId(messageId); // Notify the room void api.broadcast('notify.deleteMessage', roomId, { _id: messageId }); @@ -456,8 +457,8 @@ const eventHandlers = { // Denormalize user const denormalizedUser = normalizers.denormalizeUser(user); - // Mute user - await Rooms.unmuteUsernameByRoomId(roomId, denormalizedUser.username); + // Unmute user + await Rooms.unmuteMutedUsernameByRoomId(roomId, denormalizedUser.username); } return eventResult; diff --git a/apps/meteor/app/federation/server/lib/logger.js b/apps/meteor/app/federation/server/lib/logger.js index 9e66d33808b0..e8cf0ae12844 100644 --- a/apps/meteor/app/federation/server/lib/logger.js +++ b/apps/meteor/app/federation/server/lib/logger.js @@ -1,4 +1,4 @@ -import { Logger } from '../../../logger/server'; +import { Logger } from '@rocket.chat/logger'; const logger = new Logger('Federation'); diff --git a/apps/meteor/app/file-upload/server/config/AmazonS3.ts b/apps/meteor/app/file-upload/server/config/AmazonS3.ts index b97ff60d86d6..567e5e5d71eb 100644 --- a/apps/meteor/app/file-upload/server/config/AmazonS3.ts +++ b/apps/meteor/app/file-upload/server/config/AmazonS3.ts @@ -32,12 +32,15 @@ const get: FileUploadClass['get'] = async function (this: FileUploadClass, file, const copy: FileUploadClass['copy'] = async function (this: FileUploadClass, file, out) { const fileUrl = await this.store.getRedirectURL(file); - if (fileUrl) { - const request = /^https:/.test(fileUrl) ? https : http; - request.get(fileUrl, (fileRes) => fileRes.pipe(out)); - } else { + if (!fileUrl) { out.end(); + return; } + + const request = /^https:/.test(fileUrl) ? https : http; + return new Promise((resolve) => { + request.get(fileUrl, (fileRes) => fileRes.pipe(out).on('finish', () => resolve())); + }); }; const AmazonS3Uploads = new FileUploadClass({ diff --git a/apps/meteor/app/file-upload/server/config/GoogleStorage.ts b/apps/meteor/app/file-upload/server/config/GoogleStorage.ts index 124bad4365a0..41eb4350b876 100644 --- a/apps/meteor/app/file-upload/server/config/GoogleStorage.ts +++ b/apps/meteor/app/file-upload/server/config/GoogleStorage.ts @@ -32,12 +32,15 @@ const get: FileUploadClass['get'] = async function (this: FileUploadClass, file, const copy: FileUploadClass['copy'] = async function (this: FileUploadClass, file, out) { const fileUrl = await this.store.getRedirectURL(file, false); - if (fileUrl) { - const request = /^https:/.test(fileUrl) ? https : http; - request.get(fileUrl, (fileRes) => fileRes.pipe(out)); - } else { + if (!fileUrl) { out.end(); + return; } + + const request = /^https:/.test(fileUrl) ? https : http; + return new Promise((resolve) => { + request.get(fileUrl, (fileRes) => fileRes.pipe(out).on('finish', () => resolve())); + }); }; const GoogleCloudStorageUploads = new FileUploadClass({ diff --git a/apps/meteor/app/file-upload/server/config/GridFS.ts b/apps/meteor/app/file-upload/server/config/GridFS.ts index 44f4066ac9f4..629d177581bf 100644 --- a/apps/meteor/app/file-upload/server/config/GridFS.ts +++ b/apps/meteor/app/file-upload/server/config/GridFS.ts @@ -4,9 +4,9 @@ import stream from 'stream'; import zlib from 'zlib'; import type { IUpload } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; import { UploadFS } from '../../../../server/ufs'; -import { Logger } from '../../../logger/server'; import { FileUploadClass, FileUpload } from '../lib/FileUpload'; import { getFileRange, setRangeHeaders } from '../lib/ranges'; diff --git a/apps/meteor/app/file-upload/server/config/Webdav.ts b/apps/meteor/app/file-upload/server/config/Webdav.ts index fb8c1ca82ca4..901c74e9c149 100644 --- a/apps/meteor/app/file-upload/server/config/Webdav.ts +++ b/apps/meteor/app/file-upload/server/config/Webdav.ts @@ -19,7 +19,9 @@ const get: FileUploadClass['get'] = async function (this: FileUploadClass, file, }; const copy: FileUploadClass['copy'] = async function (this: FileUploadClass, file, out) { - (await this.store.getReadStream(file._id, file)).pipe(out); + return new Promise(async (resolve) => { + (await this.store.getReadStream(file._id, file)).pipe(out).on('finish', () => resolve()); + }); }; const WebdavUploads = new FileUploadClass({ diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.ts index 8f929a17fe34..e512e5d09bfe 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.ts @@ -562,7 +562,32 @@ export const FileUpload = { ) { res.setHeader('Content-Disposition', `${forceDownload ? 'attachment' : 'inline'}; filename="${encodeURI(fileName)}"`); - request.get(fileUrl, (fileRes) => fileRes.pipe(res)); + request.get(fileUrl, (fileRes) => { + if (fileRes.statusCode !== 200) { + res.setHeader('x-rc-proxyfile-status', String(fileRes.statusCode)); + res.setHeader('content-length', 0); + res.writeHead(500); + res.end(); + return; + } + + // eslint-disable-next-line prettier/prettier + const headersToProxy = [ + 'age', + 'cache-control', + 'content-length', + 'content-type', + 'date', + 'expired', + 'last-modified', + ]; + + headersToProxy.forEach((header) => { + fileRes.headers[header] && res.setHeader(header, String(fileRes.headers[header])); + }); + + fileRes.pipe(res); + }); }, generateJWTToFileUrls({ rid, userId, fileId }: { rid: string; userId: string; fileId: string }) { diff --git a/apps/meteor/app/file-upload/server/lib/proxy.ts b/apps/meteor/app/file-upload/server/lib/proxy.ts index 64616693a4d0..048424af4bd9 100644 --- a/apps/meteor/app/file-upload/server/lib/proxy.ts +++ b/apps/meteor/app/file-upload/server/lib/proxy.ts @@ -2,13 +2,13 @@ import http from 'http'; import URL from 'url'; import { InstanceStatus } from '@rocket.chat/instance-status'; +import { Logger } from '@rocket.chat/logger'; import { InstanceStatus as InstanceStatusModel } from '@rocket.chat/models'; import type { NextFunction } from 'connect'; import type createServer from 'connect'; import { WebApp } from 'meteor/webapp'; import { UploadFS } from '../../../../server/ufs'; -import { Logger } from '../../../logger/server'; import { isDocker } from '../../../utils/server/functions/isDocker'; const logger = new Logger('UploadProxy'); diff --git a/apps/meteor/app/importer/server/classes/ImportDataConverter.ts b/apps/meteor/app/importer/server/classes/ImportDataConverter.ts index 854e941014a2..1b596d625d9b 100644 --- a/apps/meteor/app/importer/server/classes/ImportDataConverter.ts +++ b/apps/meteor/app/importer/server/classes/ImportDataConverter.ts @@ -12,6 +12,7 @@ import type { IImportRecordType, IMessage as IDBMessage, } from '@rocket.chat/core-typings'; +import type { Logger } from '@rocket.chat/logger'; import { ImportData, Rooms, Users, Subscriptions } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import { SHA256 } from '@rocket.chat/sha256'; @@ -20,7 +21,6 @@ import { Accounts } from 'meteor/accounts-base'; import { ObjectId } from 'mongodb'; import { callbacks } from '../../../../lib/callbacks'; -import type { Logger } from '../../../../server/lib/logger/Logger'; import { createDirectMessage } from '../../../../server/methods/createDirectMessage'; import { saveRoomSettings } from '../../../channel-settings/server/methods/saveRoomSettings'; import { addUserToDefaultChannels } from '../../../lib/server/functions/addUserToDefaultChannels'; @@ -153,7 +153,7 @@ export class ImportDataConverter { _id: new ObjectId().toHexString(), data, dataType: type, - ...options, + options, }); } @@ -445,8 +445,6 @@ export class ImportDataConverter { public async convertUsers({ beforeImportFn, afterImportFn, onErrorFn, afterBatchFn }: IConversionCallbacks = {}): Promise { const users = (await this.getUsersToImport()) as IImportUserRecord[]; - await callbacks.run('beforeUserImport', { userCount: users.length }); - const insertedIds = new Set(); const updatedIds = new Set(); let skippedCount = 0; @@ -454,13 +452,14 @@ export class ImportDataConverter { const batchToInsert = new Set(); - for await (const { data, _id } of users) { + for await (const record of users) { + const { data, _id } = record; if (this.aborted) { break; } try { - if (beforeImportFn && !(await beforeImportFn(data, 'user'))) { + if (beforeImportFn && !(await beforeImportFn(record))) { await this.skipRecord(_id); skippedCount++; continue; @@ -537,7 +536,7 @@ export class ImportDataConverter { } if (afterImportFn) { - await afterImportFn(data, 'user', isNewUser); + await afterImportFn(record, isNewUser); } } catch (e) { this._logger.error(e); @@ -741,13 +740,14 @@ export class ImportDataConverter { const rids: Array = []; const messages = await this.getMessagesToImport(); - for await (const { data, _id } of messages) { + for await (const record of messages) { + const { data, _id } = record; if (this.aborted) { return; } try { - if (beforeImportFn && !(await beforeImportFn(data, 'message'))) { + if (beforeImportFn && !(await beforeImportFn(record))) { await this.skipRecord(_id); continue; } @@ -816,7 +816,7 @@ export class ImportDataConverter { } if (afterImportFn) { - await afterImportFn(data, 'message', true); + await afterImportFn(record, true); } } catch (e) { await this.saveError(_id, e instanceof Error ? e : new Error(String(e))); @@ -1034,7 +1034,11 @@ export class ImportDataConverter { return; } if (roomData.t === 'p') { - roomInfo = await createPrivateGroupMethod(startedByUserId, roomData.name, members, false, {}, {}, true); + const user = await Users.findOneById(startedByUserId); + if (!user) { + throw new Error('importer-channel-invalid-creator'); + } + roomInfo = await createPrivateGroupMethod(user, roomData.name, members, false, {}, {}, true); } else { roomInfo = await createChannelMethod(startedByUserId, roomData.name, members, false, {}, {}, true); } @@ -1116,13 +1120,14 @@ export class ImportDataConverter { async convertChannels(startedByUserId: string, { beforeImportFn, afterImportFn, onErrorFn }: IConversionCallbacks = {}): Promise { const channels = await this.getChannelsToImport(); - for await (const { data, _id } of channels) { + for await (const record of channels) { + const { data, _id } = record; if (this.aborted) { return; } try { - if (beforeImportFn && !(await beforeImportFn(data, 'channel'))) { + if (beforeImportFn && !(await beforeImportFn(record))) { await this.skipRecord(_id); continue; } @@ -1151,7 +1156,7 @@ export class ImportDataConverter { } if (afterImportFn) { - await afterImportFn(data, 'channel', !existingRoom); + await afterImportFn(record, !existingRoom); } } catch (e) { await this.saveError(_id, e instanceof Error ? e : new Error(String(e))); diff --git a/apps/meteor/app/importer/server/classes/ImporterBase.js b/apps/meteor/app/importer/server/classes/ImporterBase.js index ef971c903977..061644130a66 100644 --- a/apps/meteor/app/importer/server/classes/ImporterBase.js +++ b/apps/meteor/app/importer/server/classes/ImporterBase.js @@ -1,8 +1,9 @@ +import { Logger } from '@rocket.chat/logger'; import { Settings, ImportData, Imports } from '@rocket.chat/models'; import AdmZip from 'adm-zip'; import { Selection, SelectionChannel, SelectionUser } from '..'; -import { Logger } from '../../../logger/server'; +import { callbacks } from '../../../../lib/callbacks'; import { t } from '../../../utils/lib/i18n'; import { ImporterInfo } from '../../lib/ImporterInfo'; import { ProgressStep, ImportPreparingStartedStates } from '../../lib/ImporterProgressStep'; @@ -96,7 +97,7 @@ export class Base { this.reloadCount(); const started = Date.now(); - const beforeImportFn = async (data, type) => { + const beforeImportFn = async ({ data, dataType: type }) => { if (this.importRecord.valid === false) { this.converter.aborted = true; throw new Error('The import operation is no longer valid.'); @@ -130,7 +131,7 @@ export class Base { } } - return true; + return false; }; const afterImportFn = async () => { @@ -167,6 +168,8 @@ export class Base { await this.applySettingValues({}); await this.updateProgress(ProgressStep.IMPORTING_USERS); + const usersToImport = importSelection.users.filter((user) => user.do_import); + await callbacks.run('beforeUserImport', { userCount: usersToImport.length }); await this.converter.convertUsers({ beforeImportFn, afterImportFn, onErrorFn, afterBatchFn }); await this.updateProgress(ProgressStep.IMPORTING_CHANNELS); diff --git a/apps/meteor/app/importer/server/classes/VirtualDataConverter.ts b/apps/meteor/app/importer/server/classes/VirtualDataConverter.ts index 595771d89d2f..ef850226be5c 100644 --- a/apps/meteor/app/importer/server/classes/VirtualDataConverter.ts +++ b/apps/meteor/app/importer/server/classes/VirtualDataConverter.ts @@ -56,8 +56,8 @@ export class VirtualDataConverter extends ImportDataConverter { return undefined; } - public addUserSync(data: IImportUser): void { - return this.addObjectSync('user', data); + public addUserSync(data: IImportUser, options?: Record): void { + return this.addObjectSync('user', data, options); } protected async addObject(type: IImportRecordType, data: IImportData, options: Record = {}): Promise { @@ -79,7 +79,7 @@ export class VirtualDataConverter extends ImportDataConverter { _id: Random.id(), data, dataType: type, - ...options, + options, }); } diff --git a/apps/meteor/app/importer/server/definitions/IConversionCallbacks.ts b/apps/meteor/app/importer/server/definitions/IConversionCallbacks.ts index 27d0c38b69d3..262970dd60d9 100644 --- a/apps/meteor/app/importer/server/definitions/IConversionCallbacks.ts +++ b/apps/meteor/app/importer/server/definitions/IConversionCallbacks.ts @@ -1,11 +1,11 @@ -import type { IImportUser, IImportMessage, IImportChannel, IImportRecordType } from '@rocket.chat/core-typings'; +import type { IImportRecord } from '@rocket.chat/core-typings'; -type ImporterBeforeImportCallback = { - (data: IImportUser | IImportChannel | IImportMessage, type: IImportRecordType): Promise; +export type ImporterBeforeImportCallback = { + (data: IImportRecord): Promise; }; export type ImporterAfterImportCallback = { - (data: IImportUser | IImportChannel | IImportMessage, type: IImportRecordType, isNewRecord: boolean): Promise; + (data: IImportRecord, isNewRecord: boolean): Promise; }; export interface IConversionCallbacks { diff --git a/apps/meteor/app/integrations/server/api/api.js b/apps/meteor/app/integrations/server/api/api.js index e1db46729011..5162fa54ad9c 100644 --- a/apps/meteor/app/integrations/server/api/api.js +++ b/apps/meteor/app/integrations/server/api/api.js @@ -1,114 +1,21 @@ import { Integrations, Users } from '@rocket.chat/models'; -import * as Models from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; -import { Livechat } from 'meteor/rocketchat:livechat'; -import moment from 'moment'; import _ from 'underscore'; -import { VM, VMScript } from 'vm2'; -import * as s from '../../../../lib/utils/stringUtils'; -import { deasyncPromise } from '../../../../server/deasync/deasync'; -import { httpCall } from '../../../../server/lib/http/call'; import { API, APIClass, defaultRateLimiterOptions } from '../../../api/server'; import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage'; import { settings } from '../../../settings/server'; +import { IsolatedVMScriptEngine } from '../lib/isolated-vm/isolated-vm'; +import { VM2ScriptEngine } from '../lib/vm2/vm2'; import { incomingLogger } from '../logger'; import { addOutgoingIntegration } from '../methods/outgoing/addOutgoingIntegration'; import { deleteOutgoingIntegration } from '../methods/outgoing/deleteOutgoingIntegration'; -const DISABLE_INTEGRATION_SCRIPTS = ['yes', 'true'].includes(String(process.env.DISABLE_INTEGRATION_SCRIPTS).toLowerCase()); +const vm2Engine = new VM2ScriptEngine(true); +const ivmEngine = new IsolatedVMScriptEngine(true); -export const forbiddenModelMethods = ['registerModel', 'getCollectionName']; - -const compiledScripts = {}; -function buildSandbox(store = {}) { - const httpAsync = async (method, url, options) => { - try { - return { - result: await httpCall(method, url, options), - }; - } catch (error) { - return { error }; - } - }; - - const sandbox = { - scriptTimeout(reject) { - return setTimeout(() => reject('timed out'), 3000); - }, - _, - s, - console, - moment, - Promise, - Livechat, - Store: { - set(key, val) { - store[key] = val; - return val; - }, - get(key) { - return store[key]; - }, - }, - HTTP: (method, url, options) => { - // TODO: deprecate, track and alert - return deasyncPromise(httpAsync(method, url, options)); - }, - // TODO: Export fetch as the non deprecated method - }; - Object.keys(Models) - .filter((k) => !forbiddenModelMethods.includes(k)) - .forEach((k) => { - sandbox[k] = Models[k]; - }); - return { store, sandbox }; -} - -function getIntegrationScript(integration) { - if (DISABLE_INTEGRATION_SCRIPTS) { - throw API.v1.failure('integration-scripts-disabled'); - } - - const compiledScript = compiledScripts[integration._id]; - if (compiledScript && +compiledScript._updatedAt === +integration._updatedAt) { - return compiledScript.script; - } - - const script = integration.scriptCompiled; - const { sandbox, store } = buildSandbox(); - try { - incomingLogger.info({ msg: 'Will evaluate script of Trigger', integration: integration.name }); - incomingLogger.debug(script); - - const vmScript = new VMScript(`${script}; Script;`, 'script.js'); - const vm = new VM({ - sandbox, - }); - - const ScriptClass = vm.run(vmScript); - - if (ScriptClass) { - compiledScripts[integration._id] = { - script: new ScriptClass(), - store, - _updatedAt: integration._updatedAt, - }; - - return compiledScripts[integration._id].script; - } - } catch (err) { - incomingLogger.error({ - msg: 'Error evaluating Script in Trigger', - integration: integration.name, - script, - err, - }); - throw API.v1.failure('error-evaluating-script'); - } - - incomingLogger.error({ msg: 'Class "Script" not in Trigger', integration: integration.name }); - throw API.v1.failure('class-script-not-found'); +function getEngine(integration) { + return integration.scriptEngine === 'isolated-vm' ? ivmEngine : vm2Engine; } async function createIntegration(options, user) { @@ -178,20 +85,9 @@ async function executeIntegrationRest() { emoji: this.integration.emoji, }; - if ( - !DISABLE_INTEGRATION_SCRIPTS && - this.integration.scriptEnabled && - this.integration.scriptCompiled && - this.integration.scriptCompiled.trim() !== '' - ) { - let script; - try { - script = getIntegrationScript(this.integration); - } catch (e) { - incomingLogger.error(e); - return API.v1.failure(e.message); - } + const scriptEngine = getEngine(this.integration); + if (scriptEngine.integrationHasValidScript(this.integration)) { this.request.setEncoding('utf8'); const content_raw = this.request.read(); @@ -216,37 +112,12 @@ async function executeIntegrationRest() { }, }; - try { - const { sandbox } = buildSandbox(compiledScripts[this.integration._id].store); - sandbox.script = script; - sandbox.request = request; - - const vm = new VM({ - timeout: 3000, - sandbox, - }); - - const result = await new Promise((resolve, reject) => { - process.nextTick(async () => { - try { - const scriptResult = await vm.run(` - new Promise((resolve, reject) => { - scriptTimeout(reject); - try { - resolve(script.process_incoming_request({ request: request })); - } catch(e) { - reject(e); - } - }).catch((error) => { throw new Error(error); }); - `); - - resolve(scriptResult); - } catch (e) { - reject(e); - } - }); - }); + const result = await scriptEngine.processIncomingRequest({ + integration: this.integration, + request, + }); + try { if (!result) { incomingLogger.debug({ msg: 'Process Incoming Request result of Trigger has no data', diff --git a/apps/meteor/app/integrations/server/lib/ScriptEngine.ts b/apps/meteor/app/integrations/server/lib/ScriptEngine.ts new file mode 100644 index 000000000000..e46984a893ef --- /dev/null +++ b/apps/meteor/app/integrations/server/lib/ScriptEngine.ts @@ -0,0 +1,385 @@ +import type { + IUser, + IRoom, + IMessage, + IOutgoingIntegration, + IIncomingIntegration, + IIntegration, + IIntegrationHistory, +} from '@rocket.chat/core-typings'; +import type { Logger } from '@rocket.chat/logger'; +import type { serverFetch } from '@rocket.chat/server-fetch'; +import { wrapExceptions } from '@rocket.chat/tools'; + +import { incomingLogger, outgoingLogger } from '../logger'; +import type { IScriptClass, CompiledScript } from './definition'; +import { updateHistory } from './updateHistory'; + +type OutgoingRequestBaseData = { + token: IOutgoingIntegration['token']; + bot: false; + trigger_word: string; +}; + +type OutgoingRequestSendMessageData = OutgoingRequestBaseData & { + channel_id: string; + channel_name: string; + message_id: string; + timestamp: Date; + user_id: string; + user_name: string; + text: string; + siteUrl: string; + alias?: string; + bot?: boolean; + isEdited?: true; + tmid?: string; +}; + +type OutgoingRequestUploadedFileData = OutgoingRequestBaseData & { + channel_id: string; + channel_name: string; + message_id: string; + timestamp: Date; + user_id: string; + user_name: string; + text: string; + + user: IUser; + room: IRoom; + message: IMessage; + + alias?: string; + bot?: boolean; +}; + +type OutgoingRequestRoomCreatedData = OutgoingRequestBaseData & { + channel_id: string; + channel_name: string; + timestamp: Date; + user_id: string; + user_name: string; + owner: IUser; + room: IRoom; +}; + +type OutgoingRequestRoomData = OutgoingRequestBaseData & { + channel_id: string; + channel_name: string; + timestamp: Date; + user_id: string; + user_name: string; + owner: IUser; + room: IRoom; + bot?: boolean; +}; + +type OutgoingRequestUserCreatedData = OutgoingRequestBaseData & { + timestamp: Date; + user_id: string; + user_name: string; + user: IUser; + bot?: boolean; +}; + +type OutgoingRequestData = + | OutgoingRequestSendMessageData + | OutgoingRequestUploadedFileData + | OutgoingRequestRoomCreatedData + | OutgoingRequestRoomData + | OutgoingRequestUserCreatedData; + +type OutgoingRequest = { + params: Record; + method: 'POST'; + url: string; + data: OutgoingRequestData; + auth: undefined; + headers: Record; +}; + +type OutgoingRequestFromScript = { + url?: string; + headers?: Record; + method?: string; + message?: { + text?: string; + channel?: string; + attachments?: { + color?: string; + author_name?: string; + author_link?: string; + author_icon?: string; + title?: string; + title_link?: string; + text?: string; + fields?: { + title?: string; + value?: string; + short?: boolean; + }[]; + image_url?: string; + thumb_url?: string; + }[]; + }; + + auth?: string; + data?: Record; +}; + +type OutgoingRequestContext = { + integration: IOutgoingIntegration; + data: OutgoingRequestData; + historyId: IIntegrationHistory['_id']; + url: string; +}; + +type ProcessedOutgoingRequest = OutgoingRequest | OutgoingRequestFromScript; + +type OutgoingResponseContext = { + integration: IOutgoingIntegration; + request: ProcessedOutgoingRequest; + response: Awaited>; + content: string; + historyId: IIntegrationHistory['_id']; +}; + +type IncomingIntegrationRequest = { + url: { + hash: string | null | undefined; + search: string | null | undefined; + query: Record; + pathname: string | null | undefined; + path: string | null | undefined; + }; + url_raw: string; + url_params: Record; + content: Record; + content_raw: string; + headers: Record; + body: Record; + user: Pick, '_id' | 'name' | 'username'>; +}; + +export abstract class IntegrationScriptEngine { + protected compiledScripts: Record; + + public get disabled(): boolean { + return this.isDisabled(); + } + + public get incoming(): IsIncoming { + return this.isIncoming; + } + + constructor(private isIncoming: IsIncoming) { + this.compiledScripts = {}; + } + + public integrationHasValidScript(integration: IIntegration): boolean { + return Boolean(!this.disabled && integration.scriptEnabled && integration.scriptCompiled && integration.scriptCompiled.trim() !== ''); + } + + // PrepareOutgoingRequest will execute a script to build the request object that will be used for the actual integration request + // It may also return a message object to be sent to the room where the integration was triggered + public async prepareOutgoingRequest({ integration, data, historyId, url }: OutgoingRequestContext): Promise { + const request: OutgoingRequest = { + params: {}, + method: 'POST', + url, + data, + auth: undefined, + headers: { + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.0 Safari/537.36', + }, + }; + + if (!(await this.hasScriptAndMethod(integration, 'prepare_outgoing_request'))) { + return request; + } + + return this.executeOutgoingScript(integration, 'prepare_outgoing_request', { request }, historyId); + } + + public async processOutgoingResponse({ + integration, + request, + response, + content, + historyId, + }: OutgoingResponseContext): Promise { + if (!(await this.hasScriptAndMethod(integration, 'process_outgoing_response'))) { + return; + } + + const sandbox = { + request, + response: { + error: null, + status_code: response.status, + content, + content_raw: content, + headers: Object.fromEntries(response.headers), + }, + }; + + const scriptResult = await this.executeOutgoingScript(integration, 'process_outgoing_response', sandbox, historyId); + + if (scriptResult === false) { + return scriptResult; + } + + if (scriptResult?.content) { + return scriptResult.content; + } + } + + public async processIncomingRequest({ + integration, + request, + }: { + integration: IIncomingIntegration; + request: IncomingIntegrationRequest; + }): Promise { + return this.executeIncomingScript(integration, 'process_incoming_request', { request }); + } + + protected get logger(): ReturnType { + if (this.isIncoming) { + return incomingLogger; + } + + return outgoingLogger; + } + + protected async executeOutgoingScript( + integration: IOutgoingIntegration, + method: keyof IScriptClass, + params: Record, + historyId: IIntegrationHistory['_id'], + ): Promise { + if (this.disabled) { + return; + } + + const script = await wrapExceptions(() => this.getIntegrationScript(integration)).suppress((e: any) => + updateHistory({ + historyId, + step: 'execute-script-getting-script', + error: true, + errorStack: e, + }), + ); + + if (!script) { + return; + } + + if (!script[method]) { + this.logger.error(`Method "${method}" not found in the Integration "${integration.name}"`); + await updateHistory({ historyId, step: `execute-script-no-method-${method}` }); + return; + } + + try { + await updateHistory({ historyId, step: `execute-script-before-running-${method}` }); + + const result = await this.runScriptMethod({ + integrationId: integration._id, + script, + method, + params, + }); + + this.logger.debug({ + msg: `Script method "${method}" result of the Integration "${integration.name}" is:`, + result, + }); + + return result; + } catch (err: any) { + await updateHistory({ + historyId, + step: `execute-script-error-running-${method}`, + error: true, + errorStack: err.stack.replace(/^/gm, ' '), + }); + this.logger.error({ + msg: 'Error running Script in the Integration', + integration: integration.name, + err, + }); + this.logger.debug({ + msg: 'Error running Script in the Integration', + integration: integration.name, + script: integration.scriptCompiled, + }); + } + } + + protected async executeIncomingScript( + integration: IIncomingIntegration, + method: keyof IScriptClass, + params: Record, + ): Promise { + if (!this.integrationHasValidScript(integration)) { + return; + } + + const script = await wrapExceptions(() => this.getIntegrationScript(integration)).catch((e) => { + this.logger.error(e); + throw e; + }); + + if (!script[method]) { + this.logger.error(`Method "${method}" not found in the Integration "${integration.name}"`); + return; + } + + return wrapExceptions(() => + this.runScriptMethod({ + integrationId: integration._id, + script, + method, + params, + }), + ).catch((err: any) => { + this.logger.error({ + msg: 'Error running Script in Trigger', + integration: integration.name, + script: integration.scriptCompiled, + err, + }); + throw new Error('error-running-script'); + }); + } + + protected async hasScriptAndMethod(integration: IIntegration, method: keyof IScriptClass): Promise { + const script = await this.getScriptSafely(integration); + return typeof script?.[method] === 'function'; + } + + protected async getScriptSafely(integration: IIntegration): Promise | undefined> { + if (this.disabled || integration.scriptEnabled !== true || !integration.scriptCompiled || integration.scriptCompiled.trim() === '') { + return; + } + + return wrapExceptions(() => this.getIntegrationScript(integration)).suppress(); + } + + protected abstract isDisabled(): boolean; + + protected abstract runScriptMethod({ + integrationId, + script, + method, + params, + }: { + integrationId: IIntegration['_id']; + script: IScriptClass; + method: keyof IScriptClass; + params: Record; + }): Promise; + + protected abstract getIntegrationScript(integration: IIntegration): Promise>; +} diff --git a/apps/meteor/app/integrations/server/lib/definition.ts b/apps/meteor/app/integrations/server/lib/definition.ts new file mode 100644 index 000000000000..b4d11b9f4e8b --- /dev/null +++ b/apps/meteor/app/integrations/server/lib/definition.ts @@ -0,0 +1,19 @@ +import type { IIntegration } from '@rocket.chat/core-typings'; + +export interface IScriptClass { + prepare_outgoing_request?: (params: Record) => any; + process_outgoing_response?: (params: Record) => any; + process_incoming_request?: (params: Record) => any; +} + +export type FullScriptClass = Required; + +export type CompiledScript = { + script: Partial; + store: Record; + _updatedAt: IIntegration['_updatedAt']; +}; + +export type CompatibilityScriptResult = IScriptClass & { + availableFunctions: (keyof IScriptClass)[]; +}; diff --git a/apps/meteor/app/integrations/server/lib/isolated-vm/buildSandbox.ts b/apps/meteor/app/integrations/server/lib/isolated-vm/buildSandbox.ts new file mode 100644 index 000000000000..1bbefb6a2ee7 --- /dev/null +++ b/apps/meteor/app/integrations/server/lib/isolated-vm/buildSandbox.ts @@ -0,0 +1,127 @@ +import { EventEmitter } from 'events'; + +import { serverFetch as fetch, Response } from '@rocket.chat/server-fetch'; +import ivm, { type Context } from 'isolated-vm'; + +import * as s from '../../../../../lib/utils/stringUtils'; + +const proxyObject = (obj: Record, forbiddenKeys: string[] = []): Record => { + return copyObject({ + isProxy: true, + get: (key: string) => { + if (forbiddenKeys.includes(key)) { + return undefined; + } + + const value = obj[key]; + + if (typeof value === 'function') { + return new ivm.Reference(async (...args: any[]) => { + const result = (obj[key] as any)(...args); + + if (result && result instanceof Promise) { + return new Promise(async (resolve, reject) => { + try { + const awaitedResult = await result; + resolve(makeTransferable(awaitedResult)); + } catch (e) { + reject(e); + } + }); + } + + return makeTransferable(result); + }); + } + + return makeTransferable(value); + }, + }); +}; + +const copyObject = (obj: Record | any[]): Record | any[] => { + if (Array.isArray(obj)) { + return obj.map((data) => copyData(data)); + } + + if (obj instanceof Response) { + return proxyObject(obj, ['clone']); + } + + if (isSemiTransferable(obj)) { + return obj; + } + + if (typeof obj[Symbol.iterator as any] === 'function') { + return copyObject(Array.from(obj as any)); + } + + if (obj instanceof EventEmitter) { + return {}; + } + + const keys = Object.keys(obj); + + return { + ...Object.fromEntries( + keys.map((key) => { + const data = obj[key]; + + if (typeof data === 'function') { + return [key, new ivm.Callback((...args: any[]) => obj[key](...args))]; + } + + return [key, copyData(data)]; + }), + ), + }; +}; + +// Transferable data can be passed to isolates directly +const isTransferable = (data: any): data is ivm.Transferable => { + const dataType = typeof data; + + if (data === ivm) { + return true; + } + + if (['null', 'undefined', 'string', 'number', 'boolean', 'function'].includes(dataType)) { + return true; + } + + if (dataType !== 'object') { + return false; + } + + return ( + data instanceof ivm.Isolate || + data instanceof ivm.Context || + data instanceof ivm.Script || + data instanceof ivm.ExternalCopy || + data instanceof ivm.Callback || + data instanceof ivm.Reference + ); +}; + +// Semi-transferable data can be copied with an ivm.ExternalCopy without needing any manipulation. +const isSemiTransferable = (data: any) => data instanceof ArrayBuffer; + +const copyData = | any[]>(data: T) => (isTransferable(data) ? data : copyObject(data)); +const makeTransferable = (data: any) => (isTransferable(data) ? data : new ivm.ExternalCopy(copyObject(data)).copyInto()); + +export const buildSandbox = (context: Context) => { + const { global: jail } = context; + jail.setSync('global', jail.derefInto()); + jail.setSync('ivm', ivm); + + jail.setSync('s', makeTransferable(s)); + jail.setSync('console', makeTransferable(console)); + + jail.setSync( + 'serverFetch', + new ivm.Reference(async (url: string, ...args: any[]) => { + const result = await fetch(url, ...args); + return makeTransferable(result); + }), + ); +}; diff --git a/apps/meteor/app/integrations/server/lib/isolated-vm/getCompatibilityScript.ts b/apps/meteor/app/integrations/server/lib/isolated-vm/getCompatibilityScript.ts new file mode 100644 index 000000000000..77ce2475e8c2 --- /dev/null +++ b/apps/meteor/app/integrations/server/lib/isolated-vm/getCompatibilityScript.ts @@ -0,0 +1,60 @@ +export const getCompatibilityScript = (customScript?: string) => ` + const Store = (function() { + const store = {}; + return { + set(key, val) { + store[key] = val; + return val; + }, + get(key) { + return store[key]; + }, + }; + })(); + + const reproxy = (reference) => { + return new Proxy(reference, { + get(target, p, receiver) { + if (target !== reference || p === 'then') { + return Reflect.get(target, p, receiver); + } + + const data = reference.get(p); + + if (typeof data === 'object' && data instanceof ivm.Reference && data.typeof === 'function') { + return (...args) => data.apply(undefined, args, { arguments: { copy: true }, result: { promise: true } }); + } + + return data; + } + }); + }; + + //url, options, allowSelfSignedCertificate + const fetch = async (...args) => { + const result = await serverFetch.apply(undefined, args, { arguments: { copy: true }, result: { promise: true } }); + + if (result && typeof result === 'object' && result.isProxy) { + return reproxy(result); + } + + return result; + }; + + ${customScript} + + (function() { + const instance = new Script(); + + const functions = { + ...(typeof instance['prepare_outgoing_request'] === 'function' ? { prepare_outgoing_request : (...args) => instance.prepare_outgoing_request(...args) } : {}), + ...(typeof instance['process_outgoing_response'] === 'function' ? { process_outgoing_response : (...args) => instance.process_outgoing_response(...args) } : {}), + ...(typeof instance['process_incoming_request'] === 'function' ? { process_incoming_request : (...args) => instance.process_incoming_request(...args) } : {}), + }; + + return { + ...functions, + availableFunctions: Object.keys(functions), + } + })(); +`; diff --git a/apps/meteor/app/integrations/server/lib/isolated-vm/isolated-vm.ts b/apps/meteor/app/integrations/server/lib/isolated-vm/isolated-vm.ts new file mode 100644 index 000000000000..2c78b6d98a7c --- /dev/null +++ b/apps/meteor/app/integrations/server/lib/isolated-vm/isolated-vm.ts @@ -0,0 +1,99 @@ +import type { IIntegration, ValueOf } from '@rocket.chat/core-typings'; +import { pick } from '@rocket.chat/tools'; +import ivm, { type Reference } from 'isolated-vm'; + +import { IntegrationScriptEngine } from '../ScriptEngine'; +import type { IScriptClass, CompatibilityScriptResult, FullScriptClass } from '../definition'; +import { buildSandbox } from './buildSandbox'; +import { getCompatibilityScript } from './getCompatibilityScript'; + +const DISABLE_INTEGRATION_SCRIPTS = ['yes', 'true', 'ivm'].includes(String(process.env.DISABLE_INTEGRATION_SCRIPTS).toLowerCase()); + +export class IsolatedVMScriptEngine extends IntegrationScriptEngine { + protected isDisabled(): boolean { + return DISABLE_INTEGRATION_SCRIPTS; + } + + protected async callScriptFunction( + scriptReference: Reference>, + ...params: Parameters> + ): Promise { + return scriptReference.applySync(undefined, params, { + arguments: { copy: true }, + result: { copy: true, promise: true }, + }); + } + + protected async runScriptMethod({ + script, + method, + params, + }: { + integrationId: IIntegration['_id']; + script: Partial; + method: keyof IScriptClass; + params: Record; + }): Promise { + const fn = script[method]; + + if (typeof fn !== 'function') { + throw new Error('integration-method-not-found'); + } + + return fn(params); + } + + protected async getIntegrationScript(integration: IIntegration): Promise> { + if (this.disabled) { + throw new Error('integration-scripts-disabled'); + } + + const compiledScript = this.compiledScripts[integration._id]; + if (compiledScript && +compiledScript._updatedAt === +integration._updatedAt) { + return compiledScript.script; + } + + const script = integration.scriptCompiled; + try { + this.logger.info({ msg: 'Will evaluate the integration script', integration: pick(integration, 'name', '_id') }); + this.logger.debug(script); + + const isolate = new ivm.Isolate({ memoryLimit: 8 }); + + const ivmScript = await isolate.compileScript(getCompatibilityScript(script)); + + const ivmContext = isolate.createContextSync(); + buildSandbox(ivmContext); + + const ivmResult: Reference = await ivmScript.run(ivmContext, { + reference: true, + timeout: 3000, + }); + + const availableFunctions = await ivmResult.get('availableFunctions', { copy: true }); + const scriptFunctions = Object.fromEntries( + availableFunctions.map((functionName) => { + const fnReference = ivmResult.getSync(functionName, { reference: true }); + return [functionName, (...params: Parameters>) => this.callScriptFunction(fnReference, ...params)]; + }), + ) as Partial; + + this.compiledScripts[integration._id] = { + script: scriptFunctions, + store: {}, + _updatedAt: integration._updatedAt, + }; + + return scriptFunctions; + } catch (err: any) { + this.logger.error({ + msg: 'Error evaluating integration script', + integration: integration.name, + script, + err, + }); + + throw new Error('error-evaluating-script'); + } + } +} diff --git a/apps/meteor/app/integrations/server/lib/triggerHandler.js b/apps/meteor/app/integrations/server/lib/triggerHandler.js index b122b22ff355..b5050b8c4716 100644 --- a/apps/meteor/app/integrations/server/lib/triggerHandler.js +++ b/apps/meteor/app/integrations/server/lib/triggerHandler.js @@ -1,30 +1,25 @@ -import { Integrations, IntegrationHistory, Users, Rooms, Messages } from '@rocket.chat/models'; -import * as Models from '@rocket.chat/models'; -import { Random } from '@rocket.chat/random'; +import { Integrations, Users, Rooms, Messages } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { wrapExceptions } from '@rocket.chat/tools'; import { Meteor } from 'meteor/meteor'; -import moment from 'moment'; import _ from 'underscore'; -import { VM, VMScript } from 'vm2'; -import { omit } from '../../../../lib/utils/omit'; -import * as s from '../../../../lib/utils/stringUtils'; -import { deasyncPromise } from '../../../../server/deasync/deasync'; -import { httpCall } from '../../../../server/lib/http/call'; import { getRoomByNameOrIdWithOptionToJoin } from '../../../lib/server/functions/getRoomByNameOrIdWithOptionToJoin'; import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage'; import { settings } from '../../../settings/server'; import { outgoingEvents } from '../../lib/outgoingEvents'; -import { forbiddenModelMethods } from '../api/api'; import { outgoingLogger } from '../logger'; - -const DISABLE_INTEGRATION_SCRIPTS = ['yes', 'true'].includes(String(process.env.DISABLE_INTEGRATION_SCRIPTS).toLowerCase()); +import { IsolatedVMScriptEngine } from './isolated-vm/isolated-vm'; +import { updateHistory } from './updateHistory'; +import { VM2ScriptEngine } from './vm2/vm2'; class RocketChatIntegrationHandler { constructor() { this.successResults = [200, 201, 202]; this.compiledScripts = {}; this.triggers = {}; + this.vm2Engine = new VM2ScriptEngine(false); + this.ivmEngine = new IsolatedVMScriptEngine(false); } addIntegration(record) { @@ -51,6 +46,10 @@ class RocketChatIntegrationHandler { } } + getEngine(integration) { + return integration.scriptEngine === 'isolated-vm' ? this.ivmEngine : this.vm2Engine; + } + removeIntegration(record) { for (const trigger of Object.values(this.triggers)) { delete trigger[record._id]; @@ -67,114 +66,6 @@ class RocketChatIntegrationHandler { return false; } - async updateHistory({ - historyId, - step, - integration, - event, - data, - triggerWord, - ranPrepareScript, - prepareSentMessage, - processSentMessage, - resultMessage, - finished, - url, - httpCallData, - httpError, - httpResult, - error, - errorStack, - }) { - const history = { - type: 'outgoing-webhook', - step, - }; - - // Usually is only added on initial insert - if (integration) { - history.integration = integration; - } - - // Usually is only added on initial insert - if (event) { - history.event = event; - } - - if (data) { - history.data = { ...data }; - - if (data.user) { - history.data.user = omit(data.user, 'services'); - } - - if (data.room) { - history.data.room = data.room; - } - } - - if (triggerWord) { - history.triggerWord = triggerWord; - } - - if (typeof ranPrepareScript !== 'undefined') { - history.ranPrepareScript = ranPrepareScript; - } - - if (prepareSentMessage) { - history.prepareSentMessage = prepareSentMessage; - } - - if (processSentMessage) { - history.processSentMessage = processSentMessage; - } - - if (resultMessage) { - history.resultMessage = resultMessage; - } - - if (typeof finished !== 'undefined') { - history.finished = finished; - } - - if (url) { - history.url = url; - } - - if (typeof httpCallData !== 'undefined') { - history.httpCallData = httpCallData; - } - - if (httpError) { - history.httpError = httpError; - } - - if (typeof httpResult !== 'undefined') { - history.httpResult = JSON.stringify(httpResult, null, 2); - } - - if (typeof error !== 'undefined') { - history.error = error; - } - - if (typeof errorStack !== 'undefined') { - history.errorStack = errorStack; - } - - if (historyId) { - await IntegrationHistory.updateOne({ _id: historyId }, { $set: history }); - return historyId; - } - - history._createdAt = new Date(); - - const _id = Random.id(); - - await IntegrationHistory.insertOne({ _id, ...history }); - - return _id; - } - // Trigger is the trigger, nameOrId is a string which is used to try and find a room, room is a room, message is a message, and data contains "user_name" if trigger.impersonateUser is truthful. async sendMessage({ trigger, nameOrId = '', room, message, data }) { let user; @@ -229,199 +120,6 @@ class RocketChatIntegrationHandler { return message; } - buildSandbox(store = {}) { - const httpAsync = async (method, url, options) => { - try { - return { - result: await httpCall(method, url, options), - }; - } catch (error) { - return { error }; - } - }; - - const sandbox = { - scriptTimeout(reject) { - return setTimeout(() => reject('timed out'), 3000); - }, - _, - s, - console, - moment, - Promise, - Store: { - set: (key, val) => { - store[key] = val; - }, - get: (key) => store[key], - }, - HTTP: (method, url, options) => { - // TODO: deprecate, track and alert - return deasyncPromise(httpAsync(method, url, options)); - }, - // TODO: Export fetch as the non deprecated method - }; - - Object.keys(Models) - .filter((k) => !forbiddenModelMethods.includes(k)) - .forEach((k) => { - sandbox[k] = Models[k]; - }); - - return { store, sandbox }; - } - - getIntegrationScript(integration) { - if (DISABLE_INTEGRATION_SCRIPTS) { - throw new Meteor.Error('integration-scripts-disabled'); - } - - const compiledScript = this.compiledScripts[integration._id]; - if (compiledScript && +compiledScript._updatedAt === +integration._updatedAt) { - return compiledScript.script; - } - - const script = integration.scriptCompiled; - const { store, sandbox } = this.buildSandbox(); - - try { - outgoingLogger.info({ msg: 'Will evaluate script of Trigger', integration: integration.name }); - outgoingLogger.debug(script); - - const vmScript = new VMScript(`${script}; Script;`, 'script.js'); - const vm = new VM({ - sandbox, - }); - - const ScriptClass = vm.run(vmScript); - - if (ScriptClass) { - this.compiledScripts[integration._id] = { - script: new ScriptClass(), - store, - _updatedAt: integration._updatedAt, - }; - - return this.compiledScripts[integration._id].script; - } - } catch (err) { - outgoingLogger.error({ - msg: 'Error evaluating Script in Trigger', - integration: integration.name, - script, - err, - }); - throw new Meteor.Error('error-evaluating-script'); - } - - outgoingLogger.error(`Class "Script" not in Trigger ${integration.name}:`); - throw new Meteor.Error('class-script-not-found'); - } - - hasScriptAndMethod(integration, method) { - if ( - DISABLE_INTEGRATION_SCRIPTS || - integration.scriptEnabled !== true || - !integration.scriptCompiled || - integration.scriptCompiled.trim() === '' - ) { - return false; - } - - let script; - try { - script = this.getIntegrationScript(integration); - } catch (e) { - return false; - } - - return typeof script[method] !== 'undefined'; - } - - async executeScript(integration, method, params, historyId) { - if (DISABLE_INTEGRATION_SCRIPTS) { - return; - } - - let script; - try { - script = this.getIntegrationScript(integration); - } catch (e) { - await this.updateHistory({ - historyId, - step: 'execute-script-getting-script', - error: true, - errorStack: e, - }); - return; - } - - if (!script[method]) { - outgoingLogger.error(`Method "${method}" no found in the Integration "${integration.name}"`); - await this.updateHistory({ historyId, step: `execute-script-no-method-${method}` }); - return; - } - - try { - const { sandbox } = this.buildSandbox(this.compiledScripts[integration._id].store); - sandbox.script = script; - sandbox.method = method; - sandbox.params = params; - - await this.updateHistory({ historyId, step: `execute-script-before-running-${method}` }); - - const vm = new VM({ - timeout: 3000, - sandbox, - }); - - const result = await new Promise((resolve, reject) => { - process.nextTick(async () => { - try { - const scriptResult = await vm.run(` - new Promise((resolve, reject) => { - scriptTimeout(reject); - try { - resolve(script[method](params)) - } catch(e) { - reject(e); - } - }).catch((error) => { throw new Error(error); }); - `); - - resolve(scriptResult); - } catch (e) { - reject(e); - } - }); - }); - - outgoingLogger.debug({ - msg: `Script method "${method}" result of the Integration "${integration.name}" is:`, - result, - }); - - return result; - } catch (err) { - await this.updateHistory({ - historyId, - step: `execute-script-error-running-${method}`, - error: true, - errorStack: err.stack.replace(/^/gm, ' '), - }); - outgoingLogger.error({ - msg: 'Error running Script in the Integration', - integration: integration.name, - err, - }); - outgoingLogger.debug({ - msg: 'Error running Script in the Integration', - integration: integration.name, - script: integration.scriptCompiled, - }); // Only output the compiled script if debugging is enabled, so the logs don't get spammed. - } - } - eventNameArgumentsToObject(...args) { const argObject = { event: args[0], @@ -680,6 +378,17 @@ class RocketChatIntegrationHandler { } } + // Ensure that any errors thrown by the script engine will contibue to be compatible with Meteor.Error + async wrapScriptEngineCall(getter) { + return wrapExceptions(getter).catch((error) => { + if (error instanceof Error) { + throw new Meteor.Error(error.message); + } + + throw error; + }); + } + async executeTriggerUrl(url, trigger, { event, message, room, owner, user }, theHistoryId, tries = 0) { if (!this.isTriggerEnabled(trigger)) { outgoingLogger.warn(`The trigger "${trigger.name}" is no longer enabled, stopping execution of it at try: ${tries}`); @@ -715,7 +424,7 @@ class RocketChatIntegrationHandler { return; } - const historyId = await this.updateHistory({ + const historyId = await updateHistory({ step: 'start-execute-trigger-url', integration: trigger, event, @@ -731,36 +440,32 @@ class RocketChatIntegrationHandler { } this.mapEventArgsToData(data, { trigger, event, message, room, owner, user }); - await this.updateHistory({ historyId, step: 'mapped-args-to-data', data, triggerWord: word }); + await updateHistory({ historyId, step: 'mapped-args-to-data', data, triggerWord: word }); outgoingLogger.info(`Will be executing the Integration "${trigger.name}" to the url: ${url}`); outgoingLogger.debug({ data }); - let opts = { - params: {}, - method: 'POST', - url, - data, - auth: undefined, - headers: { - 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.0 Safari/537.36', - }, - }; + const scriptEngine = this.getEngine(trigger); - if (this.hasScriptAndMethod(trigger, 'prepare_outgoing_request')) { - opts = await this.executeScript(trigger, 'prepare_outgoing_request', { request: opts }, historyId); - } + const opts = await this.wrapScriptEngineCall(() => + scriptEngine.prepareOutgoingRequest({ + integration: trigger, + data, + url, + historyId, + }), + ); - await this.updateHistory({ historyId, step: 'after-maybe-ran-prepare', ranPrepareScript: true }); + await updateHistory({ historyId, step: 'after-maybe-ran-prepare', ranPrepareScript: true }); if (!opts) { - await this.updateHistory({ historyId, step: 'after-prepare-no-opts', finished: true }); + await updateHistory({ historyId, step: 'after-prepare-no-opts', finished: true }); return; } if (opts.message) { const prepareMessage = await this.sendMessage({ trigger, room, message: opts.message, data }); - await this.updateHistory({ + await updateHistory({ historyId, step: 'after-prepare-send-message', prepareSentMessage: prepareMessage, @@ -768,7 +473,7 @@ class RocketChatIntegrationHandler { } if (!opts.url || !opts.method) { - await this.updateHistory({ historyId, step: 'after-prepare-no-url_or_method', finished: true }); + await updateHistory({ historyId, step: 'after-prepare-no-url_or_method', finished: true }); return; } @@ -782,7 +487,7 @@ class RocketChatIntegrationHandler { opts.headers.Authorization = `Basic ${base64}`; } - await this.updateHistory({ + await updateHistory({ historyId, step: 'pre-http-call', url: opts.url, @@ -823,47 +528,42 @@ class RocketChatIntegrationHandler { } })(); - await this.updateHistory({ + await updateHistory({ historyId, step: 'after-http-call', httpError: null, httpResult: content, }); - if (this.hasScriptAndMethod(trigger, 'process_outgoing_response')) { - const sandbox = { + const responseContent = await this.wrapScriptEngineCall(() => + scriptEngine.processOutgoingResponse({ + integration: trigger, request: opts, - response: { - error: null, - status_code: res.status, // These values will be undefined to close issues #4175, #5762, and #5896 - content, - content_raw: content, - headers: Object.fromEntries(res.headers), - }, - }; - - const scriptResult = await this.executeScript(trigger, 'process_outgoing_response', sandbox, historyId); - - if (scriptResult && scriptResult.content) { - const resultMessage = await this.sendMessage({ - trigger, - room, - message: scriptResult.content, - data, - }); - await this.updateHistory({ - historyId, - step: 'after-process-send-message', - processSentMessage: resultMessage, - finished: true, - }); - return; - } + response: res, + content, + historyId, + }), + ); + + if (responseContent) { + const resultMessage = await this.sendMessage({ + trigger, + room, + message: responseContent, + data, + }); + await updateHistory({ + historyId, + step: 'after-process-send-message', + processSentMessage: resultMessage, + finished: true, + }); + return; + } - if (scriptResult === false) { - await this.updateHistory({ historyId, step: 'after-process-false-result', finished: true }); - return; - } + if (responseContent === false) { + await updateHistory({ historyId, step: 'after-process-false-result', finished: true }); + return; } // if the result contained nothing or wasn't a successful statusCode @@ -875,14 +575,14 @@ class RocketChatIntegrationHandler { }); if (res.status === 410) { - await this.updateHistory({ historyId, step: 'after-process-http-status-410', error: true }); + await updateHistory({ historyId, step: 'after-process-http-status-410', error: true }); outgoingLogger.error(`Disabling the Integration "${trigger.name}" because the status code was 401 (Gone).`); await Integrations.updateOne({ _id: trigger._id }, { $set: { enabled: false } }); return; } if (res.status === 500) { - await this.updateHistory({ historyId, step: 'after-process-http-status-500', error: true }); + await updateHistory({ historyId, step: 'after-process-http-status-500', error: true }); outgoingLogger.error({ msg: `Error "500" for the Integration "${trigger.name}" to ${url}.`, content, @@ -893,7 +593,7 @@ class RocketChatIntegrationHandler { if (trigger.retryFailedCalls) { if (tries < trigger.retryCount && trigger.retryDelay) { - await this.updateHistory({ historyId, error: true, step: `going-to-retry-${tries + 1}` }); + await updateHistory({ historyId, error: true, step: `going-to-retry-${tries + 1}` }); let waitTime; @@ -912,7 +612,7 @@ class RocketChatIntegrationHandler { break; default: const er = new Error("The integration's retryDelay setting is invalid."); - await this.updateHistory({ + await updateHistory({ historyId, step: 'failed-and-retry-delay-is-invalid', error: true, @@ -926,10 +626,10 @@ class RocketChatIntegrationHandler { void this.executeTriggerUrl(url, trigger, { event, message, room, owner, user }, historyId, tries + 1); }, waitTime); } else { - await this.updateHistory({ historyId, step: 'too-many-retries', error: true }); + await updateHistory({ historyId, step: 'too-many-retries', error: true }); } } else { - await this.updateHistory({ + await updateHistory({ historyId, step: 'failed-and-not-configured-to-retry', error: true, @@ -943,7 +643,7 @@ class RocketChatIntegrationHandler { if (content && this.successResults.includes(res.status)) { if (data?.text || data?.attachments) { const resultMsg = await this.sendMessage({ trigger, room, message: data, data }); - await this.updateHistory({ + await updateHistory({ historyId, step: 'url-response-sent-message', resultMessage: resultMsg, @@ -954,7 +654,7 @@ class RocketChatIntegrationHandler { }) .catch(async (error) => { outgoingLogger.error(error); - await this.updateHistory({ + await updateHistory({ historyId, step: 'after-http-call', httpError: error, diff --git a/apps/meteor/app/integrations/server/lib/updateHistory.ts b/apps/meteor/app/integrations/server/lib/updateHistory.ts new file mode 100644 index 000000000000..9f7a3017108d --- /dev/null +++ b/apps/meteor/app/integrations/server/lib/updateHistory.ts @@ -0,0 +1,96 @@ +import type { IIntegrationHistory, IIntegration, IMessage, AtLeast } from '@rocket.chat/core-typings'; +import { IntegrationHistory } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; + +import { omit } from '../../../../lib/utils/omit'; + +export const updateHistory = async ({ + historyId, + step, + integration, + event, + data, + triggerWord, + ranPrepareScript, + prepareSentMessage, + processSentMessage, + resultMessage, + finished, + url, + httpCallData, + httpError, + httpResult, + error, + errorStack, +}: { + historyId: IIntegrationHistory['_id']; + step: IIntegrationHistory['step']; + integration?: IIntegration; + event?: string; + triggerWord?: string; + ranPrepareScript?: boolean; + prepareSentMessage?: { channel: string; message: Partial }[]; + processSentMessage?: { channel: string; message: Partial }[]; + resultMessage?: { channel: string; message: Partial }[]; + finished?: boolean; + url?: string; + httpCallData?: Record; // ProcessedOutgoingRequest.data + httpError?: any; // null or whatever error type `fetch` may throw + httpResult?: string | null; + + error?: boolean; + errorStack?: any; // Error | Error['stack'] + + data?: Record; +}) => { + const { user: userData, room: roomData, ...fullData } = data || {}; + + const history: AtLeast = { + type: 'outgoing-webhook', + step, + + // Usually is only added on initial insert + ...(integration ? { integration } : {}), + // Usually is only added on initial insert + ...(event ? { event } : {}), + ...(fullData + ? { + data: { + ...fullData, + ...(userData ? { user: omit(userData, 'services') } : {}), + ...(roomData ? { room: roomData } : {}), + }, + } + : {}), + ...(triggerWord ? { triggerWord } : {}), + ...(typeof ranPrepareScript !== 'undefined' ? { ranPrepareScript } : {}), + ...(prepareSentMessage ? { prepareSentMessage } : {}), + ...(processSentMessage ? { processSentMessage } : {}), + ...(resultMessage ? { resultMessage } : {}), + ...(typeof finished !== 'undefined' ? { finished } : {}), + ...(url ? { url } : {}), + ...(typeof httpCallData !== 'undefined' ? { httpCallData } : {}), + ...(httpError ? { httpError } : {}), + ...(typeof httpResult !== 'undefined' ? { httpResult: JSON.stringify(httpResult, null, 2) } : {}), + ...(typeof error !== 'undefined' ? { error } : {}), + ...(typeof errorStack !== 'undefined' ? { errorStack } : {}), + }; + + if (historyId) { + await IntegrationHistory.updateOne({ _id: historyId }, { $set: history }); + return historyId; + } + + // Can't create a new history without there being an integration + if (!history.integration) { + throw new Error('error-invalid-integration'); + } + + history._createdAt = new Date(); + + const _id = Random.id(); + + await IntegrationHistory.insertOne({ _id, ...history } as IIntegrationHistory); + + return _id; +}; diff --git a/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts b/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts index d9c2db78b62e..398f81161279 100644 --- a/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts +++ b/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts @@ -1,19 +1,18 @@ import type { IUser, INewOutgoingIntegration, IOutgoingIntegration, IUpdateOutgoingIntegration } from '@rocket.chat/core-typings'; import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; +import { pick } from '@rocket.chat/tools'; import { Babel } from 'meteor/babel-compiler'; import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import _ from 'underscore'; import { parseCSV } from '../../../../lib/utils/parseCSV'; import { hasPermissionAsync, hasAllPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { outgoingEvents } from '../../lib/outgoingEvents'; +import { isScriptEngineFrozen } from './validateScriptEngine'; const scopedChannels = ['all_public_channels', 'all_private_groups', 'all_direct_messages']; const validChannelChars = ['@', '#']; -const FREEZE_INTEGRATION_SCRIPTS = ['yes', 'true'].includes(String(process.env.FREEZE_INTEGRATION_SCRIPTS).toLowerCase()); - function _verifyRequiredFields(integration: INewOutgoingIntegration | IUpdateOutgoingIntegration): void { if ( !integration.event || @@ -152,6 +151,7 @@ export const validateOutgoingIntegration = async function ( const integrationData: IOutgoingIntegration = { ...integration, + scriptEngine: integration.scriptEngine ?? 'isolated-vm', type: 'webhook-outgoing', channel: channels, userId: user._id, @@ -171,7 +171,13 @@ export const validateOutgoingIntegration = async function ( delete integrationData.triggerWords; } - if (!FREEZE_INTEGRATION_SCRIPTS && integration.scriptEnabled === true && integration.script && integration.script.trim() !== '') { + // Only compile the script if it is enabled and using a sandbox that is not frozen + if ( + !isScriptEngineFrozen(integrationData.scriptEngine) && + integration.scriptEnabled === true && + integration.script && + integration.script.trim() !== '' + ) { try { const babelOptions = Object.assign(Babel.getDefaultOptions({ runtime: false }), { compact: true, @@ -183,7 +189,7 @@ export const validateOutgoingIntegration = async function ( integrationData.scriptError = undefined; } catch (e) { integrationData.scriptCompiled = undefined; - integrationData.scriptError = e instanceof Error ? _.pick(e, 'name', 'message', 'stack') : undefined; + integrationData.scriptError = e instanceof Error ? pick(e, 'name', 'message', 'stack') : undefined; } } diff --git a/apps/meteor/app/integrations/server/lib/validateScriptEngine.ts b/apps/meteor/app/integrations/server/lib/validateScriptEngine.ts new file mode 100644 index 000000000000..c20dc9c59427 --- /dev/null +++ b/apps/meteor/app/integrations/server/lib/validateScriptEngine.ts @@ -0,0 +1,26 @@ +import type { IntegrationScriptEngine } from '@rocket.chat/core-typings'; +import { wrapExceptions } from '@rocket.chat/tools'; + +const FREEZE_INTEGRATION_SCRIPTS_VALUE = String(process.env.FREEZE_INTEGRATION_SCRIPTS).toLowerCase(); +const FREEZE_INTEGRATION_SCRIPTS = ['yes', 'true'].includes(FREEZE_INTEGRATION_SCRIPTS_VALUE); + +export const validateScriptEngine = (engine?: IntegrationScriptEngine) => { + if (FREEZE_INTEGRATION_SCRIPTS) { + throw new Error('integration-scripts-disabled'); + } + + const engineCode = engine === 'isolated-vm' ? 'ivm' : 'vm2'; + + if (engineCode === FREEZE_INTEGRATION_SCRIPTS_VALUE) { + if (engineCode === 'ivm') { + throw new Error('integration-scripts-isolated-vm-disabled'); + } + + throw new Error('integration-scripts-vm2-disabled'); + } + + return true; +}; + +export const isScriptEngineFrozen = (engine?: IntegrationScriptEngine) => + wrapExceptions(() => !validateScriptEngine(engine)).catch(() => true); diff --git a/apps/meteor/app/integrations/server/lib/vm2/buildSandbox.ts b/apps/meteor/app/integrations/server/lib/vm2/buildSandbox.ts new file mode 100644 index 000000000000..9ba74404cf26 --- /dev/null +++ b/apps/meteor/app/integrations/server/lib/vm2/buildSandbox.ts @@ -0,0 +1,88 @@ +import * as Models from '@rocket.chat/models'; +import moment from 'moment'; +import _ from 'underscore'; + +import * as s from '../../../../../lib/utils/stringUtils'; +import { deasyncPromise } from '../../../../../server/deasync/deasync'; +import { httpCall } from '../../../../../server/lib/http/call'; + +const forbiddenModelMethods: readonly (keyof typeof Models)[] = ['registerModel', 'getCollectionName']; + +type ModelName = Exclude; + +export type Vm2Sandbox = { + scriptTimeout: (reject: (reason?: any) => void) => ReturnType; + _: typeof _; + s: typeof s; + console: typeof console; + moment: typeof moment; + Promise: typeof Promise; + Store: { + set: IsIncoming extends true ? (key: string, value: any) => any : (key: string, value: any) => void; + get: (key: string) => any; + }; + HTTP: (method: string, url: string, options: Record) => unknown; +} & (IsIncoming extends true ? { Livechat: undefined } : never) & + Record; + +export const buildSandbox = ( + store: Record, + isIncoming?: IsIncoming, +): { + store: Record; + sandbox: Vm2Sandbox; +} => { + const httpAsync = async (method: string, url: string, options: Record) => { + try { + return { + result: await httpCall(method, url, options), + }; + } catch (error) { + return { error }; + } + }; + + const sandbox = { + scriptTimeout(reject: (reason?: any) => void) { + return setTimeout(() => reject('timed out'), 3000); + }, + _, + s, + console, + moment, + Promise, + // There's a small difference between the sandbox that is sent to incoming and to outgoing scripts + // Technically we could unify this but since we're deprecating vm2 anyway I'm keeping this old behavior here until the feature is removed completely + ...(isIncoming + ? { + Livechat: undefined, + Store: { + set: (key: string, val: any): any => { + store[key] = val; + return val; + }, + get: (key: string) => store[key], + }, + } + : { + Store: { + set: (key: string, val: any): void => { + store[key] = val; + }, + get: (key: string) => store[key], + }, + }), + HTTP: (method: string, url: string, options: Record) => { + // TODO: deprecate, track and alert + return deasyncPromise(httpAsync(method, url, options)); + }, + } as Vm2Sandbox; + + (Object.keys(Models) as ModelName[]) + .filter((k) => !forbiddenModelMethods.includes(k)) + .forEach((k) => { + sandbox[k] = Models[k]; + }); + + return { store, sandbox }; +}; diff --git a/apps/meteor/app/integrations/server/lib/vm2/vm2.ts b/apps/meteor/app/integrations/server/lib/vm2/vm2.ts new file mode 100644 index 000000000000..5f7519d69346 --- /dev/null +++ b/apps/meteor/app/integrations/server/lib/vm2/vm2.ts @@ -0,0 +1,111 @@ +import type { IIntegration } from '@rocket.chat/core-typings'; +import { VM, VMScript } from 'vm2'; + +import { IntegrationScriptEngine } from '../ScriptEngine'; +import type { IScriptClass } from '../definition'; +import { buildSandbox, type Vm2Sandbox } from './buildSandbox'; + +const DISABLE_INTEGRATION_SCRIPTS = ['yes', 'true', 'vm2'].includes(String(process.env.DISABLE_INTEGRATION_SCRIPTS).toLowerCase()); + +export class VM2ScriptEngine extends IntegrationScriptEngine { + protected isDisabled(): boolean { + return DISABLE_INTEGRATION_SCRIPTS; + } + + protected buildSandbox(store: Record = {}): { store: Record; sandbox: Vm2Sandbox } { + return buildSandbox(store, this.incoming); + } + + protected async runScriptMethod({ + integrationId, + script, + method, + params, + }: { + integrationId: IIntegration['_id']; + script: IScriptClass; + method: keyof IScriptClass; + params: Record; + }): Promise { + const { sandbox } = this.buildSandbox(this.compiledScripts[integrationId].store); + + const vm = new VM({ + timeout: 3000, + sandbox: { + ...sandbox, + script, + method, + params, + ...(this.incoming && 'request' in params ? { request: params.request } : {}), + }, + }); + + return new Promise((resolve, reject) => { + process.nextTick(async () => { + try { + const scriptResult = await vm.run(` + new Promise((resolve, reject) => { + scriptTimeout(reject); + try { + resolve(script[method](params)) + } catch(e) { + reject(e); + } + }).catch((error) => { throw new Error(error); }); + `); + + resolve(scriptResult); + } catch (e) { + reject(e); + } + }); + }); + } + + protected async getIntegrationScript(integration: IIntegration): Promise> { + if (this.disabled) { + throw new Error('integration-scripts-disabled'); + } + + const compiledScript = this.compiledScripts[integration._id]; + if (compiledScript && +compiledScript._updatedAt === +integration._updatedAt) { + return compiledScript.script; + } + + const script = integration.scriptCompiled; + const { store, sandbox } = this.buildSandbox(); + + try { + this.logger.info({ msg: 'Will evaluate script of Trigger', integration: integration.name }); + this.logger.debug(script); + + const vmScript = new VMScript(`${script}; Script;`, 'script.js'); + const vm = new VM({ + sandbox, + }); + + const ScriptClass = vm.run(vmScript); + + if (ScriptClass) { + this.compiledScripts[integration._id] = { + script: new ScriptClass(), + store, + _updatedAt: integration._updatedAt, + }; + + return this.compiledScripts[integration._id].script; + } + } catch (err) { + this.logger.error({ + msg: 'Error evaluating Script in Trigger', + integration: integration.name, + script, + err, + }); + throw new Error('error-evaluating-script'); + } + + this.logger.error({ msg: 'Class "Script" not in Trigger', integration: integration.name }); + throw new Error('class-script-not-found'); + } +} diff --git a/apps/meteor/app/integrations/server/logger.ts b/apps/meteor/app/integrations/server/logger.ts index a1b90ea8c101..47da6bcf22c5 100644 --- a/apps/meteor/app/integrations/server/logger.ts +++ b/apps/meteor/app/integrations/server/logger.ts @@ -1,4 +1,4 @@ -import { Logger } from '../../logger/server'; +import { Logger } from '@rocket.chat/logger'; const logger = new Logger('Integrations'); diff --git a/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts b/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts index 307e83f7aef7..45548a17a565 100644 --- a/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts @@ -8,11 +8,10 @@ import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; import { hasPermissionAsync, hasAllPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; +import { validateScriptEngine, isScriptEngineFrozen } from '../../lib/validateScriptEngine'; const validChannelChars = ['@', '#']; -const FREEZE_INTEGRATION_SCRIPTS = ['yes', 'true'].includes(String(process.env.FREEZE_INTEGRATION_SCRIPTS).toLowerCase()); - declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -32,7 +31,8 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn alias: Match.Maybe(String), emoji: Match.Maybe(String), scriptEnabled: Boolean, - overrideDestinationChannelEnabled: Boolean, + scriptEngine: Match.Maybe(String), + overrideDestinationChannelEnabled: Match.Maybe(Boolean), script: Match.Maybe(String), avatar: Match.Maybe(String), }), @@ -76,8 +76,8 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn }); } - if (FREEZE_INTEGRATION_SCRIPTS && integration.script?.trim()) { - throw new Meteor.Error('integration-scripts-disabled'); + if (integration.script?.trim()) { + validateScriptEngine(integration.scriptEngine ?? 'isolated-vm'); } const user = await Users.findOne({ username: integration.username }); @@ -90,15 +90,23 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn const integrationData: IIncomingIntegration = { ...integration, + scriptEngine: integration.scriptEngine ?? 'isolated-vm', type: 'webhook-incoming', channel: channels, + overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled ?? false, token: Random.id(48), userId: user._id, _createdAt: new Date(), _createdBy: await Users.findOne({ _id: userId }, { projection: { username: 1 } }), }; - if (integration.scriptEnabled === true && integration.script && integration.script.trim() !== '') { + // Only compile the script if it is enabled and using a sandbox that is not frozen + if ( + !isScriptEngineFrozen(integrationData.scriptEngine) && + integration.scriptEnabled === true && + integration.script && + integration.script.trim() !== '' + ) { try { let babelOptions = Babel.getDefaultOptions({ runtime: false }); babelOptions = _.extend(babelOptions, { compact: true, minified: true, comments: false }); diff --git a/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts b/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts index 5e5fe67f3977..5358e3233ce7 100644 --- a/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts @@ -1,16 +1,16 @@ import type { IIntegration, INewIncomingIntegration, IUpdateIncomingIntegration } from '@rocket.chat/core-typings'; import { Integrations, Roles, Subscriptions, Users, Rooms } from '@rocket.chat/models'; +import { wrapExceptions } from '@rocket.chat/tools'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Babel } from 'meteor/babel-compiler'; import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; import { hasAllPermissionAsync, hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; +import { isScriptEngineFrozen, validateScriptEngine } from '../../lib/validateScriptEngine'; const validChannelChars = ['@', '#']; -const FREEZE_INTEGRATION_SCRIPTS = ['yes', 'true'].includes(String(process.env.FREEZE_INTEGRATION_SCRIPTS).toLowerCase()); - declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -66,11 +66,20 @@ Meteor.methods({ }); } - if (FREEZE_INTEGRATION_SCRIPTS) { - if (currentIntegration.script?.trim() !== integration.script?.trim()) { - throw new Meteor.Error('integration-scripts-disabled'); - } - } else { + const oldScriptEngine = currentIntegration.scriptEngine ?? 'vm2'; + const scriptEngine = integration.scriptEngine ?? oldScriptEngine; + if ( + integration.script?.trim() && + (scriptEngine !== oldScriptEngine || integration.script?.trim() !== currentIntegration.script?.trim()) + ) { + wrapExceptions(() => validateScriptEngine(scriptEngine)).catch((e) => { + throw new Meteor.Error(e.message); + }); + } + + const isFrozen = isScriptEngineFrozen(scriptEngine); + + if (!isFrozen) { let scriptCompiled: string | undefined; let scriptError: Pick | undefined; @@ -165,13 +174,16 @@ Meteor.methods({ emoji: integration.emoji, alias: integration.alias, channel: channels, - ...(FREEZE_INTEGRATION_SCRIPTS + ...(isFrozen ? {} : { script: integration.script, scriptEnabled: integration.scriptEnabled, + scriptEngine, }), - overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled, + ...(typeof integration.overrideDestinationChannelEnabled !== 'undefined' && { + overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled, + }), _updatedAt: new Date(), _updatedBy: await Users.findOne({ _id: this.userId }, { projection: { username: 1 } }), }, diff --git a/apps/meteor/app/integrations/server/methods/outgoing/addOutgoingIntegration.ts b/apps/meteor/app/integrations/server/methods/outgoing/addOutgoingIntegration.ts index 9e5d29261b36..59879f99d475 100644 --- a/apps/meteor/app/integrations/server/methods/outgoing/addOutgoingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/outgoing/addOutgoingIntegration.ts @@ -6,6 +6,7 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; import { validateOutgoingIntegration } from '../../lib/validateOutgoingIntegration'; +import { validateScriptEngine } from '../../lib/validateScriptEngine'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -14,8 +15,6 @@ declare module '@rocket.chat/ui-contexts' { } } -const FREEZE_INTEGRATION_SCRIPTS = ['yes', 'true'].includes(String(process.env.FREEZE_INTEGRATION_SCRIPTS).toLowerCase()); - export const addOutgoingIntegration = async (userId: string, integration: INewOutgoingIntegration): Promise => { check( integration, @@ -29,6 +28,7 @@ export const addOutgoingIntegration = async (userId: string, integration: INewOu emoji: Match.Maybe(String), scriptEnabled: Boolean, script: Match.Maybe(String), + scriptEngine: Match.Maybe(String), urls: Match.Maybe([String]), event: Match.Maybe(String), triggerWords: Match.Maybe([String]), @@ -52,8 +52,8 @@ export const addOutgoingIntegration = async (userId: string, integration: INewOu throw new Meteor.Error('not_authorized'); } - if (FREEZE_INTEGRATION_SCRIPTS && integration.script?.trim()) { - throw new Meteor.Error('integration-scripts-disabled'); + if (integration.script?.trim()) { + validateScriptEngine(integration.scriptEngine ?? 'isolated-vm'); } const integrationData = await validateOutgoingIntegration(integration, userId); diff --git a/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts b/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts index 166badee823d..9e62561ebf9a 100644 --- a/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts @@ -1,10 +1,12 @@ import type { IIntegration, INewOutgoingIntegration, IUpdateOutgoingIntegration } from '@rocket.chat/core-typings'; import { Integrations, Users } from '@rocket.chat/models'; +import { wrapExceptions } from '@rocket.chat/tools'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; import { validateOutgoingIntegration } from '../../lib/validateOutgoingIntegration'; +import { isScriptEngineFrozen, validateScriptEngine } from '../../lib/validateScriptEngine'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -16,8 +18,6 @@ declare module '@rocket.chat/ui-contexts' { } } -const FREEZE_INTEGRATION_SCRIPTS = ['yes', 'true'].includes(String(process.env.FREEZE_INTEGRATION_SCRIPTS).toLowerCase()); - Meteor.methods({ async updateOutgoingIntegration(integrationId, _integration) { if (!this.userId) { @@ -53,10 +53,19 @@ Meteor.methods({ throw new Meteor.Error('invalid_integration', '[methods] updateOutgoingIntegration -> integration not found'); } - if (FREEZE_INTEGRATION_SCRIPTS && integration.script?.trim() !== currentIntegration.script?.trim()) { - throw new Meteor.Error('integration-scripts-disabled'); + const oldScriptEngine = currentIntegration.scriptEngine ?? 'vm2'; + const scriptEngine = integration.scriptEngine ?? oldScriptEngine; + if ( + integration.script?.trim() && + (scriptEngine !== oldScriptEngine || integration.script?.trim() !== currentIntegration.script?.trim()) + ) { + wrapExceptions(() => validateScriptEngine(scriptEngine)).catch((e) => { + throw new Meteor.Error(e.message); + }); } + const isFrozen = isScriptEngineFrozen(scriptEngine); + await Integrations.updateOne( { _id: integrationId }, { @@ -74,11 +83,12 @@ Meteor.methods({ userId: integration.userId, urls: integration.urls, token: integration.token, - ...(FREEZE_INTEGRATION_SCRIPTS + ...(isFrozen ? {} : { script: integration.script, scriptEnabled: integration.scriptEnabled, + scriptEngine, ...(integration.scriptCompiled ? { scriptCompiled: integration.scriptCompiled } : { scriptError: integration.scriptError }), }), triggerWords: integration.triggerWords, @@ -90,7 +100,7 @@ Meteor.methods({ _updatedAt: new Date(), _updatedBy: await Users.findOne({ _id: this.userId }, { projection: { username: 1 } }), }, - ...(FREEZE_INTEGRATION_SCRIPTS + ...(isFrozen ? {} : { $unset: { diff --git a/apps/meteor/app/irc/server/irc-bridge/index.js b/apps/meteor/app/irc/server/irc-bridge/index.js index 07ede8c6a2e3..9ab6c47987d0 100644 --- a/apps/meteor/app/irc/server/irc-bridge/index.js +++ b/apps/meteor/app/irc/server/irc-bridge/index.js @@ -1,3 +1,4 @@ +import { Logger } from '@rocket.chat/logger'; import { Settings } from '@rocket.chat/models'; import moment from 'moment'; import Queue from 'queue-fifo'; @@ -6,7 +7,6 @@ import { callbacks } from '../../../../lib/callbacks'; import { afterLeaveRoomCallback } from '../../../../lib/callbacks/afterLeaveRoomCallback'; import { afterLogoutCleanUpCallback } from '../../../../lib/callbacks/afterLogoutCleanUpCallback'; import { withThrottling } from '../../../../lib/utils/highOrderFunctions'; -import { Logger } from '../../../logger/server'; import * as servers from '../servers'; import * as localCommandHandlers from './localHandlers'; import * as peerCommandHandlers from './peerHandlers'; diff --git a/apps/meteor/app/irc/server/servers/RFC2813/index.js b/apps/meteor/app/irc/server/servers/RFC2813/index.js index 7e9fe74a8c26..8c5b8abf19c8 100644 --- a/apps/meteor/app/irc/server/servers/RFC2813/index.js +++ b/apps/meteor/app/irc/server/servers/RFC2813/index.js @@ -2,7 +2,8 @@ import { EventEmitter } from 'events'; import net from 'net'; import util from 'util'; -import { Logger } from '../../../../logger/server'; +import { Logger } from '@rocket.chat/logger'; + import localCommandHandlers from './localCommandHandlers'; import parseMessage from './parseMessage'; import peerCommandHandlers from './peerCommandHandlers'; diff --git a/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts b/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts index 6dc477a2926f..835f59419ad5 100644 --- a/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts +++ b/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts @@ -3,6 +3,7 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Subscriptions, Rooms } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; +import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/lib/getSubscriptionAutotranslateDefaultConfig'; export const addUserToDefaultChannels = async function (user: IUser, silenced?: boolean): Promise { await callbacks.run('beforeJoinDefaultChannels', user); @@ -11,6 +12,7 @@ export const addUserToDefaultChannels = async function (user: IUser, silenced?: }).toArray(); for await (const room of defaultRooms) { if (!(await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { _id: 1 } }))) { + const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(user); // Add a subscription to this user await Subscriptions.createWithRoomAndUser(room, user, { ts: new Date(), @@ -20,6 +22,7 @@ export const addUserToDefaultChannels = async function (user: IUser, silenced?: userMentions: 1, groupMentions: 0, ...(room.favorite && { f: true }), + ...autoTranslateConfig, }); // Insert user joined message diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index 660af823de9e..4e29576cf3bb 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -7,6 +7,7 @@ import { Meteor } from 'meteor/meteor'; import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; import { AppEvents, Apps } from '../../../../ee/server/apps'; import { callbacks } from '../../../../lib/callbacks'; +import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/lib/getSubscriptionAutotranslateDefaultConfig'; import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; export const addUserToRoom = async function ( @@ -24,7 +25,7 @@ export const addUserToRoom = async function ( }); } - const userToBeAdded = typeof user !== 'string' ? user : await Users.findOneByUsername(user.replace('@', '')); + const userToBeAdded = typeof user === 'string' ? await Users.findOneByUsername(user.replace('@', '')) : await Users.findOneById(user._id); const roomDirectives = roomCoordinator.getRoomDirectives(room.t); if (!userToBeAdded) { @@ -70,6 +71,8 @@ export const addUserToRoom = async function ( await callbacks.run('beforeJoinRoom', userToBeAdded, room); } + const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(userToBeAdded); + await Subscriptions.createWithRoomAndUser(room, userToBeAdded as IUser, { ts: now, open: true, @@ -77,6 +80,7 @@ export const addUserToRoom = async function ( unread: 1, userMentions: 1, groupMentions: 0, + ...autoTranslateConfig, }); if (!userToBeAdded.username) { diff --git a/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts b/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts index cd2951537e58..64202ab39dab 100644 --- a/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts +++ b/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts @@ -1,6 +1,6 @@ import { api } from '@rocket.chat/core-services'; import type { IRoom } from '@rocket.chat/core-typings'; -import { Messages, Rooms, Subscriptions } from '@rocket.chat/models'; +import { Messages, Rooms, Subscriptions, ReadReceipts, Users } from '@rocket.chat/models'; import { i18n } from '../../../../server/lib/i18n'; import { FileUpload } from '../../../file-upload/server'; @@ -86,6 +86,9 @@ export async function cleanRoomHistory({ } } + const selectedMessageIds = limit + ? await Messages.findByIdPinnedTimestampLimitAndUsers(rid, excludePinned, ignoreDiscussion, ts, limit, fromUsers, ignoreThreads) + : undefined; const count = await Messages.removeByIdPinnedTimestampLimitAndUsers( rid, excludePinned, @@ -94,7 +97,18 @@ export async function cleanRoomHistory({ limit, fromUsers, ignoreThreads, + selectedMessageIds, ); + + if (!limit) { + const uids = await Users.findByUsernames(fromUsers, { projection: { _id: 1 } }) + .map((user) => user._id) + .toArray(); + await ReadReceipts.removeByIdPinnedTimestampLimitAndUsers(rid, excludePinned, ignoreDiscussion, ts, uids, ignoreThreads); + } else if (selectedMessageIds) { + await ReadReceipts.removeByMessageIds(selectedMessageIds); + } + if (count) { const lastMessage = await Messages.getLastVisibleMessageSentWithNoTypeByRoomId(rid); await Rooms.resetLastMessageById(rid, lastMessage); diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index 192139f96b7c..312451f54845 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; import { Message, Team } from '@rocket.chat/core-services'; import type { ICreateRoomParams, ISubscriptionExtraData } from '@rocket.chat/core-services'; @@ -8,7 +9,7 @@ import { Meteor } from 'meteor/meteor'; import { Apps } from '../../../../ee/server/apps/orchestrator'; import { callbacks } from '../../../../lib/callbacks'; import { beforeCreateRoomCallback } from '../../../../lib/callbacks/beforeCreateRoomCallback'; -import { addUserRolesAsync } from '../../../../server/lib/roles/addUserRoles'; +import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/lib/getSubscriptionAutotranslateDefaultConfig'; import { getValidRoomName } from '../../../utils/server/lib/getValidRoomName'; import { createDirectRoom } from './createDirectRoom'; @@ -19,10 +20,90 @@ const isValidName = (name: unknown): name is string => { const onlyUsernames = (members: unknown): members is string[] => Array.isArray(members) && members.every((member) => typeof member === 'string'); +async function createUsersSubscriptions({ + room, + shouldBeHandledByFederation, + members, + now, + owner, + options, +}: { + room: IRoom; + shouldBeHandledByFederation: boolean; + members: string[]; + now: Date; + owner: IUser; + options?: ICreateRoomParams['options']; +}) { + if (shouldBeHandledByFederation) { + const extra: Partial = options?.subscriptionExtra || {}; + extra.open = true; + extra.ls = now; + + if (room.prid) { + extra.prid = room.prid; + } + + await Subscriptions.createWithRoomAndUser(room, owner, extra); + + return; + } + + const subs = []; + + const memberIds = []; + + const membersCursor = Users.findUsersByUsernames>(members, { + projection: { 'username': 1, 'settings.preferences': 1, 'federated': 1, 'roles': 1 }, + }); + + for await (const member of membersCursor) { + try { + await callbacks.run('federation.beforeAddUserToARoom', { user: member, inviter: owner }, room); + await callbacks.run('beforeAddedToRoom', { user: member, inviter: owner }); + } catch (error) { + continue; + } + + memberIds.push(member._id); + + const extra: Partial = options?.subscriptionExtra || {}; + + extra.open = true; + + if (room.prid) { + extra.prid = room.prid; + } + + if (member.username === owner.username) { + extra.ls = now; + extra.roles = ['owner']; + } + + const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(member); + + subs.push({ + user: member, + extraData: { + ...extra, + ...autoTranslateConfig, + }, + }); + } + + if (!['d', 'l'].includes(room.t)) { + await Users.addRoomByUserIds(memberIds, room._id); + } + + await Subscriptions.createWithRoomAndManyUsers(room, subs); + + await Rooms.incUsersCountById(room._id, subs.length); +} + export const createRoom = async ( type: T, name: T extends 'd' ? undefined : string, - ownerUsername: string | undefined, + owner: T extends 'd' ? IUser | undefined : IUser, members: T extends 'd' ? IUser[] : string[] = [], excludeSelf?: boolean, readOnly?: boolean, @@ -45,7 +126,7 @@ export const createRoom = async ( // options, }); if (type === 'd') { - return createDirectRoom(members as IUser[], extraData, { ...options, creator: options?.creator || ownerUsername }); + return createDirectRoom(members as IUser[], extraData, { ...options, creator: options?.creator || owner?.username }); } if (!onlyUsernames(members)) { @@ -61,15 +142,13 @@ export const createRoom = async ( }); } - if (!ownerUsername) { + if (!owner) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { function: 'RocketChat.createRoom', }); } - const owner = await Users.findOneByUsernameIgnoringCase(ownerUsername, { projection: { username: 1, name: 1 } }); - - if (!ownerUsername || !owner) { + if (!owner?.username) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { function: 'RocketChat.createRoom', }); @@ -138,51 +217,12 @@ export const createRoom = async ( if (type === 'c') { await callbacks.run('beforeCreateChannel', owner, roomProps); } - const room = await Rooms.createWithFullRoomData(roomProps); - const shouldBeHandledByFederation = room.federated === true || ownerUsername.includes(':'); - if (shouldBeHandledByFederation) { - const extra: Partial = options?.subscriptionExtra || {}; - extra.open = true; - extra.ls = now; - - if (room.prid) { - extra.prid = room.prid; - } - - await Subscriptions.createWithRoomAndUser(room, owner, extra); - } else { - for await (const username of [...new Set(members)]) { - const member = await Users.findOneByUsername(username, { - projection: { 'username': 1, 'settings.preferences': 1, 'federated': 1, 'roles': 1 }, - }); - if (!member) { - continue; - } - - try { - await callbacks.run('federation.beforeAddUserToARoom', { user: member, inviter: owner }, room); - await callbacks.run('beforeAddedToRoom', { user: member, inviter: owner }); - } catch (error) { - continue; - } - - const extra: Partial = options?.subscriptionExtra || {}; - extra.open = true; - - if (room.prid) { - extra.prid = room.prid; - } - - if (username === owner.username) { - extra.ls = now; - } + const room = await Rooms.createWithFullRoomData(roomProps); - await Subscriptions.createWithRoomAndUser(room, member, extra); - } - } + const shouldBeHandledByFederation = room.federated === true || owner.username.includes(':'); - await addUserRolesAsync(owner._id, ['owner'], room._id); + await createUsersSubscriptions({ room, members, now, owner, options, shouldBeHandledByFederation }); if (type === 'c') { if (room.teamId) { @@ -191,7 +231,7 @@ export const createRoom = async ( await Message.saveSystemMessage('user-added-room-to-team', team.roomId, room.name || '', owner); } } - await callbacks.run('afterCreateChannel', owner, room); + callbacks.runAsync('afterCreateChannel', owner, room); } else if (type === 'p') { callbacks.runAsync('afterCreatePrivateGroup', owner, room); } diff --git a/apps/meteor/app/lib/server/functions/deleteMessage.ts b/apps/meteor/app/lib/server/functions/deleteMessage.ts index aad9535b970a..6fddbd719fa9 100644 --- a/apps/meteor/app/lib/server/functions/deleteMessage.ts +++ b/apps/meteor/app/lib/server/functions/deleteMessage.ts @@ -1,6 +1,6 @@ import { api } from '@rocket.chat/core-services'; import type { AtLeast, IMessage, IUser } from '@rocket.chat/core-typings'; -import { Messages, Rooms, Uploads, Users } from '@rocket.chat/models'; +import { Messages, Rooms, Uploads, Users, ReadReceipts } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { Apps } from '../../../../ee/server/apps'; @@ -62,6 +62,7 @@ export async function deleteMessage(message: IMessage, user: IUser): Promise { - await joinRoomMethod(userId, rid); -} diff --git a/apps/meteor/app/lib/server/functions/parseUrlsInMessage.ts b/apps/meteor/app/lib/server/functions/parseUrlsInMessage.ts index c7ef5afab3dc..2a63d024bdd9 100644 --- a/apps/meteor/app/lib/server/functions/parseUrlsInMessage.ts +++ b/apps/meteor/app/lib/server/functions/parseUrlsInMessage.ts @@ -1,9 +1,10 @@ -import type { IMessage } from '@rocket.chat/core-typings'; +import type { IMessage, AtLeast } from '@rocket.chat/core-typings'; import { getMessageUrlRegex } from '../../../../lib/getMessageUrlRegex'; import { Markdown } from '../../../markdown/server'; +import { settings } from '../../../settings/server'; -export const parseUrlsInMessage = (message: IMessage & { parseUrls?: boolean }): IMessage => { +export const parseUrlsInMessage = (message: AtLeast & { parseUrls?: boolean }, previewUrls?: string[]) => { if (message.parseUrls === false) { return message; } @@ -13,13 +14,15 @@ export const parseUrlsInMessage = (message: IMessage & { parseUrls?: boolean }): const urls = message.html?.match(getMessageUrlRegex()) || []; if (urls) { - message.urls = [...new Set(urls)].map((url) => ({ url, meta: {} })); + message.urls = [...new Set(urls)].map((url) => ({ + url, + meta: {}, + ...(previewUrls && !previewUrls.includes(url) && !url.includes(settings.get('Site_Url')) && { ignoreParse: true }), + })); } message = Markdown.mountTokensBack(message, false); message.msg = message.html || message.msg; delete message.html; delete message.tokens; - - return message; }; diff --git a/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts b/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts index da566d859393..75b232462077 100644 --- a/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts +++ b/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts @@ -1,4 +1,4 @@ -import { Messages, Roles, Rooms, Subscriptions } from '@rocket.chat/models'; +import { Messages, Roles, Rooms, Subscriptions, ReadReceipts } from '@rocket.chat/models'; import { FileUpload } from '../../../file-upload/server'; import type { SubscribedRoomsForUserWithDetails } from './getRoomsWithSingleOwner'; @@ -7,7 +7,12 @@ const bulkRoomCleanUp = async (rids: string[]): Promise => { // no bulk deletion for files await Promise.all(rids.map((rid) => FileUpload.removeFilesByRoomId(rid))); - return Promise.all([Subscriptions.removeByRoomIds(rids), Messages.removeByRoomIds(rids), Rooms.removeByIds(rids)]); + return Promise.all([ + Subscriptions.removeByRoomIds(rids), + Messages.removeByRoomIds(rids), + ReadReceipts.removeByRoomIds(rids), + Rooms.removeByIds(rids), + ]); }; export const relinquishRoomOwnerships = async function ( diff --git a/apps/meteor/app/lib/server/functions/saveUser.js b/apps/meteor/app/lib/server/functions/saveUser.js index f912626c833e..46bef4c7d1aa 100644 --- a/apps/meteor/app/lib/server/functions/saveUser.js +++ b/apps/meteor/app/lib/server/functions/saveUser.js @@ -15,6 +15,7 @@ import * as Mailer from '../../../mailer/server/api'; import { settings } from '../../../settings/server'; import { safeGetMeteorUser } from '../../../utils/server/functions/safeGetMeteorUser'; import { validateEmailDomain } from '../lib'; +import { generatePassword } from '../lib/generatePassword'; import { passwordPolicy } from '../lib/passwordPolicy'; import { checkEmailAvailability } from './checkEmailAvailability'; import { checkUsernameAvailability } from './checkUsernameAvailability'; @@ -344,7 +345,7 @@ export const saveUser = async function (userId, userData) { if (userData.hasOwnProperty('setRandomPassword')) { if (userData.setRandomPassword) { - userData.password = passwordPolicy.generatePassword(); + userData.password = generatePassword(); userData.requirePasswordChange = true; sendPassword = true; } @@ -365,6 +366,7 @@ export const saveUser = async function (userId, userData) { _id: userData._id, username: userData.username, name: userData.name, + updateUsernameInBackground: true, })) ) { throw new Meteor.Error('error-could-not-save-identity', 'Could not save user identity', { diff --git a/apps/meteor/app/lib/server/functions/saveUserIdentity.ts b/apps/meteor/app/lib/server/functions/saveUserIdentity.ts index 2eb360e150c6..34ca0ca246db 100644 --- a/apps/meteor/app/lib/server/functions/saveUserIdentity.ts +++ b/apps/meteor/app/lib/server/functions/saveUserIdentity.ts @@ -1,5 +1,7 @@ +import type { IUser } from '@rocket.chat/core-typings'; import { Messages, VideoConference, LivechatDepartmentAgents, Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { SystemLogger } from '../../../../server/lib/logger/system'; import { FileUpload } from '../../../file-upload/server'; import { _setRealName } from './setRealName'; import { _setUsername } from './setUsername'; @@ -11,7 +13,17 @@ import { validateName } from './validateName'; * @param {object} changes changes to the user */ -export async function saveUserIdentity({ _id, name: rawName, username: rawUsername }: { _id: string; name?: string; username?: string }) { +export async function saveUserIdentity({ + _id, + name: rawName, + username: rawUsername, + updateUsernameInBackground = false, +}: { + _id: string; + name?: string; + username?: string; + updateUsernameInBackground?: boolean; // TODO: remove this +}) { if (!_id) { return false; } @@ -48,46 +60,91 @@ export async function saveUserIdentity({ _id, name: rawName, username: rawUserna // if coming from old username, update all references if (previousUsername) { - if (usernameChanged && typeof rawUsername !== 'undefined') { - const fileStore = FileUpload.getStore('Avatars'); - const previousFile = await fileStore.model.findOneByName(previousUsername); - const file = await fileStore.model.findOneByName(username); - if (file) { - await fileStore.model.deleteFile(file._id); - } - if (previousFile) { - await fileStore.model.updateFileNameById(previousFile._id, username); - } - - await Messages.updateAllUsernamesByUserId(user._id, username); - await Messages.updateUsernameOfEditByUserId(user._id, username); - - const cursor = Messages.findByMention(previousUsername); - for await (const msg of cursor) { - const updatedMsg = msg.msg.replace(new RegExp(`@${previousUsername}`, 'ig'), `@${username}`); - await Messages.updateUsernameAndMessageOfMentionByIdAndOldUsername(msg._id, previousUsername, username, updatedMsg); - } - - await Rooms.replaceUsername(previousUsername, username); - await Rooms.replaceMutedUsername(previousUsername, username); - await Rooms.replaceUsernameOfUserByUserId(user._id, username); - await Subscriptions.setUserUsernameByUserId(user._id, username); - - await LivechatDepartmentAgents.replaceUsernameOfAgentByUserId(user._id, username); + const handleUpdateParams = { + username, + previousUsername, + rawUsername, + usernameChanged, + user, + name, + previousName, + rawName, + nameChanged, + }; + if (updateUsernameInBackground) { + setImmediate(async () => { + try { + await updateUsernameReferences(handleUpdateParams); + } catch (err) { + SystemLogger.error(err); + } + }); + } else { + await updateUsernameReferences(handleUpdateParams); } + } + + return true; +} - // update other references if either the name or username has changed - if (usernameChanged || nameChanged) { - // update name and fname of 1-on-1 direct messages - await Subscriptions.updateDirectNameAndFnameByName(previousUsername, rawUsername && username, rawName && name); +async function updateUsernameReferences({ + username, + previousUsername, + rawUsername, + usernameChanged, + user, + name, + previousName, + rawName, + nameChanged, +}: { + username: string; + previousUsername: string; + rawUsername?: string; + usernameChanged: boolean; + user: IUser; + name: string; + previousName: string | undefined; + rawName?: string; + nameChanged: boolean; +}): Promise { + if (usernameChanged && typeof rawUsername !== 'undefined') { + const fileStore = FileUpload.getStore('Avatars'); + const previousFile = await fileStore.model.findOneByName(previousUsername); + const file = await fileStore.model.findOneByName(username); + if (file) { + await fileStore.model.deleteFile(file._id); + } + if (previousFile) { + await fileStore.model.updateFileNameById(previousFile._id, username); + } - // update name and fname of group direct messages - await updateGroupDMsName(user); + await Messages.updateAllUsernamesByUserId(user._id, username); + await Messages.updateUsernameOfEditByUserId(user._id, username); - // update name and username of users on video conferences - await VideoConference.updateUserReferences(user._id, username || previousUsername, name || previousName); + const cursor = Messages.findByMention(previousUsername); + for await (const msg of cursor) { + const updatedMsg = msg.msg.replace(new RegExp(`@${previousUsername}`, 'ig'), `@${username}`); + await Messages.updateUsernameAndMessageOfMentionByIdAndOldUsername(msg._id, previousUsername, username, updatedMsg); } + + await Rooms.replaceUsername(previousUsername, username); + await Rooms.replaceMutedUsername(previousUsername, username); + await Rooms.replaceUsernameOfUserByUserId(user._id, username); + await Subscriptions.setUserUsernameByUserId(user._id, username); + + await LivechatDepartmentAgents.replaceUsernameOfAgentByUserId(user._id, username); } - return true; + // update other references if either the name or username has changed + if (usernameChanged || nameChanged) { + // update name and fname of 1-on-1 direct messages + await Subscriptions.updateDirectNameAndFnameByName(previousUsername, rawUsername && username, rawName && name); + + // update name and fname of group direct messages + await updateGroupDMsName(user); + + // update name and username of users on video conferences + await VideoConference.updateUserReferences(user._id, username || previousUsername, name || previousName); + } } diff --git a/apps/meteor/app/lib/server/functions/sendMessage.js b/apps/meteor/app/lib/server/functions/sendMessage.js index 70a630127713..72247d4b1870 100644 --- a/apps/meteor/app/lib/server/functions/sendMessage.js +++ b/apps/meteor/app/lib/server/functions/sendMessage.js @@ -1,3 +1,4 @@ +import { Message } from '@rocket.chat/core-services'; import { Messages } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; @@ -203,7 +204,16 @@ function cleanupMessageObject(message) { ['customClass'].forEach((field) => delete message[field]); } -export const sendMessage = async function (user, message, room, upsert = false) { +/** + * Validates and sends the message object. + * @param {IUser} user + * @param {AtLeast} message + * @param {IRoom} room + * @param {boolean} [upsert=false] + * @param {string[]} [previewUrls] + * @returns {Promise} + */ +export const sendMessage = async function (user, message, room, upsert = false, previewUrls = undefined) { if (!user || !message || !room._id) { return false; } @@ -236,7 +246,9 @@ export const sendMessage = async function (user, message, room, upsert = false) cleanupMessageObject(message); - parseUrlsInMessage(message); + parseUrlsInMessage(message, previewUrls); + + message = await Message.beforeSave({ message, room, user }); message = await callbacks.run('beforeSaveMessage', message, room); if (message) { diff --git a/apps/meteor/app/lib/server/functions/updateMessage.ts b/apps/meteor/app/lib/server/functions/updateMessage.ts index ba122c18b4af..9c544bd9a333 100644 --- a/apps/meteor/app/lib/server/functions/updateMessage.ts +++ b/apps/meteor/app/lib/server/functions/updateMessage.ts @@ -1,4 +1,5 @@ -import type { IEditedMessage, IMessage, IUser } from '@rocket.chat/core-typings'; +import { Message } from '@rocket.chat/core-services'; +import type { IEditedMessage, IMessage, IUser, AtLeast } from '@rocket.chat/core-typings'; import { Messages, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -7,7 +8,12 @@ import { callbacks } from '../../../../lib/callbacks'; import { settings } from '../../../settings/server'; import { parseUrlsInMessage } from './parseUrlsInMessage'; -export const updateMessage = async function (message: IMessage, user: IUser, originalMsg?: IMessage): Promise { +export const updateMessage = async function ( + message: AtLeast, + user: IUser, + originalMsg?: IMessage, + previewUrls?: string[], +): Promise { const originalMessage = originalMsg || (await Messages.findOneById(message._id)); // For the Rocket.Chat Apps :) @@ -33,7 +39,7 @@ export const updateMessage = async function (message: IMessage, user: IUser, ori await Messages.cloneAndSaveAsHistoryById(message._id, user as Required>); } - Object.assign>(message, { + Object.assign, Omit>(message, { editedAt: new Date(), editedBy: { _id: user._id, @@ -41,7 +47,15 @@ export const updateMessage = async function (message: IMessage, user: IUser, ori }, }); - parseUrlsInMessage(message); + parseUrlsInMessage(message, previewUrls); + + const room = await Rooms.findOneById(message.rid); + if (!room) { + return; + } + + // TODO remove type cast + message = await Message.beforeSave({ message: message as IMessage, room, user }); message = await callbacks.run('beforeSaveMessage', message); @@ -62,12 +76,6 @@ export const updateMessage = async function (message: IMessage, user: IUser, ori }, ); - const room = await Rooms.findOneById(message.rid); - - if (!room) { - return; - } - if (Apps?.isLoaded()) { // This returns a promise, but it won't mutate anything about the message // so, we don't really care if it is successful or fails diff --git a/apps/meteor/app/lib/server/index.ts b/apps/meteor/app/lib/server/index.ts index 597d03752dcc..8fa779ec9644 100644 --- a/apps/meteor/app/lib/server/index.ts +++ b/apps/meteor/app/lib/server/index.ts @@ -23,7 +23,6 @@ import './methods/deleteUserOwnAccount'; import './methods/executeSlashCommandPreview'; import './startup/filterATAllTag'; import './startup/filterATHereTag'; -import './methods/filterBadWords'; import './methods/getChannelHistory'; import './methods/getRoomJoinCode'; import './methods/getRoomRoles'; diff --git a/apps/meteor/app/lib/server/lib/bugsnag.ts b/apps/meteor/app/lib/server/lib/bugsnag.ts index 3c00f894e33a..25dbfe0fdc0e 100644 --- a/apps/meteor/app/lib/server/lib/bugsnag.ts +++ b/apps/meteor/app/lib/server/lib/bugsnag.ts @@ -1,7 +1,7 @@ import Bugsnag from '@bugsnag/js'; +import { Logger } from '@rocket.chat/logger'; import { Meteor } from 'meteor/meteor'; -import { Logger } from '../../../logger/server'; import { settings } from '../../../settings/server'; import { Info } from '../../../utils/rocketchat.info'; diff --git a/apps/meteor/app/lib/server/lib/debug.js b/apps/meteor/app/lib/server/lib/debug.js index e7f634d3d2a4..aaa492e80337 100644 --- a/apps/meteor/app/lib/server/lib/debug.js +++ b/apps/meteor/app/lib/server/lib/debug.js @@ -1,9 +1,9 @@ import { InstanceStatus } from '@rocket.chat/instance-status'; +import { Logger } from '@rocket.chat/logger'; import { Meteor } from 'meteor/meteor'; import { WebApp } from 'meteor/webapp'; import _ from 'underscore'; -import { Logger } from '../../../../server/lib/logger/Logger'; import { getMethodArgs } from '../../../../server/lib/logger/logPayloads'; import { metrics } from '../../../metrics/server'; import { settings } from '../../../settings/server'; diff --git a/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts b/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts index a13bb7b59bfc..6ca8e3edfbb9 100644 --- a/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts +++ b/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts @@ -1,7 +1,7 @@ +import { Logger } from '@rocket.chat/logger'; import type { Response } from 'meteor/rocketchat:restivus'; import semver from 'semver'; -import { Logger } from '../../../logger/server'; import { metrics } from '../../../metrics/server'; const deprecationLogger = new Logger('DeprecationWarning'); diff --git a/apps/meteor/app/lib/server/lib/generatePassword.ts b/apps/meteor/app/lib/server/lib/generatePassword.ts new file mode 100644 index 000000000000..bf8d2474b7a7 --- /dev/null +++ b/apps/meteor/app/lib/server/lib/generatePassword.ts @@ -0,0 +1,31 @@ +import generator from 'generate-password'; + +import { passwordPolicy } from './passwordPolicy'; + +export const generatePassword = (): string => { + const policies = passwordPolicy.getPasswordPolicy(); + + const maxLength: number = (policies.policy.find(([key]) => key === 'get-password-policy-maxLength')?.[1]?.maxLength as number) || -1; + const minLength: number = (policies.policy.find(([key]) => key === 'get-password-policy-minLength')?.[1]?.minLength as number) || -1; + + const length = Math.min(Math.max(minLength, 12), maxLength > 0 ? maxLength : Number.MAX_SAFE_INTEGER); + + if (policies.enabled) { + for (let i = 0; i < 10; i++) { + const password = generator.generate({ + length, + ...(policies.policy && { numbers: true }), + ...(policies.policy.some(([key]) => key === 'get-password-policy-mustContainAtLeastOneSpecialCharacter') && { symbols: true }), + ...(policies.policy.some(([key]) => key === 'get-password-policy-mustContainAtLeastOneLowercase') && { lowercase: true }), + ...(policies.policy.some(([key]) => key === 'get-password-policy-mustContainAtLeastOneUppercase') && { uppercase: true }), + strict: true, + }); + + if (passwordPolicy.validate(password)) { + return password; + } + } + } + + return generator.generate({ length: 17 }); +}; diff --git a/apps/meteor/app/lib/server/lib/passwordPolicy.js b/apps/meteor/app/lib/server/lib/passwordPolicy.js deleted file mode 100644 index 57490d1712c1..000000000000 --- a/apps/meteor/app/lib/server/lib/passwordPolicy.js +++ /dev/null @@ -1,32 +0,0 @@ -import { settings } from '../../../settings/server'; -import PasswordPolicy from './PasswordPolicyClass'; - -export const passwordPolicy = new PasswordPolicy(); - -settings.watch('Accounts_Password_Policy_Enabled', (value) => { - passwordPolicy.enabled = value; -}); -settings.watch('Accounts_Password_Policy_MinLength', (value) => { - passwordPolicy.minLength = value; -}); -settings.watch('Accounts_Password_Policy_MaxLength', (value) => { - passwordPolicy.maxLength = value; -}); -settings.watch('Accounts_Password_Policy_ForbidRepeatingCharacters', (value) => { - passwordPolicy.forbidRepeatingCharacters = value; -}); -settings.watch('Accounts_Password_Policy_ForbidRepeatingCharactersCount', (value) => { - passwordPolicy.forbidRepeatingCharactersCount = value; -}); -settings.watch('Accounts_Password_Policy_AtLeastOneLowercase', (value) => { - passwordPolicy.mustContainAtLeastOneLowercase = value; -}); -settings.watch('Accounts_Password_Policy_AtLeastOneUppercase', (value) => { - passwordPolicy.mustContainAtLeastOneUppercase = value; -}); -settings.watch('Accounts_Password_Policy_AtLeastOneNumber', (value) => { - passwordPolicy.mustContainAtLeastOneNumber = value; -}); -settings.watch('Accounts_Password_Policy_AtLeastOneSpecialCharacter', (value) => { - passwordPolicy.mustContainAtLeastOneSpecialCharacter = value; -}); diff --git a/apps/meteor/app/lib/server/lib/passwordPolicy.ts b/apps/meteor/app/lib/server/lib/passwordPolicy.ts new file mode 100644 index 000000000000..b40447ca56ab --- /dev/null +++ b/apps/meteor/app/lib/server/lib/passwordPolicy.ts @@ -0,0 +1,64 @@ +import { PasswordPolicy } from '@rocket.chat/password-policies'; + +import { settings } from '../../../settings/server'; + +const enabled = false; +const minLength = -1; +const maxLength = -1; +const forbidRepeatingCharacters = false; +const forbidRepeatingCharactersCount = 3; +const mustContainAtLeastOneLowercase = false; +const mustContainAtLeastOneUppercase = false; +const mustContainAtLeastOneNumber = false; +const mustContainAtLeastOneSpecialCharacter = false; + +export let passwordPolicy = new PasswordPolicy({ + enabled, + minLength, + maxLength, + forbidRepeatingCharacters, + forbidRepeatingCharactersCount, + mustContainAtLeastOneLowercase, + mustContainAtLeastOneUppercase, + mustContainAtLeastOneNumber, + mustContainAtLeastOneSpecialCharacter, + throwError: true, +}); + +settings.watchMultiple( + [ + 'Accounts_Password_Policy_Enabled', + 'Accounts_Password_Policy_MinLength', + 'Accounts_Password_Policy_MaxLength', + 'Accounts_Password_Policy_ForbidRepeatingCharacters', + 'Accounts_Password_Policy_ForbidRepeatingCharactersCount', + 'Accounts_Password_Policy_AtLeastOneLowercase', + 'Accounts_Password_Policy_AtLeastOneUppercase', + 'Accounts_Password_Policy_AtLeastOneNumber', + 'Accounts_Password_Policy_AtLeastOneSpecialCharacter', + ], + ([ + enabled, + minLength, + maxLength, + forbidRepeatingCharacters, + forbidRepeatingCharactersCount, + mustContainAtLeastOneLowercase, + mustContainAtLeastOneUppercase, + mustContainAtLeastOneNumber, + mustContainAtLeastOneSpecialCharacter, + ]) => { + passwordPolicy = new PasswordPolicy({ + enabled: Boolean(enabled), + minLength: Number(minLength), + maxLength: Number(maxLength), + forbidRepeatingCharacters: Boolean(forbidRepeatingCharacters), + forbidRepeatingCharactersCount: Number(forbidRepeatingCharactersCount), + mustContainAtLeastOneLowercase: Boolean(mustContainAtLeastOneLowercase), + mustContainAtLeastOneUppercase: Boolean(mustContainAtLeastOneUppercase), + mustContainAtLeastOneNumber: Boolean(mustContainAtLeastOneNumber), + mustContainAtLeastOneSpecialCharacter: Boolean(mustContainAtLeastOneSpecialCharacter), + throwError: true, + }); + }, +); diff --git a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.js b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.js index 17296d8f374a..ce262e4e6756 100644 --- a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.js +++ b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.js @@ -1,3 +1,4 @@ +import { Room } from '@rocket.chat/core-services'; import { Subscriptions, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import moment from 'moment'; @@ -7,12 +8,7 @@ import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { Notification } from '../../../notification-queue/server/NotificationQueue'; import { settings } from '../../../settings/server'; -import { - callJoinRoom, - messageContainsHighlight, - parseMessageTextPerUser, - replaceMentionedUsernamesWithFullNames, -} from '../functions/notifications'; +import { messageContainsHighlight, parseMessageTextPerUser, replaceMentionedUsernamesWithFullNames } from '../functions/notifications'; import { notifyDesktopUser, shouldNotifyDesktop } from '../functions/notifications/desktop'; import { getEmailData, shouldNotifyEmail } from '../functions/notifications/email'; import { getPushData, shouldNotifyMobile } from '../functions/notifications/mobile'; @@ -365,7 +361,7 @@ export async function sendAllNotifications(message, room) { const users = await Promise.all( mentions.map(async (userId) => { - await callJoinRoom(userId, room._id); + await Room.join({ room, user: { _id: userId } }); return userId; }), diff --git a/apps/meteor/app/lib/server/methods/createChannel.ts b/apps/meteor/app/lib/server/methods/createChannel.ts index ff8182cec8c9..98cea517bed4 100644 --- a/apps/meteor/app/lib/server/methods/createChannel.ts +++ b/apps/meteor/app/lib/server/methods/createChannel.ts @@ -35,8 +35,7 @@ export const createChannelMethod = async ( throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'createChannel' }); } - const user = await Users.findOneById(userId, { projection: { username: 1 } }); - + const user = await Users.findOneById(userId); if (!user?.username) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'createChannel' }); } @@ -44,7 +43,7 @@ export const createChannelMethod = async ( if (!(await hasPermissionAsync(userId, 'create-c'))) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'createChannel' }); } - return createRoom('c', name, user.username, members, excludeSelf, readOnly, { + return createRoom('c', name, user, members, excludeSelf, readOnly, { customFields, ...extraData, }); diff --git a/apps/meteor/app/lib/server/methods/createPrivateGroup.ts b/apps/meteor/app/lib/server/methods/createPrivateGroup.ts index 65298949a345..75097b5c89b8 100644 --- a/apps/meteor/app/lib/server/methods/createPrivateGroup.ts +++ b/apps/meteor/app/lib/server/methods/createPrivateGroup.ts @@ -1,4 +1,4 @@ -import type { ICreatedRoom } from '@rocket.chat/core-typings'; +import type { ICreatedRoom, IUser } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Match, check } from 'meteor/check'; @@ -21,7 +21,7 @@ declare module '@rocket.chat/ui-contexts' { } export const createPrivateGroupMethod = async ( - userId: string, + user: IUser, name: string, members: string[], readOnly = false, @@ -35,23 +35,12 @@ export const createPrivateGroupMethod = async ( > => { check(name, String); check(members, Match.Optional([String])); - if (!userId) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { - method: 'createPrivateGroup', - }); - } - const user = await Users.findOneById(userId, { projection: { username: 1 } }); - if (!user) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { - method: 'createPrivateGroup', - }); - } - if (!(await hasPermissionAsync(userId, 'create-p'))) { + if (!(await hasPermissionAsync(user._id, 'create-p'))) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'createPrivateGroup' }); } - return createRoom('p', name, user.username, members, excludeSelf, readOnly, { + return createRoom('p', name, user, members, excludeSelf, readOnly, { customFields, ...extraData, }); @@ -67,6 +56,13 @@ Meteor.methods({ }); } - return createPrivateGroupMethod(uid, name, members, readOnly, customFields, extraData); + const user = await Users.findOneById(uid); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'createPrivateGroup', + }); + } + + return createPrivateGroupMethod(user, name, members, readOnly, customFields, extraData); }, }); diff --git a/apps/meteor/app/lib/server/methods/filterBadWords.ts b/apps/meteor/app/lib/server/methods/filterBadWords.ts deleted file mode 100644 index 3db0ee76e997..000000000000 --- a/apps/meteor/app/lib/server/methods/filterBadWords.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { IMessage } from '@rocket.chat/core-typings'; -import Filter from 'bad-words'; -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; - -import { callbacks } from '../../../../lib/callbacks'; -import { settings } from '../../../settings/server'; - -const Dep = new Tracker.Dependency(); -Meteor.startup(() => { - settings.watchMultiple(['Message_AllowBadWordsFilter', 'Message_BadWordsFilterList', 'Message_BadWordsWhitelist'], () => { - Dep.changed(); - }); - Tracker.autorun(() => { - Dep.depend(); - const allowBadWordsFilter = settings.get('Message_AllowBadWordsFilter'); - - callbacks.remove('beforeSaveMessage', 'filterBadWords'); - - if (!allowBadWordsFilter) { - return; - } - - const badWordsList = settings.get('Message_BadWordsFilterList') as string | undefined; - const whiteList = settings.get('Message_BadWordsWhitelist') as string | undefined; - - const options = { - list: - badWordsList - ?.split(',') - .map((word) => word.trim()) - .filter(Boolean) || [], - // library definition does not allow optional definition - exclude: undefined, - splitRegex: undefined, - placeHolder: undefined, - regex: undefined, - replaceRegex: undefined, - emptyList: undefined, - }; - - const filter = new Filter(options); - - if (whiteList?.length) { - filter.removeWords(...whiteList.split(',').map((word) => word.trim())); - } - - callbacks.add( - 'beforeSaveMessage', - (message: IMessage) => { - if (!message.msg) { - return message; - } - try { - message.msg = filter.clean(message.msg); - } finally { - // eslint-disable-next-line no-unsafe-finally - return message; - } - }, - callbacks.priority.HIGH, - 'filterBadWords', - ); - }); -}); diff --git a/apps/meteor/app/lib/server/methods/joinRoom.ts b/apps/meteor/app/lib/server/methods/joinRoom.ts index 355fd49916ae..0fa3ac0b3c3b 100644 --- a/apps/meteor/app/lib/server/methods/joinRoom.ts +++ b/apps/meteor/app/lib/server/methods/joinRoom.ts @@ -1,63 +1,31 @@ -import type { IRoom, IRoomWithJoinCode, IUser } from '@rocket.chat/core-typings'; -import { Rooms, Users } from '@rocket.chat/models'; +import { Room } from '@rocket.chat/core-services'; +import type { IRoom } from '@rocket.chat/core-typings'; +import { Rooms } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; -import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; -import { canAccessRoomAsync } from '../../../authorization/server'; -import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { addUserToRoom } from '../functions/addUserToRoom'; - declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { - joinRoom(rid: IRoom['_id'], code?: unknown): boolean | undefined; + joinRoom(rid: IRoom['_id'], code?: string): boolean | undefined; } } -export const joinRoomMethod = async (userId: IUser['_id'], rid: IRoom['_id'], code?: unknown): Promise => { - check(rid, String); - - const user = await Users.findOneById(userId); - - if (!user) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'joinRoom' }); - } - - const room = await Rooms.findOneById(rid); - - if (!room) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'joinRoom' }); - } - - if (!(await roomCoordinator.getRoomDirectives(room.t)?.allowMemberAction(room, RoomMemberActions.JOIN, user._id))) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'joinRoom' }); - } - - if (!(await canAccessRoomAsync(room, user))) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'joinRoom' }); - } - if (room.joinCodeRequired === true && code !== room.joinCode && !(await hasPermissionAsync(user._id, 'join-without-join-code'))) { - throw new Meteor.Error('error-code-invalid', 'Invalid Room Password', { - method: 'joinRoom', - }); - } - - return addUserToRoom(rid, user); -}; - Meteor.methods({ async joinRoom(rid, code) { check(rid, String); const userId = await Meteor.userId(); - if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'joinRoom' }); } - return joinRoomMethod(userId, rid, code); + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'joinRoom' }); + } + + return Room.join({ room, user: { _id: userId }, ...(code ? { joinCode: code } : {}) }); }, }); diff --git a/apps/meteor/app/lib/server/methods/removeOAuthService.ts b/apps/meteor/app/lib/server/methods/removeOAuthService.ts index 1be3edeb2caa..6d1bb688979d 100644 --- a/apps/meteor/app/lib/server/methods/removeOAuthService.ts +++ b/apps/meteor/app/lib/server/methods/removeOAuthService.ts @@ -61,6 +61,7 @@ Meteor.methods({ Settings.removeById(`Accounts_OAuth_Custom-${name}-channels_admin`), Settings.removeById(`Accounts_OAuth_Custom-${name}-map_channels`), Settings.removeById(`Accounts_OAuth_Custom-${name}-groups_channel_map`), + Settings.removeById(`Accounts_OAuth_Custom-${name}-merge_users_distinct_services`), ]); }, }); diff --git a/apps/meteor/app/lib/server/methods/sendMessage.ts b/apps/meteor/app/lib/server/methods/sendMessage.ts index de9024110a41..ebdcdfd43d9b 100644 --- a/apps/meteor/app/lib/server/methods/sendMessage.ts +++ b/apps/meteor/app/lib/server/methods/sendMessage.ts @@ -15,7 +15,7 @@ import { settings } from '../../../settings/server'; import { sendMessage } from '../functions/sendMessage'; import { RateLimiter } from '../lib'; -export async function executeSendMessage(uid: IUser['_id'], message: AtLeast) { +export async function executeSendMessage(uid: IUser['_id'], message: AtLeast, previewUrls?: string[]) { if (message.tshow && !message.tmid) { throw new Meteor.Error('invalid-params', 'tshow provided but missing tmid', { method: 'sendMessage', @@ -82,7 +82,7 @@ export async function executeSendMessage(uid: IUser['_id'], message: AtLeast): any; + sendMessage(message: AtLeast, previewUrls?: string[]): any; } } Meteor.methods({ - sendMessage(message) { + sendMessage(message, previewUrls) { check(message, Object); const uid = Meteor.userId(); @@ -118,7 +118,7 @@ Meteor.methods({ } try { - return executeSendMessage(uid, message); + return executeSendMessage(uid, message, previewUrls); } catch (error: any) { if ((error.error || error.message) === 'error-not-allowed') { throw new Meteor.Error(error.error || error.message, error.reason, { diff --git a/apps/meteor/app/lib/server/methods/updateMessage.ts b/apps/meteor/app/lib/server/methods/updateMessage.ts index 2ef7d236b2a9..a34492400130 100644 --- a/apps/meteor/app/lib/server/methods/updateMessage.ts +++ b/apps/meteor/app/lib/server/methods/updateMessage.ts @@ -1,4 +1,4 @@ -import type { IEditedMessage, IMessage } from '@rocket.chat/core-typings'; +import type { IEditedMessage, IMessage, IUser, AtLeast } from '@rocket.chat/core-typings'; import { Messages, Users } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Match, check } from 'meteor/check'; @@ -12,97 +12,102 @@ import { updateMessage } from '../functions/updateMessage'; const allowedEditedFields = ['tshow', 'alias', 'attachments', 'avatar', 'emoji', 'msg']; -declare module '@rocket.chat/ui-contexts' { - // eslint-disable-next-line @typescript-eslint/naming-convention - interface ServerMethods { - updateMessage(message: IEditedMessage): void; +export async function executeUpdateMessage(uid: IUser['_id'], message: AtLeast, previewUrls?: string[]) { + const originalMessage = await Messages.findOneById(message._id); + if (!originalMessage?._id) { + return; } -} -Meteor.methods({ - async updateMessage(message: IEditedMessage) { - check(message, Match.ObjectIncluding({ _id: String })); + Object.entries(message).forEach(([key, value]) => { + if (!allowedEditedFields.includes(key) && value !== originalMessage[key as keyof IMessage]) { + throw new Meteor.Error('error-invalid-update-key', `Cannot update the message ${key}`, { + method: 'updateMessage', + }); + } + }); - const uid = Meteor.userId(); + const msgText = originalMessage?.attachments?.[0]?.description ?? originalMessage.msg; + if (msgText === message.msg && !previewUrls) { + return; + } - if (!uid) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateMessage' }); - } + if (!!message.tmid && originalMessage._id === message.tmid) { + throw new Meteor.Error('error-message-same-as-tmid', 'Cannot set tmid the same as the _id', { + method: 'updateMessage', + }); + } - const originalMessage = await Messages.findOneById(message._id); - if (!originalMessage?._id) { - return; - } + if (!originalMessage.tmid && !!message.tmid) { + throw new Meteor.Error('error-message-change-to-thread', 'Cannot update message to a thread', { method: 'updateMessage' }); + } - Object.entries(message).forEach(([key, value]) => { - if (!allowedEditedFields.includes(key) && value !== originalMessage[key as keyof IMessage]) { - throw new Meteor.Error('error-invalid-update-key', `Cannot update the message ${key}`, { - method: 'updateMessage', - }); - } + const _hasPermission = await hasPermissionAsync(uid, 'edit-message', message.rid); + const editAllowed = settings.get('Message_AllowEditing'); + const editOwn = originalMessage.u && originalMessage.u._id === uid; + + if (!_hasPermission && (!editAllowed || !editOwn)) { + throw new Meteor.Error('error-action-not-allowed', 'Message editing not allowed', { + method: 'updateMessage', + action: 'Message_editing', }); + } - const msgText = originalMessage?.attachments?.[0]?.description ?? originalMessage.msg; - if (msgText === message.msg) { - return; - } + const blockEditInMinutes = settings.get('Message_AllowEditing_BlockEditInMinutes'); + const bypassBlockTimeLimit = await hasPermissionAsync(uid, 'bypass-time-limit-edit-and-delete'); - if (!!message.tmid && originalMessage._id === message.tmid) { - throw new Meteor.Error('error-message-same-as-tmid', 'Cannot set tmid the same as the _id', { + if (!bypassBlockTimeLimit && Match.test(blockEditInMinutes, Number) && blockEditInMinutes !== 0) { + let currentTsDiff = 0; + let msgTs; + + if (originalMessage.ts instanceof Date || Match.test(originalMessage.ts, Number)) { + msgTs = moment(originalMessage.ts); + } + if (msgTs) { + currentTsDiff = moment().diff(msgTs, 'minutes'); + } + if (currentTsDiff >= blockEditInMinutes) { + throw new Meteor.Error('error-message-editing-blocked', 'Message editing is blocked', { method: 'updateMessage', }); } + } - if (!originalMessage.tmid && !!message.tmid) { - throw new Meteor.Error('error-message-change-to-thread', 'Cannot update message to a thread', { method: 'updateMessage' }); - } + const user = await Users.findOneById(uid); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateMessage' }); + } + await canSendMessageAsync(message.rid, { uid: user._id, username: user.username ?? undefined, ...user }); - const _hasPermission = await hasPermissionAsync(uid, 'edit-message', message.rid); - const editAllowed = settings.get('Message_AllowEditing'); - const editOwn = originalMessage.u && originalMessage.u._id === uid; + // It is possible to have an empty array as the attachments property, so ensure both things exist + if (originalMessage.attachments && originalMessage.attachments.length > 0 && originalMessage.attachments[0].description !== undefined) { + originalMessage.attachments[0].description = message.msg; + message.attachments = originalMessage.attachments; + message.msg = originalMessage.msg; + } - if (!_hasPermission && (!editAllowed || !editOwn)) { - throw new Meteor.Error('error-action-not-allowed', 'Message editing not allowed', { - method: 'updateMessage', - action: 'Message_editing', - }); - } + message.u = originalMessage.u; - const blockEditInMinutes = settings.get('Message_AllowEditing_BlockEditInMinutes'); - const bypassBlockTimeLimit = await hasPermissionAsync(uid, 'bypass-time-limit-edit-and-delete'); - - if (!bypassBlockTimeLimit && Match.test(blockEditInMinutes, Number) && blockEditInMinutes !== 0) { - let currentTsDiff = 0; - let msgTs; - - if (originalMessage.ts instanceof Date || Match.test(originalMessage.ts, Number)) { - msgTs = moment(originalMessage.ts); - } - if (msgTs) { - currentTsDiff = moment().diff(msgTs, 'minutes'); - } - if (currentTsDiff >= blockEditInMinutes) { - throw new Meteor.Error('error-message-editing-blocked', 'Message editing is blocked', { - method: 'updateMessage', - }); - } - } + return updateMessage(message, user, originalMessage, previewUrls); +} - const user = await Users.findOneById(uid); - if (!user) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateMessage' }); - } - await canSendMessageAsync(message.rid, { uid: user._id, username: user.username ?? undefined, ...user }); +declare module '@rocket.chat/ui-contexts' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + updateMessage(message: IEditedMessage, previewUrls?: string[]): void; + } +} - // It is possible to have an empty array as the attachments property, so ensure both things exist - if (originalMessage.attachments && originalMessage.attachments.length > 0 && originalMessage.attachments[0].description !== undefined) { - originalMessage.attachments[0].description = message.msg; - message.attachments = originalMessage.attachments; - message.msg = originalMessage.msg; - } +Meteor.methods({ + async updateMessage(message: IEditedMessage, previewUrls?: string[]) { + check(message, Match.ObjectIncluding({ _id: String })); + check(previewUrls, Match.Maybe([String])); + + const uid = Meteor.userId(); - message.u = originalMessage.u; + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateMessage' }); + } - return updateMessage(message, user, originalMessage); + return executeUpdateMessage(uid, message, previewUrls); }, }); diff --git a/apps/meteor/app/lib/server/startup/oAuthServicesUpdate.js b/apps/meteor/app/lib/server/startup/oAuthServicesUpdate.js index 8f991d2e7422..b01ef2f9fb0c 100644 --- a/apps/meteor/app/lib/server/startup/oAuthServicesUpdate.js +++ b/apps/meteor/app/lib/server/startup/oAuthServicesUpdate.js @@ -1,8 +1,8 @@ +import { Logger } from '@rocket.chat/logger'; import { ServiceConfiguration } from 'meteor/service-configuration'; import _ from 'underscore'; import { CustomOAuth } from '../../../custom-oauth/server/custom_oauth_server'; -import { Logger } from '../../../logger/server'; import { settings } from '../../../settings/server'; import { addOAuthService } from '../functions/addOAuthService'; @@ -97,7 +97,7 @@ async function _OAuthServicesUpdate() { if (serviceName === 'Linkedin') { data.clientConfig = { - requestPermissions: ['r_liteprofile', 'r_emailaddress'], + requestPermissions: ['openid', 'email', 'profile'], }; } diff --git a/apps/meteor/app/lib/server/startup/rateLimiter.js b/apps/meteor/app/lib/server/startup/rateLimiter.js index 55fab3aef315..a1ddfe87886c 100644 --- a/apps/meteor/app/lib/server/startup/rateLimiter.js +++ b/apps/meteor/app/lib/server/startup/rateLimiter.js @@ -1,10 +1,10 @@ +import { Logger } from '@rocket.chat/logger'; import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; import { Meteor } from 'meteor/meteor'; import { RateLimiter } from 'meteor/rate-limit'; import _ from 'underscore'; import { sleep } from '../../../../lib/utils/sleep'; -import { Logger } from '../../../logger/server'; import { metrics } from '../../../metrics/server'; import { settings } from '../../../settings/server'; diff --git a/apps/meteor/app/livechat/imports/server/rest/appearance.ts b/apps/meteor/app/livechat/imports/server/rest/appearance.ts index f688b9da7f81..0fa365be1871 100644 --- a/apps/meteor/app/livechat/imports/server/rest/appearance.ts +++ b/apps/meteor/app/livechat/imports/server/rest/appearance.ts @@ -1,9 +1,18 @@ +import { Settings } from '@rocket.chat/models'; +import { isPOSTLivechatAppearanceParams } from '@rocket.chat/rest-typings'; + import { API } from '../../../../api/server'; import { findAppearance } from '../../../server/api/lib/appearance'; API.v1.addRoute( 'livechat/appearance', - { authRequired: true, permissionsRequired: ['view-livechat-manager'] }, + { + authRequired: true, + permissionsRequired: ['view-livechat-manager'], + validateParams: { + POST: isPOSTLivechatAppearanceParams, + }, + }, { async get() { const { appearance } = await findAppearance(); @@ -12,5 +21,44 @@ API.v1.addRoute( appearance, }); }, + async post() { + const settings = this.bodyParams; + + const validSettingList = [ + 'Livechat_title', + 'Livechat_title_color', + 'Livechat_enable_message_character_limit', + 'Livechat_message_character_limit', + 'Livechat_show_agent_info', + 'Livechat_show_agent_email', + 'Livechat_display_offline_form', + 'Livechat_offline_form_unavailable', + 'Livechat_offline_message', + 'Livechat_offline_success_message', + 'Livechat_offline_title', + 'Livechat_offline_title_color', + 'Livechat_offline_email', + 'Livechat_conversation_finished_message', + 'Livechat_conversation_finished_text', + 'Livechat_registration_form', + 'Livechat_name_field_registration_form', + 'Livechat_email_field_registration_form', + 'Livechat_registration_form_message', + ]; + + const valid = settings.every((setting) => validSettingList.includes(setting._id)); + + if (!valid) { + throw new Error('invalid-setting'); + } + + await Promise.all( + settings.map((setting) => { + return Settings.updateValueById(setting._id, setting.value); + }), + ); + + return API.v1.success(); + }, }, ); diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.js b/apps/meteor/app/livechat/imports/server/rest/sms.js index 6521c0f662dd..7ecb3b3fc100 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.js +++ b/apps/meteor/app/livechat/imports/server/rest/sms.js @@ -56,7 +56,7 @@ const defineVisitor = async (smsNumber, targetDepartment) => { } const id = await LivechatTyped.registerGuest(data); - return LivechatVisitors.findOneById(id); + return LivechatVisitors.findOneEnabledById(id); }; const normalizeLocationSharing = (payload) => { diff --git a/apps/meteor/app/livechat/imports/server/rest/triggers.ts b/apps/meteor/app/livechat/imports/server/rest/triggers.ts index fa3d0dada051..a7660827b0ce 100644 --- a/apps/meteor/app/livechat/imports/server/rest/triggers.ts +++ b/apps/meteor/app/livechat/imports/server/rest/triggers.ts @@ -1,12 +1,20 @@ -import { isGETLivechatTriggersParams } from '@rocket.chat/rest-typings'; +import { LivechatTrigger } from '@rocket.chat/models'; +import { isGETLivechatTriggersParams, isPOSTLivechatTriggersParams } from '@rocket.chat/rest-typings'; import { API } from '../../../../api/server'; import { getPaginationItems } from '../../../../api/server/helpers/getPaginationItems'; -import { findTriggers, findTriggerById } from '../../../server/api/lib/triggers'; +import { findTriggers, findTriggerById, deleteTrigger } from '../../../server/api/lib/triggers'; API.v1.addRoute( 'livechat/triggers', - { authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETLivechatTriggersParams }, + { + authRequired: true, + permissionsRequired: ['view-livechat-manager'], + validateParams: { + GET: isGETLivechatTriggersParams, + POST: isPOSTLivechatTriggersParams, + }, + }, { async get() { const { offset, count } = await getPaginationItems(this.queryParams); @@ -22,6 +30,17 @@ API.v1.addRoute( return API.v1.success(triggers); }, + async post() { + const { _id, name, description, enabled, runOnce, conditions, actions } = this.bodyParams; + + if (_id) { + await LivechatTrigger.updateById(_id, { name, description, enabled, runOnce, conditions, actions }); + } else { + await LivechatTrigger.insertOne({ name, description, enabled, runOnce, conditions, actions }); + } + + return API.v1.success(); + }, }, ); @@ -38,5 +57,12 @@ API.v1.addRoute( trigger, }); }, + async delete() { + await deleteTrigger({ + triggerId: this.urlParams._id, + }); + + return API.v1.success(); + }, }, ); diff --git a/apps/meteor/app/livechat/lib/inquiries.ts b/apps/meteor/app/livechat/lib/inquiries.ts index dddf32ee5467..488151aa4166 100644 --- a/apps/meteor/app/livechat/lib/inquiries.ts +++ b/apps/meteor/app/livechat/lib/inquiries.ts @@ -6,13 +6,16 @@ type ReturnType = | { priorityWeight: SortOrder; ts: SortOrder; + _updatedAt: SortOrder; } | { estimatedWaitingTimeQueue: SortOrder; ts: SortOrder; + _updatedAt: SortOrder; } | { ts: SortOrder; + _updatedAt: SortOrder; }; export const getOmniChatSortQuery = ( @@ -20,11 +23,11 @@ export const getOmniChatSortQuery = ( ): ReturnType => { switch (sortByMechanism) { case OmnichannelSortingMechanismSettingType.Priority: - return { priorityWeight: 1, ts: 1 }; + return { priorityWeight: 1, ts: 1, _updatedAt: -1 }; case OmnichannelSortingMechanismSettingType.SLAs: - return { estimatedWaitingTimeQueue: 1, ts: 1 }; + return { estimatedWaitingTimeQueue: 1, ts: 1, _updatedAt: -1 }; case OmnichannelSortingMechanismSettingType.Timestamp: default: - return { ts: 1 }; + return { ts: 1, _updatedAt: -1 }; } }; diff --git a/apps/meteor/app/livechat/server/api/lib/departments.ts b/apps/meteor/app/livechat/server/api/lib/departments.ts index 215938fbb20b..049dbebaf7aa 100644 --- a/apps/meteor/app/livechat/server/api/lib/departments.ts +++ b/apps/meteor/app/livechat/server/api/lib/departments.ts @@ -129,7 +129,7 @@ export async function findDepartmentById({ department: await LivechatDepartment.findOne(query), ...(includeAgents && canViewLivechatDepartments && { - agents: await LivechatDepartmentAgents.find({ departmentId }).toArray(), + agents: await LivechatDepartmentAgents.findByDepartmentId(departmentId).toArray(), }), }; @@ -192,6 +192,6 @@ export async function findDepartmentsBetweenIds({ ids: string[]; fields: Record; }): Promise<{ departments: ILivechatDepartment[] }> { - const departments = await LivechatDepartment.findInIds(ids, fields).toArray(); + const departments = await LivechatDepartment.findInIds(ids, { projection: fields }).toArray(); return { departments }; } diff --git a/apps/meteor/app/livechat/server/api/lib/inquiries.ts b/apps/meteor/app/livechat/server/api/lib/inquiries.ts index 450e035af257..19cbfc21ede9 100644 --- a/apps/meteor/app/livechat/server/api/lib/inquiries.ts +++ b/apps/meteor/app/livechat/server/api/lib/inquiries.ts @@ -8,8 +8,10 @@ import { getOmniChatSortQuery } from '../../../lib/inquiries'; import { getInquirySortMechanismSetting } from '../../lib/settings'; const agentDepartments = async (userId: IUser['_id']): Promise => { - const agentDepartments = (await LivechatDepartmentAgents.findByAgentId(userId).toArray()).map(({ departmentId }) => departmentId); - return (await LivechatDepartment.find({ _id: { $in: agentDepartments }, enabled: true }).toArray()).map(({ _id }) => _id); + const agentDepartments = (await LivechatDepartmentAgents.findByAgentId(userId, { projection: { departmentId: 1 } }).toArray()).map( + ({ departmentId }) => departmentId, + ); + return (await LivechatDepartment.findEnabledInIds(agentDepartments, { projection: { _id: 1 } }).toArray()).map(({ _id }) => _id); }; const applyDepartmentRestrictions = async ( diff --git a/apps/meteor/app/livechat/server/api/lib/livechat.ts b/apps/meteor/app/livechat/server/api/lib/livechat.ts index 6ff2be2ad5f3..7bb608090557 100644 --- a/apps/meteor/app/livechat/server/api/lib/livechat.ts +++ b/apps/meteor/app/livechat/server/api/lib/livechat.ts @@ -126,7 +126,7 @@ export function getRoom({ return LivechatTyped.getRoom(guest, message, roomInfo, agent, extraParams); } -export async function findAgent(agentId?: string): Promise { +export async function findAgent(agentId?: string): Promise { return normalizeAgent(agentId); } diff --git a/apps/meteor/app/livechat/server/api/lib/triggers.ts b/apps/meteor/app/livechat/server/api/lib/triggers.ts index dbb6f8a6633a..4cbafcb0dc73 100644 --- a/apps/meteor/app/livechat/server/api/lib/triggers.ts +++ b/apps/meteor/app/livechat/server/api/lib/triggers.ts @@ -29,3 +29,7 @@ export async function findTriggers({ export async function findTriggerById({ triggerId }: { triggerId: string }): Promise { return LivechatTrigger.findOneById(triggerId); } + +export async function deleteTrigger({ triggerId }: { triggerId: string }): Promise { + await LivechatTrigger.removeById(triggerId); +} diff --git a/apps/meteor/app/livechat/server/api/lib/users.ts b/apps/meteor/app/livechat/server/api/lib/users.ts index d82b93be968d..948cde0f8bfd 100644 --- a/apps/meteor/app/livechat/server/api/lib/users.ts +++ b/apps/meteor/app/livechat/server/api/lib/users.ts @@ -24,7 +24,12 @@ async function findUsers({ }); } - const { cursor, totalCount } = Users.findPaginatedUsersInRolesWithQuery(role, query, { + const [ + { + sortedResults, + totalCount: [{ total } = { total: 0 }], + }, + ] = await Users.findAgentsWithDepartments(role, query, { sort: sort || { name: 1 }, skip: offset, limit: count, @@ -38,11 +43,9 @@ async function findUsers({ }, }); - const [users, total] = await Promise.all([cursor.toArray(), totalCount]); - return { - users, - count: users.length, + users: sortedResults, + count: sortedResults.length, offset, total, }; diff --git a/apps/meteor/app/livechat/server/api/lib/visitors.ts b/apps/meteor/app/livechat/server/api/lib/visitors.ts index e559aecc892e..0abed5197d78 100644 --- a/apps/meteor/app/livechat/server/api/lib/visitors.ts +++ b/apps/meteor/app/livechat/server/api/lib/visitors.ts @@ -6,7 +6,7 @@ import { callbacks } from '../../../../../lib/callbacks'; import { canAccessRoomAsync } from '../../../../authorization/server/functions/canAccessRoom'; export async function findVisitorInfo({ visitorId }: { visitorId: IVisitor['_id'] }) { - const visitor = await LivechatVisitors.findOneById(visitorId); + const visitor = await LivechatVisitors.findOneEnabledById(visitorId); if (!visitor) { throw new Error('visitor-not-found'); } diff --git a/apps/meteor/app/livechat/server/api/rest.ts b/apps/meteor/app/livechat/server/api/rest.ts index d1eb008ab4df..f9da6690185e 100644 --- a/apps/meteor/app/livechat/server/api/rest.ts +++ b/apps/meteor/app/livechat/server/api/rest.ts @@ -12,3 +12,4 @@ import './v1/transfer'; import './v1/contact'; import './v1/webhooks'; import './v1/integration'; +import './v1/statistics'; diff --git a/apps/meteor/app/livechat/server/api/v1/agent.ts b/apps/meteor/app/livechat/server/api/v1/agent.ts index b13c48bb579d..7a7b70ea7c8b 100644 --- a/apps/meteor/app/livechat/server/api/v1/agent.ts +++ b/apps/meteor/app/livechat/server/api/v1/agent.ts @@ -73,16 +73,21 @@ API.v1.addRoute( const agentId = inputAgentId || this.userId; - const agent = await Users.findOneAgentById>(agentId, { + const agent = await Users.findOneAgentById>(agentId, { projection: { status: 1, statusLivechat: 1, + active: 1, }, }); if (!agent) { return API.v1.notFound('Agent not found'); } + if (!agent.active) { + return API.v1.failure('error-user-deactivated'); + } + const newStatus: ILivechatAgentStatus = status || (agent.statusLivechat === ILivechatAgentStatus.AVAILABLE ? ILivechatAgentStatus.NOT_AVAILABLE : ILivechatAgentStatus.AVAILABLE); diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index 517acf33f137..57c1d117f1b0 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -33,7 +33,7 @@ API.v1.addRoute( contactId: String, }); - const contact = await LivechatVisitors.findOneById(this.queryParams.contactId); + const contact = await LivechatVisitors.findOneEnabledById(this.queryParams.contactId); return API.v1.success({ contact }); }, diff --git a/apps/meteor/app/livechat/server/api/v1/message.ts b/apps/meteor/app/livechat/server/api/v1/message.ts index 2b6f4c00af53..104e2ece94d5 100644 --- a/apps/meteor/app/livechat/server/api/v1/message.ts +++ b/apps/meteor/app/livechat/server/api/v1/message.ts @@ -269,7 +269,7 @@ API.v1.addRoute( guest.connectionData = normalizeHttpHeaderData(this.request.headers); const visitorId = await LivechatTyped.registerGuest(guest); - visitor = await LivechatVisitors.findOneById(visitorId); + visitor = await LivechatVisitors.findOneEnabledById(visitorId); } const sentMessages = await Promise.all( diff --git a/apps/meteor/app/livechat/server/api/v1/offlineMessage.ts b/apps/meteor/app/livechat/server/api/v1/offlineMessage.ts index 8302e014b894..b01e60d2265f 100644 --- a/apps/meteor/app/livechat/server/api/v1/offlineMessage.ts +++ b/apps/meteor/app/livechat/server/api/v1/offlineMessage.ts @@ -10,11 +10,12 @@ API.v1.addRoute( { async post() { const { name, email, message, department, host } = this.bodyParams; - if (!(await Livechat.sendOfflineMessage({ name, email, message, department, host }))) { - return API.v1.failure({ message: i18n.t('Error_sending_livechat_offline_message') }); + try { + await Livechat.sendOfflineMessage({ name, email, message, department, host }); + return API.v1.success({ message: i18n.t('Livechat_offline_message_sent') }); + } catch (e) { + return API.v1.failure(i18n.t('Error_sending_livechat_offline_message')); } - - return API.v1.success({ message: i18n.t('Livechat_offline_message_sent') }); }, }, ); diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index efeeedeec54c..86629e636bf8 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -1,4 +1,4 @@ -import type { ILivechatAgent, IOmnichannelRoom, IUser, SelectedAgent } from '@rocket.chat/core-typings'; +import type { ILivechatAgent, IOmnichannelRoom, IUser, SelectedAgent, TransferByData } from '@rocket.chat/core-typings'; import { isOmnichannelRoom, OmnichannelSourceType } from '@rocket.chat/core-typings'; import { LivechatVisitors, Users, LivechatRooms, Subscriptions, Messages } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; @@ -13,7 +13,6 @@ import { isPOSTLivechatRoomCloseByUserParams, } from '@rocket.chat/rest-typings'; import { check } from 'meteor/check'; -import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../../../lib/callbacks'; import { i18n } from '../../../../../server/lib/i18n'; @@ -30,7 +29,7 @@ import { Livechat as LivechatTyped } from '../../lib/LivechatTyped'; import { findGuest, findRoom, getRoom, settings, findAgent, onCheckRoomParams } from '../lib/livechat'; import { findVisitorInfo } from '../lib/visitors'; -const isAgentWithInfo = (agentObj: ILivechatAgent | { hiddenInfo: true }): agentObj is ILivechatAgent => !('hiddenInfo' in agentObj); +const isAgentWithInfo = (agentObj: ILivechatAgent | { hiddenInfo: boolean }): agentObj is ILivechatAgent => !('hiddenInfo' in agentObj); API.v1.addRoute('livechat/room', { async get() { @@ -327,8 +326,9 @@ API.v1.addRoute( throw new Error('This_conversation_is_already_closed'); } - const guest = await LivechatVisitors.findOneById(room.v?._id); - transferData.transferredBy = normalizeTransferredByData((await Meteor.userAsync()) || {}, room); + const guest = await LivechatVisitors.findOneEnabledById(room.v?._id); + const transferedBy = this.user satisfies TransferByData; + transferData.transferredBy = normalizeTransferredByData(transferedBy, room); if (transferData.userId) { const userToTransfer = await Users.findOneById(transferData.userId); if (userToTransfer) { diff --git a/apps/meteor/app/livechat/server/api/v1/statistics.ts b/apps/meteor/app/livechat/server/api/v1/statistics.ts new file mode 100644 index 000000000000..078f366bb484 --- /dev/null +++ b/apps/meteor/app/livechat/server/api/v1/statistics.ts @@ -0,0 +1,65 @@ +import { Users } from '@rocket.chat/models'; +import { isLivechatAnalyticsAgentOverviewProps, isLivechatAnalyticsOverviewProps } from '@rocket.chat/rest-typings'; + +import { API } from '../../../../api/server'; +import { settings } from '../../../../settings/server'; +import { Livechat } from '../../lib/Livechat'; + +API.v1.addRoute( + 'livechat/analytics/agent-overview', + { + authRequired: true, + permissionsRequired: ['view-livechat-manager'], + validateParams: isLivechatAnalyticsAgentOverviewProps, + }, + { + async get() { + const { name, departmentId, from, to } = this.queryParams; + + if (!name) { + throw new Error('invalid-chart-name'); + } + + const user = await Users.findOneById(this.userId, { projection: { _id: 1, utcOffset: 1 } }); + return API.v1.success( + await Livechat.Analytics.getAgentOverviewData({ + departmentId, + utcOffset: user?.utcOffset || 0, + daterange: { from, to }, + chartOptions: { name }, + }), + ); + }, + }, +); + +API.v1.addRoute( + 'livechat/analytics/overview', + { + authRequired: true, + permissionsRequired: ['view-livechat-manager'], + validateParams: isLivechatAnalyticsOverviewProps, + }, + { + async get() { + const { name, departmentId, from, to } = this.queryParams; + + if (!name) { + throw new Error('invalid-chart-name'); + } + + const user = await Users.findOneById(this.userId, { projection: { _id: 1, utcOffset: 1 } }); + const language = user?.language || settings.get('Language') || 'en'; + + return API.v1.success( + await Livechat.Analytics.getAnalyticsOverviewData({ + departmentId, + utcOffset: user?.utcOffset || 0, + daterange: { from, to }, + analyticsOptions: { name }, + language, + }), + ); + }, + }, +); diff --git a/apps/meteor/app/livechat/server/api/v1/transcript.ts b/apps/meteor/app/livechat/server/api/v1/transcript.ts index 29d1ceeb1061..3eaa91c37c7c 100644 --- a/apps/meteor/app/livechat/server/api/v1/transcript.ts +++ b/apps/meteor/app/livechat/server/api/v1/transcript.ts @@ -1,7 +1,10 @@ -import { isPOSTLivechatTranscriptParams } from '@rocket.chat/rest-typings'; +import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatRooms, Users } from '@rocket.chat/models'; +import { isPOSTLivechatTranscriptParams, isPOSTLivechatTranscriptRequestParams } from '@rocket.chat/rest-typings'; import { i18n } from '../../../../../server/lib/i18n'; import { API } from '../../../../api/server'; +import { Livechat as LivechatJS } from '../../lib/Livechat'; import { Livechat } from '../../lib/LivechatTyped'; API.v1.addRoute( @@ -18,3 +21,45 @@ API.v1.addRoute( }, }, ); + +API.v1.addRoute( + 'livechat/transcript/:rid', + { + authRequired: true, + permissionsRequired: ['send-omnichannel-chat-transcript'], + validateParams: { + POST: isPOSTLivechatTranscriptRequestParams, + }, + }, + { + async delete() { + const { rid } = this.urlParams; + const room = await LivechatRooms.findOneById>(rid, { + projection: { open: 1, transcriptRequest: 1 }, + }); + + if (!room?.open) { + throw new Error('error-invalid-room'); + } + if (!room.transcriptRequest) { + throw new Error('error-transcript-not-requested'); + } + + await LivechatRooms.unsetEmailTranscriptRequestedByRoomId(rid); + + return API.v1.success(); + }, + async post() { + const { rid } = this.urlParams; + const { email, subject } = this.bodyParams; + + const user = await Users.findOneById(this.userId, { + projection: { _id: 1, username: 1, name: 1, utcOffset: 1 }, + }); + + await LivechatJS.requestTranscript({ rid, email, subject, user }); + + return API.v1.success(); + }, + }, +); diff --git a/apps/meteor/app/livechat/server/api/v1/visitor.ts b/apps/meteor/app/livechat/server/api/v1/visitor.ts index 012b412639ea..ae9d1ea4fd83 100644 --- a/apps/meteor/app/livechat/server/api/v1/visitor.ts +++ b/apps/meteor/app/livechat/server/api/v1/visitor.ts @@ -45,7 +45,7 @@ API.v1.addRoute('livechat/visitor', { const visitorId = await LivechatTyped.registerGuest(guest); - let visitor = await VisitorsRaw.findOneById(visitorId, {}); + let visitor = await VisitorsRaw.findOneEnabledById(visitorId, {}); if (visitor) { const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); // If it's updating an existing visitor, it must also update the roomInfo @@ -65,7 +65,7 @@ API.v1.addRoute('livechat/visitor', { } } - visitor = await VisitorsRaw.findOneById(visitorId, {}); + visitor = await VisitorsRaw.findOneEnabledById(visitorId, {}); } if (!visitor) { @@ -122,7 +122,7 @@ API.v1.addRoute('livechat/visitor/:token', { const { _id } = visitor; const result = await Livechat.removeGuest(_id); - if (!result) { + if (!result.modifiedCount) { throw new Meteor.Error('error-removing-visitor', 'An error ocurred while deleting visitor'); } diff --git a/apps/meteor/app/livechat/server/api/v1/webhooks.ts b/apps/meteor/app/livechat/server/api/v1/webhooks.ts index dceb19ed0420..e282e2bd548b 100644 --- a/apps/meteor/app/livechat/server/api/v1/webhooks.ts +++ b/apps/meteor/app/livechat/server/api/v1/webhooks.ts @@ -66,7 +66,7 @@ API.v1.addRoute( const webhookUrl = settings.get('Livechat_webhookUrl'); if (!webhookUrl) { - return API.v1.failure('Webhook URL is not set'); + return API.v1.failure('Webhook_URL_not_set'); } try { diff --git a/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts b/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts index 2c5ffe38169d..55de5bbf6315 100644 --- a/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts +++ b/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts @@ -1,13 +1,10 @@ -import { ILivechatAgentStatus } from '@rocket.chat/core-typings'; -import type { ILivechatBusinessHour, ILivechatDepartment } from '@rocket.chat/core-typings'; +import type { ILivechatAgentStatus, ILivechatBusinessHour, ILivechatDepartment } from '@rocket.chat/core-typings'; import type { ILivechatBusinessHoursModel, IUsersModel } from '@rocket.chat/model-typings'; import { LivechatBusinessHours, Users } from '@rocket.chat/models'; import moment from 'moment-timezone'; import type { UpdateFilter } from 'mongodb'; import type { IWorkHoursCronJobsWrapper } from '../../../../server/models/raw/LivechatBusinessHours'; -import { businessHourLogger } from '../lib/logger'; -import { filterBusinessHoursThatMustBeOpened } from './Helper'; export interface IBusinessHourBehavior { findHoursToCreateJobs(): Promise; @@ -61,29 +58,6 @@ export abstract class AbstractBusinessHourBehavior { { livechatStatusSystemModified: true }, ); } - - async onNewAgentCreated(agentId: string): Promise { - businessHourLogger.debug(`Executing onNewAgentCreated for agentId: ${agentId}`); - - const defaultBusinessHour = await LivechatBusinessHours.findOneDefaultBusinessHour(); - if (!defaultBusinessHour) { - businessHourLogger.debug(`No default business hour found for agentId: ${agentId}`); - return; - } - - const businessHourToOpen = await filterBusinessHoursThatMustBeOpened([defaultBusinessHour]); - if (!businessHourToOpen.length) { - businessHourLogger.debug( - `No business hour to open found for agentId: ${agentId}. Default business hour is closed. Setting agentId: ${agentId} to status: ${ILivechatAgentStatus.NOT_AVAILABLE}`, - ); - await Users.setLivechatStatus(agentId, ILivechatAgentStatus.NOT_AVAILABLE); - return; - } - - await Users.addBusinessHourByAgentIds([agentId], defaultBusinessHour._id); - - businessHourLogger.debug(`Setting agentId: ${agentId} to status: ${ILivechatAgentStatus.AVAILABLE}`); - } } export abstract class AbstractBusinessHourType { diff --git a/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts b/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts index 52ccd0441e24..c541e5f7b2c3 100644 --- a/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts +++ b/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts @@ -7,7 +7,6 @@ import moment from 'moment'; import { closeBusinessHour } from '../../../../ee/app/livechat-enterprise/server/business-hour/Helper'; import { callbacks } from '../../../../lib/callbacks'; import { settings } from '../../../settings/server'; -import { businessHourLogger } from '../lib/logger'; import type { IBusinessHourBehavior, IBusinessHourType } from './AbstractBusinessHour'; export class BusinessHourManager { @@ -27,7 +26,6 @@ export class BusinessHourManager { async startManager(): Promise { await this.createCronJobsForWorkHours(); - businessHourLogger.debug('Cron jobs created, setting up callbacks'); this.setupCallbacks(); await this.cleanupDisabledDepartmentReferences(); await this.behavior.onStartBusinessHours(); diff --git a/apps/meteor/app/livechat/server/business-hour/Helper.ts b/apps/meteor/app/livechat/server/business-hour/Helper.ts index e61bb1621765..e96ccb4c7b89 100644 --- a/apps/meteor/app/livechat/server/business-hour/Helper.ts +++ b/apps/meteor/app/livechat/server/business-hour/Helper.ts @@ -59,7 +59,6 @@ export const openBusinessHourDefault = async (): Promise => { await Users.makeAgentsWithinBusinessHourAvailable(); } await Users.updateLivechatStatusBasedOnBusinessHours(); - businessHourLogger.debug('Done opening default business hours'); }; export const createDefaultBusinessHourIfNotExists = async (): Promise => { diff --git a/apps/meteor/app/livechat/server/business-hour/Single.ts b/apps/meteor/app/livechat/server/business-hour/Single.ts index 63135fa53224..5d2730dba9a1 100644 --- a/apps/meteor/app/livechat/server/business-hour/Single.ts +++ b/apps/meteor/app/livechat/server/business-hour/Single.ts @@ -1,13 +1,13 @@ -import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; +import { ILivechatAgentStatus, LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; +import { LivechatBusinessHours, Users } from '@rocket.chat/models'; import { businessHourLogger } from '../lib/logger'; import type { IBusinessHourBehavior } from './AbstractBusinessHour'; import { AbstractBusinessHourBehavior } from './AbstractBusinessHour'; -import { openBusinessHourDefault } from './Helper'; +import { filterBusinessHoursThatMustBeOpened, openBusinessHourDefault } from './Helper'; export class SingleBusinessHourBehavior extends AbstractBusinessHourBehavior implements IBusinessHourBehavior { async openBusinessHoursByDayAndHour(): Promise { - businessHourLogger.debug('opening single business hour'); return openBusinessHourDefault(); } @@ -22,10 +22,38 @@ export class SingleBusinessHourBehavior extends AbstractBusinessHourBehavior imp } async onStartBusinessHours(): Promise { - businessHourLogger.debug('Starting Single Business Hours'); return openBusinessHourDefault(); } + async onNewAgentCreated(agentId: string): Promise { + const defaultBusinessHour = await LivechatBusinessHours.findOneDefaultBusinessHour(); + if (!defaultBusinessHour) { + businessHourLogger.debug('No default business hour found for agentId', { + agentId, + }); + return; + } + + const businessHourToOpen = await filterBusinessHoursThatMustBeOpened([defaultBusinessHour]); + if (!businessHourToOpen.length) { + businessHourLogger.debug({ + msg: 'No business hours found. Moving agent to NOT_AVAILABLE status', + agentId, + newStatus: ILivechatAgentStatus.NOT_AVAILABLE, + }); + await Users.setLivechatStatus(agentId, ILivechatAgentStatus.NOT_AVAILABLE); + return; + } + + await Users.addBusinessHourByAgentIds([agentId], defaultBusinessHour._id); + + businessHourLogger.debug({ + msg: 'Business hours found. Moving agent to AVAILABLE status', + agentId, + newStatus: ILivechatAgentStatus.AVAILABLE, + }); + } + afterSaveBusinessHours(): Promise { return openBusinessHourDefault(); } diff --git a/apps/meteor/app/livechat/server/hooks/afterAgentRemoved.ts b/apps/meteor/app/livechat/server/hooks/afterAgentRemoved.ts new file mode 100644 index 000000000000..475eff9002fc --- /dev/null +++ b/apps/meteor/app/livechat/server/hooks/afterAgentRemoved.ts @@ -0,0 +1,13 @@ +import { LivechatDepartment, Users, LivechatDepartmentAgents, LivechatVisitors } from '@rocket.chat/models'; + +import { callbacks } from '../../../../lib/callbacks'; + +callbacks.add('livechat.afterAgentRemoved', async ({ agent }) => { + const departmentIds = (await LivechatDepartmentAgents.findByAgentId(agent._id).toArray()).map((department) => department.departmentId); + await Promise.all([ + Users.removeAgent(agent._id), + LivechatDepartmentAgents.removeByAgentId(agent._id), + agent.username && LivechatVisitors.removeContactManagerByUsername(agent.username), + departmentIds.length && LivechatDepartment.decreaseNumberOfAgentsByIds(departmentIds), + ]); +}); diff --git a/apps/meteor/app/livechat/server/hooks/afterUserActions.ts b/apps/meteor/app/livechat/server/hooks/afterUserActions.ts index 50fe5846637b..0419f1d02a1d 100644 --- a/apps/meteor/app/livechat/server/hooks/afterUserActions.ts +++ b/apps/meteor/app/livechat/server/hooks/afterUserActions.ts @@ -1,8 +1,8 @@ -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 '../../../../lib/callbacks'; import { Livechat } from '../lib/Livechat'; -import { callbackLogger } from '../lib/logger'; type IAfterSaveUserProps = { user: IUser; @@ -33,14 +33,12 @@ const handleAgentCreated = async (user: IUser) => { const handleDeactivateUser = async (user: IUser) => { if (wasAgent(user)) { - callbackLogger.debug('Removing agent', user._id); - await Livechat.removeAgent(user.username); + await Users.makeAgentUnavailableAndUnsetExtension(user._id); } }; const handleActivateUser = async (user: IUser) => { if (isAgent(user)) { - callbackLogger.debug('Adding agent', user._id); await Livechat.addAgent(user.username); } }; diff --git a/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts b/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts index 5467e517768e..ad68fcf5ce5c 100644 --- a/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts +++ b/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts @@ -1,5 +1,7 @@ +import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; import { isOmnichannelRoom, isEditedMessage } from '@rocket.chat/core-typings'; -import { LivechatRooms } from '@rocket.chat/models'; +import { LivechatRooms, LivechatVisitors } from '@rocket.chat/models'; +import moment from 'moment'; import { callbacks } from '../../../../lib/callbacks'; @@ -15,28 +17,52 @@ callbacks.add( return message; } + // skips this callback if the message is a system message + if (message.t) { + return message; + } + // if the message has a token, it was sent by the visitor, so ignore it if (message.token) { return message; } + + // Return YYYY-MM from moment + const monthYear = moment().format('YYYY-MM'); + const isVisitorActive = await LivechatVisitors.isVisitorActiveOnPeriod(room.v._id, monthYear); + if (!isVisitorActive) { + await LivechatVisitors.markVisitorActiveForPeriod(room.v._id, monthYear); + } + + await LivechatRooms.markVisitorActiveForPeriod(room._id, monthYear); + if (room.responseBy) { await LivechatRooms.setAgentLastMessageTs(room._id); } - // check if room is yet awaiting for response - if (!(typeof room.t !== 'undefined' && room.t === 'l' && room.waitingResponse)) { + // check if room is yet awaiting for response from visitor + if (!room.waitingResponse) { + // case where agent sends second message or any subsequent message in a room before visitor responds to the first message + // in this case, we just need to update the lastMessageTs of the responseBy object + if (room.responseBy) { + await LivechatRooms.setAgentLastMessageTs(room._id); + } return message; } - await LivechatRooms.setResponseByRoomId(room._id, { - user: { - _id: message.u._id, - username: message.u.username, - }, - }); + // This is the first message from agent after visitor had last responded + const responseBy: IOmnichannelRoom['responseBy'] = room.responseBy || { + _id: message.u._id, + username: message.u.username, + firstResponseTs: new Date(message.ts), + lastMessageTs: new Date(message.ts), + }; + + // this unsets waitingResponse and sets responseBy object + await LivechatRooms.setResponseByRoomId(room._id, responseBy); return message; }, - callbacks.priority.LOW, + callbacks.priority.HIGH, 'markRoomResponded', ); diff --git a/apps/meteor/app/livechat/server/hooks/offlineMessageToChannel.ts b/apps/meteor/app/livechat/server/hooks/offlineMessageToChannel.ts index c8166d1a7454..7cc8f8e6e9ad 100644 --- a/apps/meteor/app/livechat/server/hooks/offlineMessageToChannel.ts +++ b/apps/meteor/app/livechat/server/hooks/offlineMessageToChannel.ts @@ -1,3 +1,4 @@ +import type { ILivechatDepartment } from '@rocket.chat/core-typings'; import { isOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatDepartment, Users, Rooms } from '@rocket.chat/models'; @@ -17,9 +18,12 @@ callbacks.add( let departmentName; const { name, email, department, message: text, host } = data; if (department && department !== '') { - const dept = await LivechatDepartment.findOneById(department, { - projection: { name: 1, offlineMessageChannelName: 1 }, - }); + const dept = await LivechatDepartment.findOneById>( + department, + { + projection: { name: 1, offlineMessageChannelName: 1 }, + }, + ); departmentName = dept?.name; if (dept?.offlineMessageChannelName) { channelName = dept.offlineMessageChannelName; @@ -30,7 +34,7 @@ callbacks.add( return data; } - const room: any = await Rooms.findOneByName(channelName, { projection: { t: 1, archived: 1 } }); + const room = await Rooms.findOneByName(channelName, { projection: { t: 1, archived: 1 } }); if (!room || room.archived || (isOmnichannelRoom(room) && room.closedAt)) { return data; } diff --git a/apps/meteor/app/livechat/server/hooks/processRoomAbandonment.ts b/apps/meteor/app/livechat/server/hooks/processRoomAbandonment.ts index b2c168ff9602..8a5a4c280670 100644 --- a/apps/meteor/app/livechat/server/hooks/processRoomAbandonment.ts +++ b/apps/meteor/app/livechat/server/hooks/processRoomAbandonment.ts @@ -1,4 +1,4 @@ -import type { IOmnichannelRoom, IMessage, IBusinessHourWorkHour } from '@rocket.chat/core-typings'; +import type { IOmnichannelRoom, IMessage, IBusinessHourWorkHour, ILivechatDepartment } from '@rocket.chat/core-typings'; import { isOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatBusinessHours, LivechatDepartment, Messages, LivechatRooms } from '@rocket.chat/models'; import moment from 'moment'; @@ -27,7 +27,11 @@ const getSecondsSinceLastAgentResponse = async (room: IOmnichannelRoom, agentLas return getSecondsWhenOfficeHoursIsDisabled(room, agentLastMessage); } let officeDays; - const department = room.departmentId ? await LivechatDepartment.findOneById(room.departmentId) : null; + const department = room.departmentId + ? await LivechatDepartment.findOneById>(room.departmentId, { + projection: { businessHourId: 1 }, + }) + : null; if (department?.businessHourId) { const businessHour = await LivechatBusinessHours.findOneById(department.businessHourId); if (!businessHour) { diff --git a/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts b/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts index 48a52aa41fe0..e92e6b4d940b 100644 --- a/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts +++ b/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts @@ -3,12 +3,10 @@ import { LivechatRooms } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; import { normalizeMessageFileUpload } from '../../../utils/server/functions/normalizeMessageFileUpload'; -import { callbackLogger } from '../lib/logger'; callbacks.add( 'afterSaveMessage', async (message, room) => { - callbackLogger.debug(`Calculating Omnichannel metrics for room ${room._id}`); // check if room is livechat if (!isOmnichannelRoom(room)) { return message; @@ -43,7 +41,6 @@ callbacks.add( const isResponseTotal = room.metrics?.response?.total; if (agentLastReply === room.ts) { - callbackLogger.debug('Calculating: first message from agent'); // first response const firstResponseDate = now; const firstResponseTime = (now.getTime() - new Date(visitorLastQuery).getTime()) / 1000; @@ -66,7 +63,6 @@ callbacks.add( reactionTime, }; } else if (visitorLastQuery > agentLastReply) { - callbackLogger.debug('Calculating: visitor sent a message after agent'); // response, not first const responseTime = (now.getTime() - new Date(visitorLastQuery).getTime()) / 1000; const avgResponseTime = diff --git a/apps/meteor/app/livechat/server/hooks/saveLastVisitorMessageTs.ts b/apps/meteor/app/livechat/server/hooks/saveLastVisitorMessageTs.ts index 576b14722a2a..4bc28c3990ba 100644 --- a/apps/meteor/app/livechat/server/hooks/saveLastVisitorMessageTs.ts +++ b/apps/meteor/app/livechat/server/hooks/saveLastVisitorMessageTs.ts @@ -12,10 +12,11 @@ callbacks.add( if (message.t) { return message; } - if (message.token) { - await LivechatRooms.setVisitorLastMessageTimestampByRoomId(room._id, message.ts); + if (!message.token) { + return message; } - return message; + + await LivechatRooms.setVisitorLastMessageTimestampByRoomId(room._id, message.ts); }, callbacks.priority.HIGH, 'save-last-visitor-message-timestamp', diff --git a/apps/meteor/app/livechat/server/index.ts b/apps/meteor/app/livechat/server/index.ts index 7955393acdb1..7d9167966953 100644 --- a/apps/meteor/app/livechat/server/index.ts +++ b/apps/meteor/app/livechat/server/index.ts @@ -15,6 +15,7 @@ import './hooks/sendEmailTranscriptOnClose'; import './hooks/saveContactLastChat'; import './hooks/saveLastMessageToInquiry'; import './hooks/afterUserActions'; +import './hooks/afterAgentRemoved'; import './methods/addAgent'; import './methods/addManager'; import './methods/changeLivechatStatus'; diff --git a/apps/meteor/app/livechat/server/lib/Analytics.js b/apps/meteor/app/livechat/server/lib/Analytics.js index 0ae201dbc252..28bed221afbf 100644 --- a/apps/meteor/app/livechat/server/lib/Analytics.js +++ b/apps/meteor/app/livechat/server/lib/Analytics.js @@ -1,10 +1,10 @@ +import { Logger } from '@rocket.chat/logger'; import { LivechatRooms } from '@rocket.chat/models'; import moment from 'moment-timezone'; import { callbacks } from '../../../../lib/callbacks'; import { secondsToHHMMSS } from '../../../../lib/utils/secondsToHHMMSS'; import { i18n } from '../../../../server/lib/i18n'; -import { Logger } from '../../../logger/server'; import { getTimezone } from '../../../utils/server/lib/getTimezone'; const HOURS_IN_DAY = 24; @@ -43,8 +43,6 @@ export const Analytics = { const from = moment.tz(fDate, 'YYYY-MM-DD', timezone).startOf('day').utc(); const to = moment.tz(tDate, 'YYYY-MM-DD', timezone).endOf('day').utc(); - logger.debug(`getAgentOverviewData[${name}] -> Using timezone ${timezone} with date range ${from} - ${to}`); - if (!(moment(from).isValid() && moment(to).isValid())) { logger.error('livechat:getAgentOverviewData => Invalid dates'); return; @@ -79,8 +77,6 @@ export const Analytics = { const to = moment.tz(tDate, 'YYYY-MM-DD', timezone).endOf('day').utc(); const isSameDay = from.diff(to, 'days') === 0; - logger.debug(`getAnalyticsChartData[${name}] -> Using timezone ${timezone} with date range ${from} - ${to}`); - if (!(moment(from).isValid() && moment(to).isValid())) { logger.error('livechat:getAnalyticsChartData => Invalid dates'); return; @@ -133,8 +129,6 @@ export const Analytics = { const from = moment.tz(fDate, 'YYYY-MM-DD', timezone).startOf('day').utc(); const to = moment.tz(tDate, 'YYYY-MM-DD', timezone).endOf('day').utc(); - logger.debug(`getAnalyticsOverviewData[${name}] -> Using timezone ${timezone} with date range ${from} - ${to}`); - if (!(moment(from).isValid() && moment(to).isValid())) { logger.error('livechat:getAnalyticsOverviewData => Invalid dates'); return; diff --git a/apps/meteor/app/livechat/server/lib/Departments.ts b/apps/meteor/app/livechat/server/lib/Departments.ts index 349af6bd227d..f17015e52e79 100644 --- a/apps/meteor/app/livechat/server/lib/Departments.ts +++ b/apps/meteor/app/livechat/server/lib/Departments.ts @@ -1,7 +1,8 @@ +import type { ILivechatDepartmentAgents } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; import { LivechatDepartment, LivechatDepartmentAgents, LivechatRooms } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; -import { Logger } from '../../../logger/server'; class DepartmentHelperClass { logger = new Logger('Omnichannel:DepartmentHelper'); @@ -11,7 +12,6 @@ class DepartmentHelperClass { const department = await LivechatDepartment.findOneById(departmentId); if (!department) { - this.logger.debug(`Department not found: ${departmentId}`); throw new Error('error-department-not-found'); } @@ -19,12 +19,13 @@ class DepartmentHelperClass { const ret = await LivechatDepartment.removeById(_id); if (ret.acknowledged !== true) { - this.logger.error(`Department record not removed: ${_id}. Result from db: ${ret}`); throw new Error('error-failed-to-delete-department'); } - this.logger.debug(`Department record removed: ${_id}`); - const agentsIds: string[] = await LivechatDepartmentAgents.findAgentsByDepartmentId(department._id) + const agentsIds: string[] = await LivechatDepartmentAgents.findAgentsByDepartmentId>( + department._id, + { projection: { agentId: 1 } }, + ) .cursor.map((agent) => agent.agentId) .toArray(); @@ -43,8 +44,6 @@ class DepartmentHelperClass { } }); - this.logger.debug(`Post-department-removal actions completed: ${_id}. Notifying callbacks with department and agentsIds`); - setImmediate(() => { void callbacks.run('livechat.afterRemoveDepartment', { department, agentsIds }); }); diff --git a/apps/meteor/app/livechat/server/lib/Helper.js b/apps/meteor/app/livechat/server/lib/Helper.ts similarity index 70% rename from apps/meteor/app/livechat/server/lib/Helper.js rename to apps/meteor/app/livechat/server/lib/Helper.ts index cbc8807d6187..75722e709b17 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.js +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -1,7 +1,23 @@ import { LivechatTransferEventType } from '@rocket.chat/apps-engine/definition/livechat'; import { api, Message } from '@rocket.chat/core-services'; -import { OmnichannelSourceType, DEFAULT_SLA_CONFIG } from '@rocket.chat/core-typings'; +import type { + ILivechatVisitor, + IOmnichannelRoom, + IMessage, + SelectedAgent, + ISubscription, + ILivechatInquiryRecord, + IUser, + TransferData, + ILivechatDepartmentAgents, + TransferByData, + ILivechatAgent, + ILivechatDepartment, +} from '@rocket.chat/core-typings'; +import { LivechatInquiryStatus, OmnichannelSourceType, DEFAULT_SLA_CONFIG, UserStatus } from '@rocket.chat/core-typings'; import { LivechatPriorityWeight } from '@rocket.chat/core-typings/src/ILivechatPriority'; +import { Logger } from '@rocket.chat/logger'; +import type { InsertionModel } from '@rocket.chat/model-typings'; import { LivechatDepartmentAgents, LivechatInquiry, @@ -21,7 +37,6 @@ import { i18n } from '../../../../server/lib/i18n'; import { hasRoleAsync } from '../../../authorization/server/functions/hasRole'; import { sendNotification } from '../../../lib/server'; import { sendMessage } from '../../../lib/server/functions/sendMessage'; -import { Logger } from '../../../logger/server'; import { settings } from '../../../settings/server'; import { Livechat } from './Livechat'; import { Livechat as LivechatTyped } from './LivechatTyped'; @@ -29,8 +44,7 @@ import { queueInquiry, saveQueueInquiry } from './QueueManager'; import { RoutingManager } from './RoutingManager'; const logger = new Logger('LivechatHelper'); - -export const allowAgentSkipQueue = (agent) => { +export const allowAgentSkipQueue = (agent: SelectedAgent) => { check( agent, Match.ObjectIncluding({ @@ -40,8 +54,13 @@ export const allowAgentSkipQueue = (agent) => { return hasRoleAsync(agent.agentId, 'bot'); }; - -export const createLivechatRoom = async (rid, name, guest, roomInfo = {}, extraData = {}) => { +export const createLivechatRoom = async ( + rid: string, + name: string, + guest: ILivechatVisitor, + roomInfo: Partial = {}, + extraData = {}, +) => { check(rid, String); check(name, String); check( @@ -60,14 +79,14 @@ export const createLivechatRoom = async (rid, name, guest, roomInfo = {}, extraD logger.debug(`Creating livechat room for visitor ${_id}`); - const room = Object.assign( + const room: InsertionModel = Object.assign( { _id: rid, msgs: 0, usersCount: 1, lm: newRoomAt, fname: name, - t: 'l', + t: 'l' as const, ts: newRoomAt, departmentId, v: { @@ -95,7 +114,7 @@ export const createLivechatRoom = async (rid, name, guest, roomInfo = {}, extraD const roomId = (await Rooms.insertOne(room)).insertedId; - Apps.triggerEvent(AppEvents.IPostLivechatRoomStarted, room); + void Apps.triggerEvent(AppEvents.IPostLivechatRoomStarted, room); await callbacks.run('livechat.newRoom', room); await sendMessage(guest, { t: 'livechat-started', msg: '', groupable: false }, room); @@ -103,7 +122,21 @@ export const createLivechatRoom = async (rid, name, guest, roomInfo = {}, extraD return roomId; }; -export const createLivechatInquiry = async ({ rid, name, guest, message, initialStatus, extraData = {} }) => { +export const createLivechatInquiry = async ({ + rid, + name, + guest, + message, + initialStatus, + extraData, +}: { + rid: string; + name?: string; + guest?: Pick; + message?: Pick; + initialStatus?: LivechatInquiryStatus; + extraData?: Pick; +}) => { check(rid, String); check(name, String); check( @@ -124,19 +157,19 @@ export const createLivechatInquiry = async ({ rid, name, guest, message, initial const extraInquiryInfo = await callbacks.run('livechat.beforeInquiry', extraData); - const { _id, username, token, department, status = 'online' } = guest; + const { _id, username, token, department, status = UserStatus.ONLINE } = guest; const { msg } = message; const ts = new Date(); logger.debug(`Creating livechat inquiry for visitor ${_id}`); - const inquiry = { + const inquiry: InsertionModel = { rid, name, ts, department, message: msg, - status: initialStatus || 'ready', + status: initialStatus || LivechatInquiryStatus.READY, v: { _id, username, @@ -156,7 +189,13 @@ export const createLivechatInquiry = async ({ rid, name, guest, message, initial return result; }; -export const createLivechatSubscription = async (rid, name, guest, agent, department) => { +export const createLivechatSubscription = async ( + rid: string, + name: string, + guest: Pick, + agent: SelectedAgent, + department?: string, +) => { check(rid, String); check(name, String); check( @@ -180,11 +219,14 @@ export const createLivechatSubscription = async (rid, name, guest, agent, depart return existingSubscription; } - const { _id, username, token, status = 'online' } = guest; + const { _id, username, token, status = UserStatus.ONLINE } = guest; - const subscriptionData = { + const subscriptionData: InsertionModel = { rid, + name, fname: name, + lowerCaseName: name.toLowerCase(), + lowerCaseFName: name.toLowerCase(), alert: true, open: true, unread: 1, @@ -204,31 +246,38 @@ export const createLivechatSubscription = async (rid, name, guest, agent, depart token, status, }, + ts: new Date(), + lr: new Date(), + ls: new Date(), ...(department && { department }), }; return Subscriptions.insertOne(subscriptionData); }; -export const removeAgentFromSubscription = async (rid, { _id, username }) => { +export const removeAgentFromSubscription = async (rid: string, { _id, username }: Pick) => { const room = await LivechatRooms.findOneById(rid); const user = await Users.findOneById(_id); + if (!room || !user) { + return; + } + await Subscriptions.removeByRoomIdAndUserId(rid, _id); - await Message.saveSystemMessage('ul', rid, username, { _id: user._id, username: user.username, name: user.name }); + await Message.saveSystemMessage('ul', rid, username || '', { _id: user._id, username: user.username, name: user.name }); setImmediate(() => { - Apps.triggerEvent(AppEvents.IPostLivechatAgentUnassigned, { room, user }); + void Apps.triggerEvent(AppEvents.IPostLivechatAgentUnassigned, { room, user }); }); }; -export const parseAgentCustomFields = (customFields) => { +export const parseAgentCustomFields = (customFields?: Record) => { if (!customFields) { return; } const externalCustomFields = () => { - const accountCustomFields = settings.get('Accounts_CustomFields'); + const accountCustomFields = settings.get('Accounts_CustomFields'); if (!accountCustomFields || accountCustomFields.trim() === '') { return []; } @@ -245,11 +294,11 @@ export const parseAgentCustomFields = (customFields) => { const externalCF = externalCustomFields(); return Object.keys(customFields).reduce( (newObj, key) => (externalCF.includes(key) ? { ...newObj, [key]: customFields[key] } : newObj), - null, + {}, ); }; -export const normalizeAgent = async (agentId) => { +export const normalizeAgent = async (agentId?: string) => { if (!agentId) { return; } @@ -259,13 +308,17 @@ export const normalizeAgent = async (agentId) => { } const agent = await Users.getAgentInfo(agentId, settings.get('Livechat_show_agent_email')); + if (!agent) { + return; + } + const { customFields: agentCustomFields, ...extraData } = agent; const customFields = parseAgentCustomFields(agentCustomFields); - return Object.assign(extraData, { ...(customFields && { customFields }) }); + return Object.assign(extraData, { ...(customFields && { customFields }) }) as ILivechatAgent; }; -export const dispatchAgentDelegated = async (rid, agentId) => { +export const dispatchAgentDelegated = async (rid: string, agentId?: string) => { const agent = await normalizeAgent(agentId); void api.broadcast('omnichannel.room', rid, { @@ -274,7 +327,7 @@ export const dispatchAgentDelegated = async (rid, agentId) => { }); }; -export const dispatchInquiryQueued = async (inquiry, agent) => { +export const dispatchInquiryQueued = async (inquiry: ILivechatInquiryRecord, agent?: SelectedAgent | null) => { if (!inquiry?._id) { return; } @@ -282,9 +335,13 @@ export const dispatchInquiryQueued = async (inquiry, agent) => { const { department, rid, v } = inquiry; const room = await LivechatRooms.findOneById(rid); + if (!room) { + return; + } + setImmediate(() => callbacks.run('livechat.chatQueued', room)); - if (RoutingManager.getConfig().autoAssignAgent) { + if (RoutingManager.getConfig()?.autoAssignAgent) { return; } @@ -325,6 +382,8 @@ export const dispatchInquiryQueued = async (inquiry, agent) => { }, sender: v, hasMentionToAll: true, // consider all agents to be in the room + hasReplyToThread: false, + disableAllMessageNotifications: false, hasMentionToHere: false, message: Object.assign({}, { u: v }), // we should use server's language for this type of messages instead of user's @@ -335,8 +394,8 @@ export const dispatchInquiryQueued = async (inquiry, agent) => { } }; -export const forwardRoomToAgent = async (room, transferData) => { - if (!room || !room.open) { +export const forwardRoomToAgent = async (room: IOmnichannelRoom, transferData: TransferData) => { + if (!room?.open) { return false; } @@ -350,7 +409,7 @@ export const forwardRoomToAgent = async (room, transferData) => { } const { _id: rid, servedBy: oldServedBy } = room; - const inquiry = await LivechatInquiry.findOneByRoomId(rid); + const inquiry = await LivechatInquiry.findOneByRoomId(rid, {}); if (!inquiry) { logger.debug(`No inquiries found for room ${room._id}. Cannot forward`); throw new Error('error-invalid-inquiry'); @@ -363,10 +422,10 @@ export const forwardRoomToAgent = async (room, transferData) => { const { username } = user; const agent = { agentId, username }; // Remove department from inquiry to make sure the routing algorithm treat this as forwarding to agent and not as forwarding to department - inquiry.department = undefined; + delete inquiry.department; // There are some Enterprise features that may interrupt the forwarding process // Due to that we need to check whether the agent has been changed or not - logger.debug(`Forwarding inquiry ${inquiry._id} to agent ${agent._id}`); + logger.debug(`Forwarding inquiry ${inquiry._id} to agent ${agent.agentId}`); const roomTaken = await RoutingManager.takeInquiry(inquiry, agent, { ...(clientAction && { clientAction }), }); @@ -384,7 +443,7 @@ export const forwardRoomToAgent = async (room, transferData) => { } setImmediate(() => { - Apps.triggerEvent(AppEvents.IPostLivechatRoomTransferred, { + void Apps.triggerEvent(AppEvents.IPostLivechatRoomTransferred, { type: LivechatTransferEventType.AGENT, room: rid, from: oldServedBy?._id, @@ -393,18 +452,28 @@ export const forwardRoomToAgent = async (room, transferData) => { }); } - logger.debug(`Inquiry ${inquiry._id} taken by agent ${agent._id}`); + logger.debug(`Inquiry ${inquiry._id} taken by agent ${agent.agentId}`); await callbacks.run('livechat.afterForwardChatToAgent', { rid, servedBy, oldServedBy }); return true; }; -export const updateChatDepartment = async ({ rid, newDepartmentId, oldDepartmentId }) => { - await LivechatRooms.changeDepartmentIdByRoomId(rid, newDepartmentId); - await LivechatInquiry.changeDepartmentIdByRoomId(rid, newDepartmentId); - await Subscriptions.changeDepartmentByRoomId(rid, newDepartmentId); +export const updateChatDepartment = async ({ + rid, + newDepartmentId, + oldDepartmentId, +}: { + rid: string; + newDepartmentId: string; + oldDepartmentId?: string; +}) => { + await Promise.all([ + LivechatRooms.changeDepartmentIdByRoomId(rid, newDepartmentId), + LivechatInquiry.changeDepartmentIdByRoomId(rid, newDepartmentId), + Subscriptions.changeDepartmentByRoomId(rid, newDepartmentId), + ]); setImmediate(() => { - Apps.triggerEvent(AppEvents.IPostLivechatRoomTransferred, { + void Apps.triggerEvent(AppEvents.IPostLivechatRoomTransferred, { type: LivechatTransferEventType.DEPARTMENT, room: rid, from: oldDepartmentId, @@ -419,8 +488,8 @@ export const updateChatDepartment = async ({ rid, newDepartmentId, oldDepartment }); }; -export const forwardRoomToDepartment = async (room, guest, transferData) => { - if (!room || !room.open) { +export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILivechatVisitor, transferData: TransferData) => { + if (!room?.open) { return false; } logger.debug(`Attempting to forward room ${room._id} to department ${transferData.departmentId}`); @@ -429,13 +498,17 @@ export const forwardRoomToDepartment = async (room, guest, transferData) => { const { _id: rid, servedBy: oldServedBy, departmentId: oldDepartmentId } = room; let agent = null; - const inquiry = await LivechatInquiry.findOneByRoomId(rid); + const inquiry = await LivechatInquiry.findOneByRoomId(rid, {}); if (!inquiry) { logger.debug(`Cannot forward room ${room._id}. No inquiries found`); throw new Error('error-transferring-inquiry'); } const { departmentId } = transferData; + if (!departmentId) { + logger.debug(`Cannot forward room ${room._id}. No departmentId provided`); + throw new Error('error-transferring-inquiry-no-department'); + } if (oldDepartmentId === departmentId) { throw new Error('error-forwarding-chat-same-department'); } @@ -443,19 +516,21 @@ export const forwardRoomToDepartment = async (room, guest, transferData) => { const { userId: agentId, clientAction } = transferData; if (agentId) { logger.debug(`Forwarding room ${room._id} to department ${departmentId} (to user ${agentId})`); - let user = await Users.findOneOnlineAgentById(agentId); + const user = await Users.findOneOnlineAgentById(agentId); if (!user) { throw new Error('error-user-is-offline'); } - user = await LivechatDepartmentAgents.findOneByAgentIdAndDepartmentId(agentId, departmentId); - if (!user) { + const isInDepartment = await LivechatDepartmentAgents.findOneByAgentIdAndDepartmentId(agentId, departmentId, { + projection: { _id: 1 }, + }); + if (!isInDepartment) { throw new Error('error-user-not-belong-to-department'); } const { username } = user; agent = { agentId, username }; } - if (!RoutingManager.getConfig().autoAssignAgent) { + if (!RoutingManager.getConfig()?.autoAssignAgent) { logger.debug( `Routing algorithm doesn't support auto assignment (using ${RoutingManager.methodName}). Chat will be on department queue`, ); @@ -477,23 +552,32 @@ export const forwardRoomToDepartment = async (room, guest, transferData) => { const { servedBy, chatQueued } = roomTaken; if (!chatQueued && oldServedBy && servedBy && oldServedBy._id === servedBy._id) { - const department = await LivechatDepartment.findOneById(departmentId); + const department = departmentId + ? await LivechatDepartment.findOneById>(departmentId, { + projection: { fallbackForwardDepartment: 1 }, + }) + : null; if (!department?.fallbackForwardDepartment?.length) { logger.debug(`Cannot forward room ${room._id}. Chat assigned to agent ${servedBy._id} (Previous was ${oldServedBy._id})`); throw new Error('error-no-agents-online-in-department'); } // if a chat has a fallback department, attempt to redirect chat to there [EE] - return !!callbacks.run('livechat:onTransferFailure', { room, guest, transferData }); + const transferSuccess = !!(await callbacks.run('livechat:onTransferFailure', room, { guest, transferData })); + // On CE theres no callback so it will return the room + if (typeof transferSuccess !== 'boolean' || !transferSuccess) { + logger.debug(`Cannot forward room ${room._id}. Unable to delegate inquiry`); + return false; + } } await Livechat.saveTransferHistory(room, transferData); if (oldServedBy) { // if chat is queued then we don't ignore the new servedBy agent bcs at this // point the chat is not assigned to him/her and it is still in the queue - await RoutingManager.removeAllRoomSubscriptions(room, !chatQueued && servedBy); + await RoutingManager.removeAllRoomSubscriptions(room, !chatQueued ? servedBy : undefined); } if (!chatQueued && servedBy) { - await Message.saveSystemMessage('uj', rid, servedBy.username, servedBy); + await Message.saveSystemMessage('uj', rid, servedBy.username || '', servedBy); } await updateChatDepartment({ rid, newDepartmentId: departmentId, oldDepartmentId }); @@ -502,10 +586,14 @@ export const forwardRoomToDepartment = async (room, guest, transferData) => { logger.debug(`Forwarding succesful. Marking inquiry ${inquiry._id} as ready`); await LivechatInquiry.readyInquiry(inquiry._id); await LivechatRooms.removeAgentByRoomId(rid); - await dispatchAgentDelegated(rid, null); + await dispatchAgentDelegated(rid); const newInquiry = await LivechatInquiry.findOneById(inquiry._id); - await queueInquiry(newInquiry); + if (!newInquiry) { + logger.debug(`Inquiry ${inquiry._id} not found`); + throw new Error('error-invalid-inquiry'); + } + await queueInquiry(newInquiry); logger.debug(`Inquiry ${inquiry._id} queued succesfully`); } @@ -516,7 +604,7 @@ export const forwardRoomToDepartment = async (room, guest, transferData) => { return true; }; -export const normalizeTransferredByData = (transferredBy, room) => { +export const normalizeTransferredByData = (transferredBy: TransferByData, room: IOmnichannelRoom) => { if (!transferredBy || !room) { throw new Error('You must provide "transferredBy" and "room" params to "getTransferredByData"'); } @@ -531,7 +619,7 @@ export const normalizeTransferredByData = (transferredBy, room) => { }; }; -export const checkServiceStatus = async ({ guest, agent }) => { +export const checkServiceStatus = async ({ guest, agent }: { guest: Pick; agent?: SelectedAgent }) => { if (!agent) { return LivechatTyped.online(guest.department); } @@ -541,7 +629,21 @@ export const checkServiceStatus = async ({ guest, agent }) => { return users > 0; }; -export const updateDepartmentAgents = async (departmentId, agents, departmentEnabled) => { +const parseFromIntOrStr = (value: string | number) => { + if (typeof value === 'number') { + return value; + } + return parseInt(value); +}; + +export const updateDepartmentAgents = async ( + departmentId: string, + agents: { + upsert?: Pick[]; + remove?: Pick[]; + }, + departmentEnabled: boolean, +) => { check(departmentId, String); check( agents, @@ -572,9 +674,9 @@ export const updateDepartmentAgents = async (departmentId, agents, departmentEna await LivechatDepartmentAgents.saveAgent({ agentId: agent.agentId, departmentId, - username: agentFromDb.username, - count: agent.count ? parseInt(agent.count) : 0, - order: agent.order ? parseInt(agent.order) : 0, + username: agentFromDb.username || '', + count: agent.count ? parseFromIntOrStr(agent.count) : 0, + order: agent.order ? parseFromIntOrStr(agent.order) : 0, departmentEnabled, }); agentsAdded.push(agent.agentId); @@ -595,7 +697,7 @@ export const updateDepartmentAgents = async (departmentId, agents, departmentEna return true; }; -export const validateEmail = (email) => { +export const validateEmail = (email: string) => { if (!validatorFunc(email)) { throw new Meteor.Error('error-invalid-email', `Invalid email ${email}`, { function: 'Livechat.validateEmail', diff --git a/apps/meteor/app/livechat/server/lib/Livechat.js b/apps/meteor/app/livechat/server/lib/Livechat.js index 5e400642b793..c560f3dd7aa7 100644 --- a/apps/meteor/app/livechat/server/lib/Livechat.js +++ b/apps/meteor/app/livechat/server/lib/Livechat.js @@ -5,6 +5,7 @@ import dns from 'dns'; import util from 'util'; import { Message, VideoConf, api } from '@rocket.chat/core-services'; +import { Logger } from '@rocket.chat/logger'; import { LivechatVisitors, LivechatCustomField, @@ -17,6 +18,7 @@ import { LivechatDepartmentAgents, Rooms, Users, + ReadReceipts, } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -34,7 +36,6 @@ import { FileUpload } from '../../../file-upload/server'; import { deleteMessage } from '../../../lib/server/functions/deleteMessage'; import { sendMessage } from '../../../lib/server/functions/sendMessage'; import { updateMessage } from '../../../lib/server/functions/updateMessage'; -import { Logger } from '../../../logger/server'; import * as Mailer from '../../../mailer/server/api'; import { settings } from '../../../settings/server'; import { businessHourManager } from '../business-hour'; @@ -284,7 +285,7 @@ export const Livechat = { Livechat.logger.debug(`Closing open chats for user ${userId}`); const user = await Users.findOneById(userId); - const extraQuery = await callbacks.run('livechat.applyDepartmentRestrictions', {}); + const extraQuery = await callbacks.run('livechat.applyDepartmentRestrictions', {}, { userId }); const openChats = LivechatRooms.findOpenByAgent(userId, extraQuery); const promises = []; await openChats.forEach((room) => { @@ -297,7 +298,7 @@ export const Livechat = { async forwardOpenChats(userId) { Livechat.logger.debug(`Transferring open chats for user ${userId}`); for await (const room of LivechatRooms.findOpenByAgent(userId)) { - const guest = await LivechatVisitors.findOneById(room.v._id); + const guest = await LivechatVisitors.findOneEnabledById(room.v._id); const user = await Users.findOneById(userId); const { _id, username, name } = user; const transferredBy = normalizeTransferredByData({ _id, username, name }, room); @@ -461,7 +462,7 @@ export const Livechat = { }, async getLivechatRoomGuestInfo(room) { - const visitor = await LivechatVisitors.findOneById(room.v._id); + const visitor = await LivechatVisitors.findOneEnabledById(room.v._id); const agent = await Users.findOneById(room.servedBy && room.servedBy._id); const ua = new UAParser(); @@ -564,18 +565,14 @@ export const Livechat = { }, async afterRemoveAgent(user) { - await Promise.all([ - Users.removeAgent(user._id), - LivechatDepartmentAgents.removeByAgentId(user._id), - LivechatVisitors.removeContactManagerByUsername(user.username), - ]); + await callbacks.run('livechat.afterAgentRemoved', { agent: user }); return true; }, async removeAgent(username) { check(username, String); - const user = await Users.findOneByUsername(username, { projection: { _id: 1 } }); + const user = await Users.findOneByUsername(username, { projection: { _id: 1, username: 1 } }); if (!user) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { @@ -607,16 +604,15 @@ export const Livechat = { }, async removeGuest(_id) { - check(_id, String); - const guest = await LivechatVisitors.findOneById(_id, { projection: { _id: 1 } }); + const guest = await LivechatVisitors.findOneEnabledById(_id, { projection: { _id: 1, token: 1 } }); if (!guest) { throw new Meteor.Error('error-invalid-guest', 'Invalid guest', { method: 'livechat:removeGuest', }); } - await this.cleanGuestHistory(_id); - return LivechatVisitors.removeById(_id); + await this.cleanGuestHistory(guest); + return LivechatVisitors.disableById(_id); }, async setUserStatusLivechat(userId, status) { @@ -631,27 +627,29 @@ export const Livechat = { return user; }, - async cleanGuestHistory(_id) { - const guest = await LivechatVisitors.findOneById(_id); - if (!guest) { - throw new Meteor.Error('error-invalid-guest', 'Invalid guest', { - method: 'livechat:cleanGuestHistory', - }); - } - + async cleanGuestHistory(guest) { const { token } = guest; - check(token, String); + + // This shouldn't be possible, but just in case + if (!token) { + throw new Error('error-invalid-guest'); + } const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); const cursor = LivechatRooms.findByVisitorToken(token, extraQuery); for await (const room of cursor) { - await FileUpload.removeFilesByRoomId(room._id); - await Messages.removeByRoomId(room._id); + await Promise.all([ + FileUpload.removeFilesByRoomId(room._id), + Messages.removeByRoomId(room._id), + ReadReceipts.removeByRoomId(room._id), + ]); } - await Subscriptions.removeByVisitorToken(token); - await LivechatRooms.removeByVisitorToken(token); - await LivechatInquiry.removeByVisitorToken(token); + await Promise.all([ + Subscriptions.removeByVisitorToken(token), + LivechatRooms.removeByVisitorToken(token), + LivechatInquiry.removeByVisitorToken(token), + ]); }, async saveDepartmentAgents(_id, departmentAgents) { @@ -707,7 +705,9 @@ export const Livechat = { }); } const ret = (await LivechatDepartmentRaw.removeById(_id)).deletedCount; - const agentsIds = (await LivechatDepartmentAgents.findByDepartmentId(_id).toArray()).map((agent) => agent.agentId); + const agentsIds = (await LivechatDepartmentAgents.findByDepartmentId(_id, { projection: { agentId: 1 } }).toArray()).map( + (agent) => agent.agentId, + ); await LivechatDepartmentAgents.removeByDepartmentId(_id); await LivechatDepartmentRaw.unsetFallbackDepartmentByDepartmentId(_id); if (ret) { @@ -837,7 +837,7 @@ export const Livechat = { async sendOfflineMessage(data = {}) { if (!settings.get('Livechat_display_offline_form')) { - return false; + throw new Error('error-offline-form-disabled'); } const { message, name, email, department, host } = data; @@ -872,6 +872,10 @@ export const Livechat = { } } + // TODO Block offline form if Livechat_offline_email is undefined + // (it does not make sense to have an offline form that does nothing) + // `this.sendEmail` will throw an error if the email is invalid + // thus this breaks livechat, since the "to" email is invalid, and that returns an [invalid email] error to the livechat client let emailTo = settings.get('Livechat_offline_email'); if (department && department !== '') { const dep = await LivechatDepartmentRaw.findOneByIdOrName(department); @@ -886,8 +890,6 @@ export const Livechat = { setImmediate(() => { callbacks.run('livechat.offlineMessage', data); }); - - return true; }, async notifyAgentStatusChanged(userId, status) { diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index 6f53455a80bc..c443bc7873c7 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -9,8 +9,10 @@ import type { SelectedAgent, ILivechatAgent, IMessage, + ILivechatDepartment, } from '@rocket.chat/core-typings'; -import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { UserStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { Logger, type MainLogger } from '@rocket.chat/logger'; import { LivechatDepartment, LivechatInquiry, @@ -20,6 +22,7 @@ import { Messages, Users, LivechatDepartmentAgents, + ReadReceipts, } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; @@ -29,10 +32,8 @@ import type { FindCursor, UpdateFilter } from 'mongodb'; import { Apps, AppEvents } from '../../../../ee/server/apps'; import { callbacks } from '../../../../lib/callbacks'; import { i18n } from '../../../../server/lib/i18n'; -import type { MainLogger } from '../../../../server/lib/logger/getPino'; import { hasRoleAsync } from '../../../authorization/server/functions/hasRole'; import { sendMessage } from '../../../lib/server/functions/sendMessage'; -import { Logger } from '../../../logger/server'; import * as Mailer from '../../../mailer/server/api'; import { metrics } from '../../../metrics/server'; import { settings } from '../../../settings/server'; @@ -125,7 +126,7 @@ class LivechatClass { return RoutingManager.getNextAgent(department); } - async getOnlineAgents(department?: string, agent?: SelectedAgent): Promise | undefined> { + async getOnlineAgents(department?: string, agent?: SelectedAgent | null): Promise | undefined> { if (agent?.agentId) { return Users.findOnlineAgents(agent.agentId); } @@ -156,6 +157,11 @@ class LivechatClass { return; } + const commentRequired = settings.get('Livechat_request_comment_when_closing_conversation'); + if (commentRequired && !comment?.trim()) { + throw new Error('error-comment-is-required'); + } + const { updatedOptions: options } = await this.resolveChatTags(room, params.options); this.logger.debug(`Resolved chat tags for room ${room._id}`); @@ -294,9 +300,12 @@ class LivechatClass { room = null; } - if (guest.department && !(await LivechatDepartment.findOneById(guest.department))) { + if ( + guest.department && + !(await LivechatDepartment.findOneById>(guest.department, { projection: { _id: 1 } })) + ) { await LivechatVisitors.removeDepartmentById(guest._id); - const tmpGuest = await LivechatVisitors.findOneById(guest._id); + const tmpGuest = await LivechatVisitors.findOneEnabledById(guest._id); if (tmpGuest) { guest = tmpGuest; } @@ -352,7 +361,9 @@ class LivechatClass { return onlineForDep; } - const dep = await LivechatDepartment.findOneById(department); + const dep = await LivechatDepartment.findOneById>(department, { + projection: { fallbackForwardDepartment: 1 }, + }); if (!dep?.fallbackForwardDepartment) { return onlineForDep; } @@ -397,6 +408,7 @@ class LivechatClass { const result = await Promise.allSettled([ Messages.removeByRoomId(rid), + ReadReceipts.removeByRoomId(rid), Subscriptions.removeByRoomId(rid), LivechatInquiry.removeByRoomId(rid), LivechatRooms.removeById(rid), @@ -540,7 +552,7 @@ class LivechatClass { phone, username, connectionData, - status = 'online', + status = UserStatus.ONLINE, }: { id?: string; token: string; @@ -580,7 +592,7 @@ class LivechatClass { if (department) { Livechat.logger.debug(`Attempt to find a department with id/name ${department}`); - const dep = await LivechatDepartment.findOneByIdOrName(department); + const dep = await LivechatDepartment.findOneByIdOrName(department, { projection: { _id: 1 } }); if (!dep) { Livechat.logger.debug('Invalid department provided'); throw new Meteor.Error('error-invalid-department', 'The provided department is invalid'); @@ -667,7 +679,12 @@ class LivechatClass { }; } - const department = await LivechatDepartment.findOneById(departmentId); + const department = await LivechatDepartment.findOneById>( + departmentId, + { + projection: { requestTagBeforeClosingChat: 1, chatClosingTags: 1 }, + }, + ); if (!department) { return { updatedOptions: { @@ -781,7 +798,7 @@ class LivechatClass { return updateDepartmentAgents( dep._id, { - ...(toRemoveIds.includes(dep._id) ? { remove: [{ agentId: _id }] } : { upsert: [{ agentId: _id }] }), + ...(toRemoveIds.includes(dep._id) ? { remove: [{ agentId: _id }] } : { upsert: [{ agentId: _id, count: 0, order: 0 }] }), }, dep.enabled, ); diff --git a/apps/meteor/app/livechat/server/lib/QueueManager.js b/apps/meteor/app/livechat/server/lib/QueueManager.ts similarity index 60% rename from apps/meteor/app/livechat/server/lib/QueueManager.js rename to apps/meteor/app/livechat/server/lib/QueueManager.ts index 674657a4d7c0..aed0061e808e 100644 --- a/apps/meteor/app/livechat/server/lib/QueueManager.js +++ b/apps/meteor/app/livechat/server/lib/QueueManager.ts @@ -1,33 +1,52 @@ +import type { ILivechatInquiryRecord, ILivechatVisitor, IMessage, IOmnichannelRoom, SelectedAgent } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; import { LivechatInquiry, LivechatRooms, Users } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../../lib/callbacks'; -import { Logger } from '../../../logger/server'; import { checkServiceStatus, createLivechatRoom, createLivechatInquiry } from './Helper'; import { RoutingManager } from './RoutingManager'; const logger = new Logger('QueueManager'); -export const saveQueueInquiry = async (inquiry) => { +export const saveQueueInquiry = async (inquiry: ILivechatInquiryRecord) => { await LivechatInquiry.queueInquiry(inquiry._id); await callbacks.run('livechat.afterInquiryQueued', inquiry); }; -export const queueInquiry = async (inquiry, defaultAgent) => { +export const queueInquiry = async (inquiry: ILivechatInquiryRecord, defaultAgent?: SelectedAgent) => { const inquiryAgent = await RoutingManager.delegateAgent(defaultAgent, inquiry); logger.debug(`Delegating inquiry with id ${inquiry._id} to agent ${defaultAgent?.username}`); await callbacks.run('livechat.beforeRouteChat', inquiry, inquiryAgent); - inquiry = await LivechatInquiry.findOneById(inquiry._id); + const dbInquiry = await LivechatInquiry.findOneById(inquiry._id); - if (inquiry.status === 'ready') { + if (!dbInquiry) { + throw new Error('inquiry-not-found'); + } + + if (dbInquiry.status === 'ready') { logger.debug(`Inquiry with id ${inquiry._id} is ready. Delegating to agent ${inquiryAgent?.username}`); - return RoutingManager.delegateInquiry(inquiry, inquiryAgent); + return RoutingManager.delegateInquiry(dbInquiry, inquiryAgent); } }; -export const QueueManager = { +type queueManager = { + requestRoom: (params: { + guest: ILivechatVisitor; + message: Pick; + roomInfo: { + source?: IOmnichannelRoom['source']; + [key: string]: unknown; + }; + agent?: SelectedAgent; + extraData?: Record; + }) => Promise; + unarchiveRoom: (archivedRoom?: IOmnichannelRoom) => Promise; +}; + +export const QueueManager: queueManager = { async requestRoom({ guest, message, roomInfo, agent, extraData }) { logger.debug(`Requesting a room for guest ${guest._id}`); check( @@ -43,18 +62,22 @@ export const QueueManager = { username: String, status: Match.Maybe(String), department: Match.Maybe(String), + name: Match.Maybe(String), }), ); if (!(await checkServiceStatus({ guest, agent }))) { - logger.debug(`Cannot create room for visitor ${guest._id}. No online agents`); throw new Meteor.Error('no-agent-online', 'Sorry, no online agents'); } const { rid } = message; - const name = (roomInfo && roomInfo.fname) || guest.name || guest.username; + const name = (roomInfo?.fname as string) || guest.name || guest.username; const room = await LivechatRooms.findOneById(await createLivechatRoom(rid, name, guest, roomInfo, extraData)); + if (!room) { + logger.error(`Room for visitor ${guest._id} not found`); + throw new Error('room-not-found'); + } logger.debug(`Room for visitor ${guest._id} created with id ${room._id}`); const inquiry = await LivechatInquiry.findOneById( @@ -66,7 +89,10 @@ export const QueueManager = { extraData: { ...extraData, source: roomInfo.source }, }), ); - logger.debug(`Generated inquiry for visitor ${guest._id} with id ${inquiry._id} [Not queued]`); + if (!inquiry) { + logger.error(`Inquiry for visitor ${guest._id} not found`); + throw new Error('inquiry-not-found'); + } await LivechatRooms.updateRoomCount(); @@ -82,18 +108,12 @@ export const QueueManager = { return newRoom; }, - async unarchiveRoom(archivedRoom = {}) { - const { - _id: rid, - open, - closedAt, - fname: name, - servedBy, - v, - departmentId: department, - lastMessage: message, - source = {}, - } = archivedRoom; + async unarchiveRoom(archivedRoom) { + if (!archivedRoom) { + throw new Error('no-room-to-unarchive'); + } + + const { _id: rid, open, closedAt, fname: name, servedBy, v, departmentId: department, lastMessage: message, source } = archivedRoom; if (!rid || !closedAt || !!open) { return archivedRoom; @@ -101,7 +121,7 @@ export const QueueManager = { logger.debug(`Attempting to unarchive room with id ${rid}`); - const oldInquiry = await LivechatInquiry.findOneByRoomId(rid); + const oldInquiry = await LivechatInquiry.findOneByRoomId>(rid, { projection: { _id: 1 } }); if (oldInquiry) { logger.debug(`Removing old inquiry (${oldInquiry._id}) for room ${rid}`); await LivechatInquiry.removeByRoomId(rid); @@ -112,19 +132,20 @@ export const QueueManager = { ...(department && { department }), }; - let defaultAgent; - if (servedBy && (await Users.findOneOnlineAgentByUserList(servedBy.username))) { + let defaultAgent: SelectedAgent | undefined; + if (servedBy?.username && (await Users.findOneOnlineAgentByUserList(servedBy.username))) { defaultAgent = { agentId: servedBy._id, username: servedBy.username }; } await LivechatRooms.unarchiveOneById(rid); const room = await LivechatRooms.findOneById(rid); if (!room) { - logger.debug(`Room with id ${rid} not found`); throw new Error('room-not-found'); } const inquiry = await LivechatInquiry.findOneById(await createLivechatInquiry({ rid, name, guest, message, extraData: { source } })); - logger.debug(`Generated inquiry for visitor ${v._id} with id ${inquiry._id} [Not queued]`); + if (!inquiry) { + throw new Error('inquiry-not-found'); + } await queueInquiry(inquiry, defaultAgent); logger.debug(`Inquiry ${inquiry._id} queued`); diff --git a/apps/meteor/app/livechat/server/lib/RoutingManager.ts b/apps/meteor/app/livechat/server/lib/RoutingManager.ts index 729f2bc8d985..f2fd7010eb12 100644 --- a/apps/meteor/app/livechat/server/lib/RoutingManager.ts +++ b/apps/meteor/app/livechat/server/lib/RoutingManager.ts @@ -1,4 +1,4 @@ -import { Message } from '@rocket.chat/core-services'; +import { Message, Omnichannel } from '@rocket.chat/core-services'; import type { ILivechatInquiryRecord, ILivechatVisitor, @@ -8,14 +8,15 @@ import type { RoutingMethodConfig, SelectedAgent, InquiryWithAgentInfo, + TransferData, } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; import { LivechatInquiry, LivechatRooms, Subscriptions, Rooms, Users } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { Apps, AppEvents } from '../../../../ee/server/apps'; import { callbacks } from '../../../../lib/callbacks'; -import { Logger } from '../../../../server/lib/logger/Logger'; import { createLivechatSubscription, dispatchAgentDelegated, @@ -34,7 +35,7 @@ type Routing = { methods: Record; startQueue(): void; isMethodSet(): boolean; - setMethodNameAndStartQueue(name: string): void; + setMethodNameAndStartQueue(name: string): Promise; registerMethod(name: string, Method: IRoutingMethodConstructor): void; getMethod(): IRoutingMethod; getConfig(): RoutingMethodConfig | undefined; @@ -42,8 +43,8 @@ type Routing = { delegateInquiry( inquiry: InquiryWithAgentInfo, agent?: SelectedAgent | null, - options?: { clientAction?: boolean; forwardingToDepartment?: { oldDepartmentId: string; transferData: any } }, - ): Promise; + options?: { clientAction?: boolean; forwardingToDepartment?: { oldDepartmentId?: string; transferData?: any } }, + ): Promise<(IOmnichannelRoom & { chatQueued?: boolean }) | null | void>; assignAgent(inquiry: InquiryWithAgentInfo, agent: SelectedAgent): Promise; unassignAgent(inquiry: ILivechatInquiryRecord, departmentId?: string): Promise; takeInquiry( @@ -52,18 +53,10 @@ type Routing = { 'estimatedInactivityCloseTimeAt' | 'message' | 't' | 'source' | 'estimatedWaitingTimeQueue' | 'priorityWeight' | '_updatedAt' >, agent: SelectedAgent | null, - options?: { clientAction?: boolean; forwardingToDepartment?: { oldDepartmentId: string; transferData: any } }, + options?: { clientAction?: boolean; forwardingToDepartment?: { oldDepartmentId?: string; transferData?: any } }, ): Promise; - transferRoom( - room: IOmnichannelRoom, - guest: ILivechatVisitor, - transferData: { - departmentId?: string; - userId?: string; - transferredBy: { _id: string }; - }, - ): Promise; - delegateAgent(agent: SelectedAgent, inquiry: ILivechatInquiryRecord): Promise; + transferRoom(room: IOmnichannelRoom, guest: ILivechatVisitor, transferData: TransferData): Promise; + delegateAgent(agent: SelectedAgent | undefined, inquiry: ILivechatInquiryRecord): Promise; removeAllRoomSubscriptions(room: Pick, ignoreUser?: { _id: string }): Promise; }; @@ -80,8 +73,8 @@ export const RoutingManager: Routing = { return !!this.methodName; }, - setMethodNameAndStartQueue(name) { - logger.debug(`Changing default routing method from ${this.methodName} to ${name}`); + async setMethodNameAndStartQueue(name) { + logger.info(`Changing default routing method from ${this.methodName} to ${name}`); if (!this.methods[name]) { logger.warn(`Cannot change routing method to ${name}. Selected Routing method does not exists. Defaulting to Manual_Selection`); this.methodName = 'Manual_Selection'; @@ -89,12 +82,11 @@ export const RoutingManager: Routing = { this.methodName = name; } - this.startQueue(); + void (await Omnichannel.getQueueWorker()).shouldStart(); }, // eslint-disable-next-line @typescript-eslint/naming-convention registerMethod(name, Method) { - logger.debug(`Registering new routing method with name ${name}`); this.methods[name] = new Method(); }, @@ -195,10 +187,9 @@ export const RoutingManager: Routing = { const { servedBy } = room; if (servedBy) { - logger.debug(`Unassigning current agent for inquiry ${inquiry._id}`); await LivechatRooms.removeAgentByRoomId(rid); await this.removeAllRoomSubscriptions(room); - await dispatchAgentDelegated(rid, null); + await dispatchAgentDelegated(rid); } await dispatchInquiryQueued(inquiry); @@ -252,9 +243,8 @@ export const RoutingManager: Routing = { if (!agent) { logger.debug(`Cannot take Inquiry ${inquiry._id}: Precondition failed for agent`); - const cbRoom = await callbacks.run<'livechat.onAgentAssignmentFailed'>('livechat.onAgentAssignmentFailed', { + const cbRoom = await callbacks.run<'livechat.onAgentAssignmentFailed'>('livechat.onAgentAssignmentFailed', room, { inquiry, - room, options, }); return cbRoom; @@ -262,7 +252,7 @@ export const RoutingManager: Routing = { await LivechatInquiry.takeInquiry(_id); const inq = await this.assignAgent(inquiry as InquiryWithAgentInfo, agent); - logger.debug(`Inquiry ${inquiry._id} taken by agent ${agent.agentId}`); + logger.info(`Inquiry ${inquiry._id} taken by agent ${agent.agentId}`); callbacks.runAsync('livechat.afterTakeInquiry', inq, agent); @@ -270,7 +260,6 @@ export const RoutingManager: Routing = { }, async transferRoom(room, guest, transferData) { - logger.debug(`Transfering room ${room._id} by ${transferData.transferredBy._id}`); if (transferData.departmentId) { logger.debug(`Transfering room ${room._id} to department ${transferData.departmentId}`); return forwardRoomToDepartment(room, guest, transferData); @@ -286,7 +275,6 @@ export const RoutingManager: Routing = { }, async delegateAgent(agent, inquiry) { - logger.debug(`Delegating Inquiry ${inquiry._id}`); const defaultAgent = await callbacks.run('livechat.beforeDelegateAgent', agent, { department: inquiry?.department, }); @@ -309,7 +297,6 @@ export const RoutingManager: Routing = { if (ignoreUser && ignoreUser._id === u._id) { return; } - // @ts-expect-error - File still in JS, expecting error for now on `u` types void removeAgentFromSubscription(roomId, u); }); }, diff --git a/apps/meteor/app/livechat/server/lib/logger.ts b/apps/meteor/app/livechat/server/lib/logger.ts index ee6b2969c5c5..bb452cd4db04 100644 --- a/apps/meteor/app/livechat/server/lib/logger.ts +++ b/apps/meteor/app/livechat/server/lib/logger.ts @@ -1,4 +1,4 @@ -import { Logger } from '../../../../server/lib/logger/Logger'; +import { Logger } from '@rocket.chat/logger'; export const callbackLogger = new Logger('[Omnichannel] Callback'); export const businessHourLogger = new Logger('Business Hour'); diff --git a/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts b/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts index f5e57138730e..bbce5d16efb4 100644 --- a/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts +++ b/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts @@ -1,4 +1,5 @@ -import { Logger } from '../../../../logger/server'; +import { Logger } from '@rocket.chat/logger'; + import { settings } from '../../../../settings/server'; import { Livechat } from '../Livechat'; diff --git a/apps/meteor/app/livechat/server/methods/discardTranscript.ts b/apps/meteor/app/livechat/server/methods/discardTranscript.ts index fec443444d18..d46c8ffea35f 100644 --- a/apps/meteor/app/livechat/server/methods/discardTranscript.ts +++ b/apps/meteor/app/livechat/server/methods/discardTranscript.ts @@ -4,6 +4,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -14,7 +15,11 @@ declare module '@rocket.chat/ui-contexts' { Meteor.methods({ async 'livechat:discardTranscript'(rid: string) { + methodDeprecationLogger.method('livechat:discardTranscript', '7.0.0'); check(rid, String); + methodDeprecationLogger.warn( + 'The method "livechat:discardTranscript" is deprecated and will be removed after version v7.0.0. Use "livechat/transcript/:rid" (DELETE) instead.', + ); const user = Meteor.userId(); diff --git a/apps/meteor/app/livechat/server/methods/getAgentData.ts b/apps/meteor/app/livechat/server/methods/getAgentData.ts index f4ba67ebe10e..d32d24d7f7c1 100644 --- a/apps/meteor/app/livechat/server/methods/getAgentData.ts +++ b/apps/meteor/app/livechat/server/methods/getAgentData.ts @@ -4,6 +4,7 @@ import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { settings } from '../../../settings/server'; declare module '@rocket.chat/ui-contexts' { @@ -21,6 +22,10 @@ Meteor.methods({ check(roomId, String); check(token, String); + methodDeprecationLogger.warn( + 'The method "livechat:getAgentData" is deprecated and will be removed after version v7.0.0. Use "livechat/agent.info/:rid/:token" instead.', + ); + const room = await LivechatRooms.findOneById(roomId); const visitor = await LivechatVisitors.getVisitorByToken(token); diff --git a/apps/meteor/app/livechat/server/methods/getAgentOverviewData.ts b/apps/meteor/app/livechat/server/methods/getAgentOverviewData.ts index afbc2e8bb7fc..9cd5de75a0f3 100644 --- a/apps/meteor/app/livechat/server/methods/getAgentOverviewData.ts +++ b/apps/meteor/app/livechat/server/methods/getAgentOverviewData.ts @@ -3,6 +3,7 @@ import type { ServerMethods, TranslationKey } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { Livechat } from '../lib/Livechat'; declare module '@rocket.chat/ui-contexts' { @@ -17,6 +18,8 @@ declare module '@rocket.chat/ui-contexts' { Meteor.methods({ async 'livechat:getAgentOverviewData'(options) { + methodDeprecationLogger.method('livechat:getAgentOverviewData', '7.0.0', ' Use "livechat/analytics/agent-overview" instead.'); + const uid = Meteor.userId(); if (!uid || !(await hasPermissionAsync(uid, 'view-livechat-manager'))) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { diff --git a/apps/meteor/app/livechat/server/methods/getAnalyticsOverviewData.ts b/apps/meteor/app/livechat/server/methods/getAnalyticsOverviewData.ts index 48313f1ce67c..76b7f276d671 100644 --- a/apps/meteor/app/livechat/server/methods/getAnalyticsOverviewData.ts +++ b/apps/meteor/app/livechat/server/methods/getAnalyticsOverviewData.ts @@ -3,6 +3,7 @@ import type { ServerMethods, TranslationKey } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { settings } from '../../../settings/server'; import { Livechat } from '../lib/Livechat'; @@ -18,6 +19,7 @@ declare module '@rocket.chat/ui-contexts' { Meteor.methods({ async 'livechat:getAnalyticsOverviewData'(options) { + methodDeprecationLogger.method('livechat:getAnalyticsOverviewData', '7.0.0', ' Use "livechat/analytics/overview" instead.'); const uid = Meteor.userId(); if (!uid || !(await hasPermissionAsync(uid, 'view-livechat-manager'))) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { diff --git a/apps/meteor/app/livechat/server/methods/removeTrigger.ts b/apps/meteor/app/livechat/server/methods/removeTrigger.ts index c403dcd3edac..69f3a4a2d80c 100644 --- a/apps/meteor/app/livechat/server/methods/removeTrigger.ts +++ b/apps/meteor/app/livechat/server/methods/removeTrigger.ts @@ -4,6 +4,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -14,6 +15,8 @@ declare module '@rocket.chat/ui-contexts' { Meteor.methods({ async 'livechat:removeTrigger'(triggerId) { + methodDeprecationLogger.method('livechat:removeTrigger', '7.0.0'); + const uid = Meteor.userId(); if (!uid || !(await hasPermissionAsync(uid, 'view-livechat-manager'))) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { diff --git a/apps/meteor/app/livechat/server/methods/requestTranscript.ts b/apps/meteor/app/livechat/server/methods/requestTranscript.ts index e114daa0aa95..02ac65a48173 100644 --- a/apps/meteor/app/livechat/server/methods/requestTranscript.ts +++ b/apps/meteor/app/livechat/server/methods/requestTranscript.ts @@ -4,6 +4,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { Livechat } from '../lib/Livechat'; declare module '@rocket.chat/ui-contexts' { @@ -15,6 +16,7 @@ declare module '@rocket.chat/ui-contexts' { Meteor.methods({ async 'livechat:requestTranscript'(rid, email, subject) { + methodDeprecationLogger.method('livechat:requestTranscript', '7.0.0'); check(rid, String); check(email, String); diff --git a/apps/meteor/app/livechat/server/methods/saveAppearance.ts b/apps/meteor/app/livechat/server/methods/saveAppearance.ts index 3bf9ad7070ad..35152d136afd 100644 --- a/apps/meteor/app/livechat/server/methods/saveAppearance.ts +++ b/apps/meteor/app/livechat/server/methods/saveAppearance.ts @@ -3,6 +3,7 @@ import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -13,6 +14,7 @@ declare module '@rocket.chat/ui-contexts' { Meteor.methods({ async 'livechat:saveAppearance'(settings) { + methodDeprecationLogger.method('livechat:saveAppearance', '7.0.0'); const uid = Meteor.userId(); if (!uid || !(await hasPermissionAsync(uid, 'view-livechat-manager'))) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { diff --git a/apps/meteor/app/livechat/server/methods/saveTrigger.ts b/apps/meteor/app/livechat/server/methods/saveTrigger.ts index 18b17f687469..37f78d081203 100644 --- a/apps/meteor/app/livechat/server/methods/saveTrigger.ts +++ b/apps/meteor/app/livechat/server/methods/saveTrigger.ts @@ -5,6 +5,7 @@ import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -15,6 +16,7 @@ declare module '@rocket.chat/ui-contexts' { Meteor.methods({ async 'livechat:saveTrigger'(trigger) { + methodDeprecationLogger.method('livechat:saveTrigger', '7.0.0'); const uid = Meteor.userId(); if (!uid || !(await hasPermissionAsync(uid, 'view-livechat-manager'))) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { diff --git a/apps/meteor/app/livechat/server/methods/sendOfflineMessage.ts b/apps/meteor/app/livechat/server/methods/sendOfflineMessage.ts index c5c31951894e..9a475de5e32d 100644 --- a/apps/meteor/app/livechat/server/methods/sendOfflineMessage.ts +++ b/apps/meteor/app/livechat/server/methods/sendOfflineMessage.ts @@ -9,7 +9,7 @@ import { Livechat } from '../lib/Livechat'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { - 'livechat:sendOfflineMessage'(data: { name: string; email: string; message: string }): Promise; + 'livechat:sendOfflineMessage'(data: { name: string; email: string; message: string }): Promise; } } @@ -23,7 +23,7 @@ Meteor.methods({ message: String, }); - return Livechat.sendOfflineMessage(data); + await Livechat.sendOfflineMessage(data); }, }); diff --git a/apps/meteor/app/livechat/server/methods/transfer.ts b/apps/meteor/app/livechat/server/methods/transfer.ts index a2dce2ed2757..3817b10bf42b 100644 --- a/apps/meteor/app/livechat/server/methods/transfer.ts +++ b/apps/meteor/app/livechat/server/methods/transfer.ts @@ -58,7 +58,13 @@ Meteor.methods({ }); } - const guest = await LivechatVisitors.findOneById(room.v?._id); + const guest = await LivechatVisitors.findOneEnabledById(room.v?._id); + + const user = await Meteor.userAsync(); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'livechat:transfer' }); + } const normalizedTransferData: { roomId: string; @@ -70,7 +76,7 @@ Meteor.methods({ transferredTo?: Pick; } = { ...transferData, - transferredBy: normalizeTransferredByData((await Meteor.userAsync()) || {}, room), + transferredBy: normalizeTransferredByData(user, room), }; if (normalizedTransferData.userId) { diff --git a/apps/meteor/app/livechat/server/roomAccessValidator.compatibility.ts b/apps/meteor/app/livechat/server/roomAccessValidator.compatibility.ts index 8e598ee108d3..d5ef83272550 100644 --- a/apps/meteor/app/livechat/server/roomAccessValidator.compatibility.ts +++ b/apps/meteor/app/livechat/server/roomAccessValidator.compatibility.ts @@ -1,4 +1,4 @@ -import type { IUser, ILivechatDepartment, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import type { IUser, IOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatDepartmentAgents, LivechatInquiry, LivechatRooms, LivechatDepartment } from '@rocket.chat/models'; import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission'; @@ -47,10 +47,10 @@ export const validators: OmnichannelRoomAccessValidator[] = [ let departmentIds; if (!(await hasRoleAsync(user._id, 'livechat-manager'))) { - const departmentAgents = (await LivechatDepartmentAgents.findByAgentId(user._id).toArray()).map((d) => d.departmentId); - departmentIds = (await LivechatDepartment.find({ _id: { $in: departmentAgents }, enabled: true }).toArray()).map( - (d: ILivechatDepartment) => d._id, + const departmentAgents = (await LivechatDepartmentAgents.findByAgentId(user._id, { projection: { departmentId: 1 } }).toArray()).map( + (d) => d.departmentId, ); + departmentIds = (await LivechatDepartment.findEnabledInIds(departmentAgents, { projection: { _id: 1 } }).toArray()).map((d) => d._id); } const filter = { @@ -75,7 +75,9 @@ export const validators: OmnichannelRoomAccessValidator[] = [ if (!room.departmentId || room.open || !user?._id) { return; } - const agentOfDepartment = await LivechatDepartmentAgents.findOneByAgentIdAndDepartmentId(user._id, room.departmentId); + const agentOfDepartment = await LivechatDepartmentAgents.findOneByAgentIdAndDepartmentId(user._id, room.departmentId, { + projection: { _id: 1 }, + }); if (!agentOfDepartment) { return; } diff --git a/apps/meteor/app/livechat/server/sendMessageBySMS.ts b/apps/meteor/app/livechat/server/sendMessageBySMS.ts index 54ac69befae2..2557fcdeb83d 100644 --- a/apps/meteor/app/livechat/server/sendMessageBySMS.ts +++ b/apps/meteor/app/livechat/server/sendMessageBySMS.ts @@ -10,49 +10,45 @@ import { callbackLogger } from './lib/logger'; callbacks.add( 'afterSaveMessage', async (message, room) => { - callbackLogger.debug('Attempting to send SMS message'); // skips this callback if the message was edited if (isEditedMessage(message)) { - callbackLogger.debug('Message was edited, skipping SMS send'); return message; } if (!settings.get('SMS_Enabled')) { - callbackLogger.debug('SMS is not enabled, skipping SMS send'); return message; } // only send the sms by SMS if it is a livechat room with SMS set to true if (!(isOmnichannelRoom(room) && room.sms && room.v && room.v.token)) { - callbackLogger.debug('Room is not a livechat room, skipping SMS send'); return message; } // if the message has a token, it was sent from the visitor, so ignore it if (message.token) { - callbackLogger.debug('Message was sent from the visitor, skipping SMS send'); return message; } // if the message has a type means it is a special message (like the closing comment), so skips if (message.t) { - callbackLogger.debug('Message is a special message, skipping SMS send'); return message; } - let extraData = {}; + const { rid, u: { _id: userId } = {} } = message; + let extraData = { rid, userId }; if (message.file) { message = { ...(await normalizeMessageFileUpload(message)), ...{ _updatedAt: message._updatedAt } }; - const { fileUpload, rid, u: { _id: userId } = {} } = message; - extraData = Object.assign({}, { rid, userId, fileUpload }); + const { fileUpload } = message; + extraData = Object.assign({}, extraData, { fileUpload }); } if (message.location) { const { location } = message; extraData = Object.assign({}, extraData, { location }); } + const service = settings.get('SMS_Service'); - const SMSService = await OmnichannelIntegration.getSmsService(settings.get('SMS_Service')); + const SMSService = await OmnichannelIntegration.getSmsService(service); if (!SMSService) { callbackLogger.debug('SMS Service is not configured, skipping SMS send'); @@ -62,14 +58,12 @@ callbacks.add( const visitor = await LivechatVisitors.getVisitorByToken(room.v.token, { projection: { phone: 1 } }); if (!visitor?.phone || visitor.phone.length === 0) { - callbackLogger.debug('Visitor does not have a phone number, skipping SMS send'); return message; } try { - callbackLogger.debug(`Message will be sent to ${visitor.phone[0].phoneNumber} through service ${settings.get('SMS_Service')}`); await SMSService.send(room.sms.from, visitor.phone[0].phoneNumber, message.msg, extraData); - callbackLogger.debug(`SMS message sent to ${visitor.phone[0].phoneNumber}`); + callbackLogger.debug(`SMS message sent to ${visitor.phone[0].phoneNumber} via ${service}`); } catch (e) { callbackLogger.error(e); } diff --git a/apps/meteor/app/livechat/server/startup.ts b/apps/meteor/app/livechat/server/startup.ts index 84b2adfa0755..f9fce509e39a 100644 --- a/apps/meteor/app/livechat/server/startup.ts +++ b/apps/meteor/app/livechat/server/startup.ts @@ -62,18 +62,16 @@ Meteor.startup(async () => { await createDefaultBusinessHourIfNotExists(); settings.watch('Livechat_enable_business_hours', async (value) => { - Livechat.logger.debug(`Changing business hour type to ${value}`); + Livechat.logger.info(`Changing business hour type to ${value}`); if (value) { await businessHourManager.startManager(); - Livechat.logger.debug(`Business hour manager started`); return; } await businessHourManager.stopManager(); - Livechat.logger.debug(`Business hour manager stopped`); }); settings.watch('Livechat_Routing_Method', (value) => { - RoutingManager.setMethodNameAndStartQueue(value); + void RoutingManager.setMethodNameAndStartQueue(value); }); // Remove when accounts.onLogout is async diff --git a/apps/meteor/app/logger/server/index.ts b/apps/meteor/app/logger/server/index.ts deleted file mode 100644 index 5630b3a0aa2a..000000000000 --- a/apps/meteor/app/logger/server/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// TODO there are imports pointing to this file still, ideally we should point everything to "/server/lib/logger/Logger" and remove this file -export { Logger } from '../../../server/lib/logger/Logger'; diff --git a/apps/meteor/app/mailer/server/api.ts b/apps/meteor/app/mailer/server/api.ts index 957bbbe3cc51..b50fdfd26a2a 100644 --- a/apps/meteor/app/mailer/server/api.ts +++ b/apps/meteor/app/mailer/server/api.ts @@ -75,11 +75,12 @@ export const wrap = (html: string, data: { [key: string]: unknown } = {}): strin } if (!body) { - throw new Error('`body` is not set yet'); + throw new Error('error-email-body-not-initialized'); } return replaceEscaped(body.replace('{{body}}', html), data); }; + export const inlinecss = (html: string): string => { const css = settings.get('email_style'); return css ? juice.inlineContent(html, css) : html; diff --git a/apps/meteor/app/mentions/lib/MentionsParser.js b/apps/meteor/app/mentions/lib/MentionsParser.js index 7a9422b2b82b..87329ac9f120 100644 --- a/apps/meteor/app/mentions/lib/MentionsParser.js +++ b/apps/meteor/app/mentions/lib/MentionsParser.js @@ -38,7 +38,7 @@ export class MentionsParser { } get userMentionRegex() { - return new RegExp(`(^|\\s|> ?)@(${this.pattern}(@(${this.pattern}))?(:([0-9a-zA-Z-_.]+))?)`, 'gm'); + return new RegExp(`(^|\\s|>)@(${this.pattern}(@(${this.pattern}))?(:([0-9a-zA-Z-_.]+))?)`, 'gm'); } get channelMentionRegex() { diff --git a/apps/meteor/app/mentions/server/getMentionedTeamMembers.ts b/apps/meteor/app/mentions/server/getMentionedTeamMembers.ts new file mode 100644 index 000000000000..b1355c90cb93 --- /dev/null +++ b/apps/meteor/app/mentions/server/getMentionedTeamMembers.ts @@ -0,0 +1,35 @@ +import { Team } from '@rocket.chat/core-services'; +import type { IMessage } from '@rocket.chat/core-typings'; + +import { callbacks } from '../../../lib/callbacks'; +import { settings } from '../../settings/server'; + +interface IExtraDataForNotification { + userMentions: any[]; + otherMentions: any[]; + message: IMessage; +} + +const beforeGetMentions = async (mentionIds: string[], extra?: IExtraDataForNotification) => { + const { otherMentions } = extra ?? {}; + + const teamIds = otherMentions?.filter(({ type }) => type === 'team').map(({ _id }) => _id); + + if (!teamIds?.length) { + return mentionIds; + } + + const members = await Team.getMembersByTeamIds(teamIds, { projection: { userId: 1 } }); + mentionIds.push(...new Set(members.map(({ userId }) => userId).filter((userId) => !mentionIds.includes(userId)))); + + return mentionIds; +}; + +settings.watch('Troubleshoot_Disable_Teams_Mention', (value) => { + if (value) { + callbacks.remove('beforeGetMentions', 'before-get-mentions-get-teams'); + return; + } + + callbacks.add('beforeGetMentions', beforeGetMentions, callbacks.priority.MEDIUM, 'before-get-mentions-get-teams'); +}); diff --git a/apps/meteor/app/mentions/server/index.ts b/apps/meteor/app/mentions/server/index.ts index 474d41a439e1..a04af05b9db1 100644 --- a/apps/meteor/app/mentions/server/index.ts +++ b/apps/meteor/app/mentions/server/index.ts @@ -1,2 +1,3 @@ -import './server'; +import './getMentionedTeamMembers'; import './methods/getUserMentionsByChannel'; +import './server'; diff --git a/apps/meteor/app/mentions/server/server.ts b/apps/meteor/app/mentions/server/server.ts index 5eb70aae4656..13765e99d856 100644 --- a/apps/meteor/app/mentions/server/server.ts +++ b/apps/meteor/app/mentions/server/server.ts @@ -1,5 +1,5 @@ -import { api } from '@rocket.chat/core-services'; -import type { IUser, IRoom } from '@rocket.chat/core-typings'; +import { api, Team } from '@rocket.chat/core-services'; +import type { IUser, IRoom, ITeam } from '@rocket.chat/core-typings'; import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -9,16 +9,32 @@ import { settings } from '../../settings/server'; import MentionsServer from './Mentions'; export class MentionQueries { - async getUsers(usernames: string[]): Promise<(Pick & { type: 'user' })[]> { + async getUsers( + usernames: string[], + ): Promise<((Pick & { type: 'user' }) | (Pick & { type: 'team' }))[]> { + const uniqueUsernames = [...new Set(usernames)]; + const teams = await Team.listByNames(uniqueUsernames, { projection: { name: 1 } }); + const users = await Users.find( - { username: { $in: [...new Set(usernames)] } }, + { username: { $in: uniqueUsernames } }, { projection: { _id: true, username: true, name: 1 } }, ).toArray(); - return users.map((user) => ({ + const taggedUsers = users.map((user) => ({ ...user, - type: 'user', + type: 'user' as const, })); + + if (settings.get('Troubleshoot_Disable_Teams_Mention')) { + return taggedUsers; + } + + const taggedTeams = teams.map((team) => ({ + ...team, + type: 'team' as const, + })); + + return [...taggedUsers, ...taggedTeams]; } async getUser(userId: string): Promise { diff --git a/apps/meteor/app/message-mark-as-unread/client/actionButton.ts b/apps/meteor/app/message-mark-as-unread/client/actionButton.ts index f8c9b8c39ca3..fc4a9d80c43c 100644 --- a/apps/meteor/app/message-mark-as-unread/client/actionButton.ts +++ b/apps/meteor/app/message-mark-as-unread/client/actionButton.ts @@ -14,6 +14,7 @@ Meteor.startup(() => { icon: 'flag', label: 'Mark_unread', context: ['message', 'message-mobile', 'threads'], + type: 'interaction', async action(_, props) { const { message = messageArgs(this).msg } = props; @@ -44,7 +45,7 @@ Meteor.startup(() => { return message.u._id !== user._id; }, - order: 10, + order: 4, group: 'menu', }); }); diff --git a/apps/meteor/app/message-mark-as-unread/server/logger.ts b/apps/meteor/app/message-mark-as-unread/server/logger.ts index dc95ec0320d8..23a21f529cdd 100644 --- a/apps/meteor/app/message-mark-as-unread/server/logger.ts +++ b/apps/meteor/app/message-mark-as-unread/server/logger.ts @@ -1,4 +1,4 @@ -import { Logger } from '../../logger/server'; +import { Logger } from '@rocket.chat/logger'; const logger = new Logger('MessageMarkAsUnread'); export default logger; diff --git a/apps/meteor/app/message-pin/server/pinMessage.ts b/apps/meteor/app/message-pin/server/pinMessage.ts index f10683e35afe..906f0c98c181 100644 --- a/apps/meteor/app/message-pin/server/pinMessage.ts +++ b/apps/meteor/app/message-pin/server/pinMessage.ts @@ -1,7 +1,7 @@ import { Message } from '@rocket.chat/core-services'; -import { isQuoteAttachment } from '@rocket.chat/core-typings'; -import type { IMessage, IUser, MessageAttachment, MessageQuoteAttachment } from '@rocket.chat/core-typings'; -import { Messages, Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { isQuoteAttachment, isRegisterUser } from '@rocket.chat/core-typings'; +import type { IMessage, MessageAttachment, MessageQuoteAttachment } from '@rocket.chat/core-typings'; +import { Messages, Rooms, Subscriptions, Users, ReadReceipts } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -82,15 +82,13 @@ Meteor.methods({ throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'pinMessage' }); } - const me = await Users.findOneById>>(userId, { - projection: { username: 1, name: 1 }, - }); + const me = await Users.findOneById(userId); if (!me) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'pinMessage' }); } // If we keep history of edits, insert a new message to store history information - if (settings.get('Message_KeepHistory') && me.username) { + if (settings.get('Message_KeepHistory') && isRegisterUser(me)) { await Messages.cloneAndSaveAsHistoryById(message._id, me); } @@ -110,9 +108,14 @@ Meteor.methods({ username: me.username, }; + originalMessage = await Message.beforeSave({ message: originalMessage, room, user: me }); + originalMessage = await callbacks.run('beforeSaveMessage', originalMessage); await Messages.setPinnedByIdAndUserId(originalMessage._id, originalMessage.pinnedBy, originalMessage.pinned); + if (settings.get('Message_Read_Receipt_Store_Users')) { + await ReadReceipts.setPinnedByMessageId(message._id, originalMessage.pinned); + } if (isTheLastMessage(room, message)) { await Rooms.setLastMessagePinned(room._id, originalMessage.pinnedBy, originalMessage.pinned); } @@ -183,15 +186,13 @@ Meteor.methods({ throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'unpinMessage' }); } - const me = await Users.findOneById>>(userId, { - projection: { username: 1, name: 1 }, - }); + const me = await Users.findOneById(userId); if (!me) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'unpinMessage' }); } // If we keep history of edits, insert a new message to store history information - if (settings.get('Message_KeepHistory') && me.username) { + if (settings.get('Message_KeepHistory') && isRegisterUser(me)) { await Messages.cloneAndSaveAsHistoryById(originalMessage._id, me); } @@ -200,7 +201,6 @@ Meteor.methods({ _id: userId, username: me.username, }; - originalMessage = await callbacks.run('beforeSaveMessage', originalMessage); const room = await Rooms.findOneById(originalMessage.rid, { projection: { ...roomAccessAttributes, lastMessage: 1 } }); if (!room) { @@ -211,6 +211,10 @@ Meteor.methods({ throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'unpinMessage' }); } + originalMessage = await Message.beforeSave({ message: originalMessage, room, user: me }); + + originalMessage = await callbacks.run('beforeSaveMessage', originalMessage); + if (isTheLastMessage(room, message)) { await Rooms.setLastMessagePinned(room._id, originalMessage.pinnedBy, originalMessage.pinned); } @@ -219,6 +223,9 @@ Meteor.methods({ await Apps.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, await Meteor.userAsync(), originalMessage.pinned); await Messages.setPinnedByIdAndUserId(originalMessage._id, originalMessage.pinnedBy, originalMessage.pinned); + if (settings.get('Message_Read_Receipt_Store_Users')) { + await ReadReceipts.setPinnedByMessageId(originalMessage._id, originalMessage.pinned); + } return true; }, 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 3b734e70e233..f62ab71f2302 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts @@ -221,8 +221,8 @@ export class SAML { }, ); - if (username && fullName && (username !== user.username || fullName !== user.name)) { - await saveUserIdentity({ _id: user._id, name: fullName, username }); + if ((username && username !== user.username) || (fullName && fullName !== user.name)) { + await saveUserIdentity({ _id: user._id, name: fullName || undefined, username }); } // sending token along with the userId @@ -430,7 +430,7 @@ export class SAML { }; await this.storeCredential(credentialToken, loginResult); - const url = `${Meteor.absoluteUrl('saml')}/${credentialToken}`; + const url = Meteor.absoluteUrl(SAMLUtils.getValidationActionRedirectPath(credentialToken)); res.writeHead(302, { Location: url, }); @@ -480,7 +480,6 @@ export class SAML { continue; } - const room = await Rooms.findOneByNameAndType(roomName, 'c', {}); const privRoom = await Rooms.findOneByNameAndType(roomName, 'p', {}); if (privRoom && includePrivateChannelsInUpdate === true) { @@ -488,6 +487,7 @@ export class SAML { continue; } + const room = await Rooms.findOneByNameAndType(roomName, 'c', {}); if (room) { await addUserToRoom(room._id, user); continue; @@ -496,7 +496,7 @@ export class SAML { if (!room && !privRoom) { // If the user doesn't have an username yet, we can't create new rooms for them if (user.username) { - await createRoom('c', roomName, user.username); + await createRoom('c', roomName, user); } } } diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/Utils.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/Utils.ts index 260b8257bd8e..70df22120b75 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/Utils.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/Utils.ts @@ -1,8 +1,9 @@ import { EventEmitter } from 'events'; import zlib from 'zlib'; +import type { Logger } from '@rocket.chat/logger'; + import { ensureArray } from '../../../../lib/utils/arrayUtils'; -import type { Logger } from '../../../../server/lib/logger/Logger'; import type { IUserDataMap, IAttributeMapping } from '../definition/IAttributeMapping'; import type { ISAMLGlobalSettings } from '../definition/ISAMLGlobalSettings'; import type { ISAMLUser } from '../definition/ISAMLUser'; @@ -130,6 +131,11 @@ export class SAMLUtils { return newTemplate; } + public static getValidationActionRedirectPath(credentialToken: string): string { + // the saml_idp_credentialToken param is needed by the mobile app + return `saml/${credentialToken}?saml_idp_credentialToken=${credentialToken}`; + } + public static log(obj: any, ...args: Array): void { if (debug && logger) { logger.debug(obj, ...args); diff --git a/apps/meteor/app/meteor-accounts-saml/server/startup.ts b/apps/meteor/app/meteor-accounts-saml/server/startup.ts index 91a94828eb7d..7a2bf16d3244 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/startup.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/startup.ts @@ -1,6 +1,6 @@ +import { Logger } from '@rocket.chat/logger'; import { Meteor } from 'meteor/meteor'; -import { Logger } from '../../logger/server'; import { settings } from '../../settings/server'; import { SAMLUtils } from './lib/Utils'; import { loadSamlServiceProviders, addSettings } from './lib/settings'; diff --git a/apps/meteor/app/oembed/server/jumpToMessage.ts b/apps/meteor/app/oembed/server/jumpToMessage.ts index a2fbe466edf4..9c5be2639c7c 100644 --- a/apps/meteor/app/oembed/server/jumpToMessage.ts +++ b/apps/meteor/app/oembed/server/jumpToMessage.ts @@ -4,7 +4,6 @@ import URL from 'url'; import type { MessageAttachment, IMessage, IOmnichannelRoom } from '@rocket.chat/core-typings'; import { isQuoteAttachment } from '@rocket.chat/core-typings'; import { Messages, Users, Rooms } from '@rocket.chat/models'; -import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../lib/callbacks'; import { createQuoteAttachment } from '../../../lib/createQuoteAttachment'; @@ -51,7 +50,7 @@ callbacks.add( for await (const item of msg.urls) { // if the URL doesn't belong to the current server, skip - if (!item.url.includes(Meteor.absoluteUrl())) { + if (!item.url.includes(settings.get('Site_Url'))) { continue; } diff --git a/apps/meteor/app/oembed/server/providers.ts b/apps/meteor/app/oembed/server/providers.ts index e80e456c679b..d2d0f85d19ce 100644 --- a/apps/meteor/app/oembed/server/providers.ts +++ b/apps/meteor/app/oembed/server/providers.ts @@ -1,9 +1,5 @@ -import QueryString from 'querystring'; -import URL from 'url'; - -import type { OEmbedMeta, OEmbedUrlContent, ParsedUrl, OEmbedProvider } from '@rocket.chat/core-typings'; +import type { OEmbedMeta, OEmbedUrlContent, OEmbedProvider } from '@rocket.chat/core-typings'; import { camelCase } from 'change-case'; -import _ from 'underscore'; import { callbacks } from '../../../lib/callbacks'; import { SystemLogger } from '../../../server/lib/logger/system'; @@ -16,10 +12,10 @@ class Providers { } static getConsumerUrl(provider: OEmbedProvider, url: string): string { - const urlObj = new URL.URL(provider.endPoint); + const urlObj = new URL(provider.endPoint); urlObj.searchParams.set('url', url); - return URL.format(urlObj); + return urlObj.toString(); } registerProvider(provider: OEmbedProvider): number { @@ -95,25 +91,20 @@ providers.registerProvider({ callbacks.add( 'oembed:beforeGetUrlContent', (data) => { - if (data.parsedUrl != null) { - const url = URL.format(data.parsedUrl); - const provider = providers.getProviderForUrl(url); - if (provider != null) { - const consumerUrl = Providers.getConsumerUrl(provider, url); - - const parsedConsumerUrl = URL.parse(consumerUrl, true); - _.extend(data.parsedUrl, parsedConsumerUrl); - - data.urlObj.port = parsedConsumerUrl.port; - data.urlObj.hostname = parsedConsumerUrl.hostname; - data.urlObj.pathname = parsedConsumerUrl.pathname; - data.urlObj.query = parsedConsumerUrl.query; - - delete data.urlObj.search; - delete data.urlObj.host; - } + if (!data.urlObj) { + return data; } - return data; + + const url = data.urlObj.toString(); + const provider = providers.getProviderForUrl(url); + + if (!provider) { + return data; + } + + const consumerUrl = Providers.getConsumerUrl(provider, url); + + return { ...data, urlObj: new URL(consumerUrl) }; }, callbacks.priority.MEDIUM, 'oembed-providers-before', @@ -123,13 +114,11 @@ const cleanupOembed = (data: { url: string; meta: OEmbedMeta; headers: { [k: string]: string }; - parsedUrl: ParsedUrl; content: OEmbedUrlContent; }): { url: string; meta: Omit; headers: { [k: string]: string }; - parsedUrl: ParsedUrl; content: OEmbedUrlContent; } => { if (!data?.meta) { @@ -148,24 +137,17 @@ const cleanupOembed = (data: { callbacks.add( 'oembed:afterParseContent', (data) => { - if (!data?.url || !data.content?.body || !data.parsedUrl?.query) { + if (!data?.url || !data.content?.body) { return cleanupOembed(data); } - const queryString = typeof data.parsedUrl.query === 'string' ? QueryString.parse(data.parsedUrl.query) : data.parsedUrl.query; - - if (!queryString.url) { - return cleanupOembed(data); - } + const provider = providers.getProviderForUrl(data.url); - const { url: originalUrl } = data; - const provider = providers.getProviderForUrl(originalUrl); if (!provider) { return cleanupOembed(data); } - const { url } = queryString; - data.meta.oembedUrl = url; + data.meta.oembedUrl = data.url; try { const metas = JSON.parse(data.content.body); diff --git a/apps/meteor/app/oembed/server/server.ts b/apps/meteor/app/oembed/server/server.ts index 02fdeb909213..79de0402043f 100644 --- a/apps/meteor/app/oembed/server/server.ts +++ b/apps/meteor/app/oembed/server/server.ts @@ -1,8 +1,6 @@ -import querystring from 'querystring'; -import URL from 'url'; - import type { OEmbedUrlContentResult, OEmbedUrlWithMetadata, IMessage, MessageAttachment, OEmbedMeta } from '@rocket.chat/core-typings'; import { isOEmbedUrlContentResult, isOEmbedUrlWithMetadata } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; import { Messages, OEmbedCache } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { camelCase } from 'change-case'; @@ -10,14 +8,13 @@ import he from 'he'; import iconv from 'iconv-lite'; import ipRangeCheck from 'ip-range-check'; import jschardet from 'jschardet'; -import _ from 'underscore'; import { callbacks } from '../../../lib/callbacks'; import { isURL } from '../../../lib/utils/isURL'; -import { Logger } from '../../logger/server'; import { settings } from '../../settings/server'; import { Info } from '../../utils/rocketchat.info'; +const MAX_EXTERNAL_URL_PREVIEWS = 5; const log = new Logger('OEmbed'); // Detect encoding // Priority: @@ -61,14 +58,7 @@ const toUtf8 = function (contentType: string, body: Buffer): string { return iconv.decode(body, getCharset(contentType, body)); }; -const getUrlContent = async function (urlObjStr: string | URL.UrlWithStringQuery, redirectCount = 5): Promise { - let urlObj: URL.UrlWithStringQuery; - if (typeof urlObjStr === 'string') { - urlObj = URL.parse(urlObjStr); - } else { - urlObj = urlObjStr; - } - +const getUrlContent = async (urlObj: URL, redirectCount = 5): Promise => { const portsProtocol = new Map( Object.entries({ 80: 'http:', @@ -77,34 +67,28 @@ const getUrlContent = async function (urlObjStr: string | URL.UrlWithStringQuery }), ); - const parsedUrl = _.pick(urlObj, ['host', 'hash', 'pathname', 'protocol', 'port', 'query', 'search', 'hostname']); const ignoredHosts = settings.get('API_EmbedIgnoredHosts').replace(/\s/g, '').split(',') || []; - if (parsedUrl.hostname && (ignoredHosts.includes(parsedUrl.hostname) || ipRangeCheck(parsedUrl.hostname, ignoredHosts))) { + if (urlObj.hostname && (ignoredHosts.includes(urlObj.hostname) || ipRangeCheck(urlObj.hostname, ignoredHosts))) { throw new Error('invalid host'); } const safePorts = settings.get('API_EmbedSafePorts').replace(/\s/g, '').split(',') || []; - if (safePorts.length > 0 && parsedUrl.port && !safePorts.includes(parsedUrl.port)) { + // checks if the URL port is in the safe ports list + if (safePorts.length > 0 && urlObj.port && !safePorts.includes(urlObj.port)) { throw new Error('invalid/unsafe port'); } - if (safePorts.length > 0 && !parsedUrl.port && !safePorts.some((port) => portsProtocol.get(port) === parsedUrl.protocol)) { + // if port is not detected, use protocol to verify instead + if (safePorts.length > 0 && !urlObj.port && !safePorts.some((port) => portsProtocol.get(port) === urlObj.protocol)) { throw new Error('invalid/unsafe port'); } const data = await callbacks.run('oembed:beforeGetUrlContent', { urlObj, - parsedUrl, }); - /* This prop is neither passed or returned by the callback, so I'll just comment it for now - if (data.attachments != null) { - return data; - } */ - - const url = URL.format(data.urlObj); - + const url = data.urlObj.toString(); const sizeLimit = 250000; log.debug(`Fetching ${url} following redirects ${redirectCount} times`); @@ -136,10 +120,10 @@ const getUrlContent = async function (urlObjStr: string | URL.UrlWithStringQuery log.debug('Obtained response from server with length of', totalSize); const buffer = Buffer.concat(chunks); + return { headers: Object.fromEntries(response.headers), body: toUtf8(response.headers.get('content-type') || 'text/plain', buffer), - parsedUrl, statusCode: response.status, }; }; @@ -149,19 +133,13 @@ const getUrlMeta = async function ( withFragment?: boolean, ): Promise { log.debug('Obtaining metadata for URL', url); - const urlObj = URL.parse(url); - if (withFragment != null) { - const queryStringObj = querystring.parse(urlObj.query || ''); - queryStringObj._escaped_fragment_ = ''; - urlObj.query = querystring.stringify(queryStringObj); - let path = urlObj.pathname; - if (urlObj.query != null) { - path += `?${urlObj.query}`; - urlObj.search = `?${urlObj.query}`; - } - urlObj.path = path; + const urlObj = new URL(url); + + if (withFragment) { + urlObj.searchParams.set('_escaped_fragment_', ''); } - log.debug('Fetching url content', urlObj.path); + + log.debug('Fetching url content', urlObj.toString()); let content: OEmbedUrlContentResult | undefined; try { content = await getUrlContent(urlObj, 5); @@ -173,7 +151,7 @@ const getUrlMeta = async function ( return; } - if (content.attachments != null) { + if (content.attachments) { return content; } @@ -220,7 +198,6 @@ const getUrlMeta = async function ( url, meta: metas, headers, - parsedUrl: content.parsedUrl, content, }); }; @@ -232,38 +209,25 @@ const getUrlMetaWithCache = async function ( log.debug('Getting oembed metadata for', url); const cache = await OEmbedCache.findOneById(url); - if (cache != null) { + if (cache) { log.debug('Found oembed metadata in cache for', url); return cache.data; } + const data = await getUrlMeta(url, withFragment); - if (data != null) { - try { - log.debug('Saving oembed metadata in cache for', url); - await OEmbedCache.createWithIdAndData(url, data); - } catch (_error) { - log.error({ msg: 'OEmbed duplicated record', url }); - } - return data; - } -}; -const hasOnlyContentLength = (obj: any): obj is { contentLength: string } => 'contentLength' in obj && Object.keys(obj).length === 1; -const hasOnlyContentType = (obj: any): obj is { contentType: string } => 'contentType' in obj && Object.keys(obj).length === 1; -const hasContentLengthAndContentType = (obj: any): obj is { contentLength: string; contentType: string } => - 'contentLength' in obj && 'contentType' in obj && Object.keys(obj).length === 2; - -const getRelevantHeaders = function (headersObj: { - [key: string]: string; -}): { contentLength: string } | { contentType: string } | { contentLength: string; contentType: string } | void { - const headers = { - ...(headersObj.contentLength && { contentLength: headersObj.contentLength }), - ...(headersObj.contentType && { contentType: headersObj.contentType }), - }; + if (!data) { + return; + } - if (hasOnlyContentLength(headers) || hasOnlyContentType(headers) || hasContentLengthAndContentType(headers)) { - return headers; + try { + log.debug('Saving oembed metadata in cache for', url); + await OEmbedCache.createWithIdAndData(url, data); + } catch (_error) { + log.error({ msg: 'OEmbed duplicated record', url }); } + + return data; }; const getRelevantMetaTags = function (metaObj: OEmbedMeta): Record | void { @@ -285,48 +249,71 @@ const insertMaxWidthInOembedHtml = (oembedHtml?: string): string | undefined => const rocketUrlParser = async function (message: IMessage): Promise { log.debug('Parsing message URLs'); - if (Array.isArray(message.urls)) { - log.debug('URLs found', message.urls.length); - const attachments: MessageAttachment[] = []; - - let changed = false; - for await (const item of message.urls) { - if (item.ignoreParse === true) { - log.debug('URL ignored', item.url); - break; - } - if (!isURL(item.url)) { - break; - } - const data = await getUrlMetaWithCache(item.url); - if (data != null) { - if (isOEmbedUrlContentResult(data) && data.attachments) { - attachments.push(...data.attachments); - break; - } - if (isOEmbedUrlWithMetadata(data) && data.meta != null) { - item.meta = getRelevantMetaTags(data.meta) || {}; - if (item.meta?.oembedHtml) { - item.meta.oembedHtml = insertMaxWidthInOembedHtml(item.meta.oembedHtml) || ''; - } - } - if (data.headers != null) { - const headers = getRelevantHeaders(data.headers); - if (headers) { - item.headers = headers; - } - } - item.parsedUrl = data.parsedUrl; - changed = true; + + if (!Array.isArray(message.urls)) { + return message; + } + + log.debug('URLs found', message.urls.length); + + if ( + (message.attachments && message.attachments.length > 0) || + message.urls.filter((item) => !item.url.includes(settings.get('Site_Url'))).length > MAX_EXTERNAL_URL_PREVIEWS + ) { + log.debug('All URL ignored'); + return message; + } + + const attachments: MessageAttachment[] = []; + + let changed = false; + for await (const item of message.urls) { + if (item.ignoreParse === true) { + log.debug('URL ignored', item.url); + continue; + } + + if (!isURL(item.url)) { + continue; + } + + const data = await getUrlMetaWithCache(item.url); + + if (!data) { + continue; + } + + if (isOEmbedUrlContentResult(data) && data.attachments) { + attachments.push(...data.attachments); + break; + } + + if (isOEmbedUrlWithMetadata(data) && data.meta) { + item.meta = getRelevantMetaTags(data.meta) || {}; + if (item.meta?.oembedHtml) { + item.meta.oembedHtml = insertMaxWidthInOembedHtml(item.meta.oembedHtml) || ''; } } - if (attachments.length) { - await Messages.setMessageAttachments(message._id, attachments); + + if (data.headers?.contentLength) { + item.headers = { ...item.headers, contentLength: data.headers.contentLength }; } - if (changed === true) { - await Messages.setUrlsById(message._id, message.urls); + + if (data.headers?.contentType) { + item.headers = { ...item.headers, contentType: data.headers.contentType }; } + + changed = true; } + + if (attachments.length) { + await Messages.setMessageAttachments(message._id, attachments); + } + + if (changed === true) { + await Messages.setUrlsById(message._id, message.urls); + } + return message; }; diff --git a/apps/meteor/app/otr/server/methods/deleteOldOTRMessages.ts b/apps/meteor/app/otr/server/methods/deleteOldOTRMessages.ts index b83f56a17128..a994b19dd1a4 100644 --- a/apps/meteor/app/otr/server/methods/deleteOldOTRMessages.ts +++ b/apps/meteor/app/otr/server/methods/deleteOldOTRMessages.ts @@ -1,5 +1,5 @@ import type { IRoom } from '@rocket.chat/core-typings'; -import { Messages, Subscriptions } from '@rocket.chat/models'; +import { Messages, Subscriptions, ReadReceipts } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; @@ -28,5 +28,6 @@ Meteor.methods({ } await Messages.deleteOldOTRMessages(roomId, now); + await ReadReceipts.removeOTRReceiptsUntilDate(roomId, now); }, }); diff --git a/apps/meteor/app/push-notifications/server/lib/PushNotification.ts b/apps/meteor/app/push-notifications/server/lib/PushNotification.ts index a89f9fada68e..3e021cfbcc75 100644 --- a/apps/meteor/app/push-notifications/server/lib/PushNotification.ts +++ b/apps/meteor/app/push-notifications/server/lib/PushNotification.ts @@ -65,7 +65,7 @@ class PushNotification { // message is being redacted already by 'getPushData' if idOnly is true const text = !idOnly && roomName !== '' ? `${username}: ${message}` : message; - const config: IPushNotificationConfig = { + return { from: 'push', badge, sound: 'default', @@ -84,15 +84,8 @@ class PushNotification { style: 'inbox', image: RocketChatAssets.getURL('Assets_favicon_192'), }, + ...(category !== '' ? { apn: { category } } : {}), }; - - if (category !== '') { - config.apn = { - category, - }; - } - - return config; } async send({ rid, uid, mid, roomName, username, message, payload, badge = 1, category }: PushNotificationData): Promise { diff --git a/apps/meteor/app/push/server/apn.js b/apps/meteor/app/push/server/apn.ts similarity index 67% rename from apps/meteor/app/push/server/apn.js rename to apps/meteor/app/push/server/apn.ts index 663c7512ab1a..1794a2886324 100644 --- a/apps/meteor/app/push/server/apn.js +++ b/apps/meteor/app/push/server/apn.ts @@ -1,13 +1,33 @@ +import type { IAppsTokens, RequiredField } from '@rocket.chat/core-typings'; import apn from 'apn'; import EJSON from 'ejson'; +import type { PushOptions, PendingPushNotification } from './definition'; import { logger } from './logger'; -let apnConnection; +let apnConnection: apn.Provider | undefined; -export const sendAPN = ({ userToken, notification, _removeToken }) => { - if (typeof notification.apn === 'object') { - notification = Object.assign({}, notification, notification.apn); +declare module 'apn' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface Notification { + setContentAvailable: (value: boolean | 1 | 0) => void; + set category(_value: string | undefined); + set body(_value: string); + set title(_value: string); + } +} + +export const sendAPN = ({ + userToken, + notification, + _removeToken, +}: { + userToken: string; + notification: PendingPushNotification & { topic: string }; + _removeToken: (token: IAppsTokens['token']) => void; +}) => { + if (!apnConnection) { + throw new Error('Apn Connection not initialized.'); } const priority = notification.priority || notification.priority === 0 ? notification.priority : 10; @@ -15,8 +35,13 @@ export const sendAPN = ({ userToken, notification, _removeToken }) => { const note = new apn.Notification(); note.expiry = Math.floor(Date.now() / 1000) + 3600; // Expires 1 hour from now. - note.badge = notification.badge; - note.sound = notification.sound; + if (notification.badge !== undefined) { + note.badge = notification.badge; + } + + if (notification.sound !== undefined) { + note.sound = notification.sound; + } if (notification.contentAvailable != null) { note.setContentAvailable(notification.contentAvailable); @@ -25,7 +50,7 @@ export const sendAPN = ({ userToken, notification, _removeToken }) => { // adds category support for iOS8 custom actions as described here: // https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/ // RemoteNotificationsPG/Chapters/IPhoneOSClientImp.html#//apple_ref/doc/uid/TP40008194-CH103-SW36 - note.category = notification.category; + note.category = notification.apn?.category; note.body = notification.text; note.title = notification.title; @@ -40,16 +65,14 @@ export const sendAPN = ({ userToken, notification, _removeToken }) => { note.payload.messageFrom = notification.from; note.priority = priority; - // Store the token on the note so we can reference it if there was an error - note.token = userToken; - note.topic = notification.topic; - note.mutableContent = 1; + note.topic = `${notification.topic}${notification.apn?.topicSuffix || ''}`; + note.mutableContent = true; - apnConnection.send(note, userToken).then((response) => { + void apnConnection.send(note, userToken).then((response) => { response.failed.forEach((failure) => { logger.debug(`Got error code ${failure.status} for token ${userToken}`); - if (['400', '410'].includes(failure.status)) { + if (['400', '410'].includes(failure.status ?? '')) { logger.debug(`Removing token ${userToken}`); _removeToken({ apn: userToken, @@ -59,19 +82,10 @@ export const sendAPN = ({ userToken, notification, _removeToken }) => { }); }; -export const initAPN = ({ options, absoluteUrl }) => { +export const initAPN = ({ options, absoluteUrl }: { options: RequiredField; absoluteUrl: string }) => { logger.debug('APN configured'); - // Allow production to be a general option for push notifications - if (options.production === Boolean(options.production)) { - options.apn.production = options.production; - } - - // Give the user warnings about development settings - if (options.apn.development) { - // This flag is normally set by the configuration file - logger.warn('WARNING: Push APN is using development key and certificate'); - } else if (options.apn.gateway) { + if (options.apn.gateway) { // We check the apn gateway i the options, we could risk shipping // server into production while using the production configuration. // On the other hand we could be in development but using the production @@ -93,7 +107,7 @@ export const initAPN = ({ options, absoluteUrl }) => { // Warn about gateways we dont know about logger.warn(`WARNING: Push APN unknown gateway "${options.apn.gateway}"`); } - } else if (options.apn.production) { + } else if (options.production) { if (/http:\/\/localhost/.test(absoluteUrl)) { logger.warn('WARNING: Push APN is configured to production mode - but server is running from localhost'); } @@ -102,12 +116,12 @@ export const initAPN = ({ options, absoluteUrl }) => { } // Check certificate data - if (!options.apn.cert || !options.apn.cert.length) { + if (!options.apn.cert?.length) { logger.error('ERROR: Push server could not find cert'); } // Check key data - if (!options.apn.key || !options.apn.key.length) { + if (!options.apn.key?.length) { logger.error('ERROR: Push server could not find key'); } diff --git a/apps/meteor/app/push/server/definition.ts b/apps/meteor/app/push/server/definition.ts new file mode 100644 index 000000000000..39ab94de0a36 --- /dev/null +++ b/apps/meteor/app/push/server/definition.ts @@ -0,0 +1,46 @@ +export type PushOptions = { + sendTimeout?: number; + production?: boolean; + apn?: { + passphrase: string; + key: string; + cert: string; + + gateway?: string; + }; + gcm?: { + apiKey: string; + projectNumber: string; + }; + gateways?: string[]; + uniqueId: string; + getAuthorization?: () => Promise; +}; + +export type PendingPushNotification = { + from: string; + title: string; + text: string; + badge?: number; + sound?: string; + notId?: number; + apn?: { + category?: string; + topicSuffix?: string; + }; + gcm?: { + style?: string; + image?: string; + }; + payload?: Record; + createdAt: Date; + createdBy?: string; + + userId: string; + + sent?: boolean; + sending?: number; + priority?: number; + + contentAvailable?: 1 | 0; +}; diff --git a/apps/meteor/app/push/server/gcm.js b/apps/meteor/app/push/server/gcm.ts similarity index 67% rename from apps/meteor/app/push/server/gcm.js rename to apps/meteor/app/push/server/gcm.ts index 2fb2e844356a..6ab679d504f6 100644 --- a/apps/meteor/app/push/server/gcm.js +++ b/apps/meteor/app/push/server/gcm.ts @@ -1,13 +1,23 @@ +import type { IAppsTokens, RequiredField } from '@rocket.chat/core-typings'; import EJSON from 'ejson'; import gcm from 'node-gcm'; +import type { PendingPushNotification, PushOptions } from './definition'; import { logger } from './logger'; -export const sendGCM = function ({ userTokens, notification, _replaceToken, _removeToken, options }) { - if (typeof notification.gcm === 'object') { - notification = Object.assign({}, notification, notification.gcm); - } - +export const sendGCM = function ({ + userTokens, + notification, + _replaceToken, + _removeToken, + options, +}: { + userTokens: string | string[]; + notification: PendingPushNotification; + _replaceToken: (currentToken: IAppsTokens['token'], newToken: IAppsTokens['token']) => void; + _removeToken: (token: IAppsTokens['token']) => void; + options: RequiredField; +}) { // Make sure userTokens are an array of strings if (typeof userTokens === 'string') { userTokens = [userTokens]; @@ -22,22 +32,14 @@ export const sendGCM = function ({ userTokens, notification, _replaceToken, _rem logger.debug('sendGCM', userTokens, notification); // Allow user to set payload - const data = notification.payload ? { ejson: EJSON.stringify(notification.payload) } : {}; + const data: Record = notification.payload ? { ejson: EJSON.stringify(notification.payload) } : {}; data.title = notification.title; data.message = notification.text; // Set image - if (notification.image != null) { - data.image = notification.image; - } - - if (notification.android_channel_id != null) { - data.android_channel_id = notification.android_channel_id; - } else { - logger.debug( - 'For devices running Android 8.0 or later you are required to provide an android_channel_id. See https://github.com/raix/push/issues/341 for more info', - ); + if (notification.gcm?.image != null) { + data.image = notification.gcm?.image; } // Set extra details @@ -50,24 +52,8 @@ export const sendGCM = function ({ userTokens, notification, _replaceToken, _rem if (notification.notId != null) { data.notId = notification.notId; } - if (notification.style != null) { - data.style = notification.style; - } - if (notification.summaryText != null) { - data.summaryText = notification.summaryText; - } - if (notification.picture != null) { - data.picture = notification.picture; - } - - // Action Buttons - if (notification.actions != null) { - data.actions = notification.actions; - } - - // Force Start - if (notification.forceStart != null) { - data['force-start'] = notification.forceStart; + if (notification.gcm?.style != null) { + data.style = notification.gcm?.style; } if (notification.contentAvailable != null) { @@ -105,7 +91,7 @@ export const sendGCM = function ({ userTokens, notification, _replaceToken, _rem logger.debug({ msg: 'ANDROID: Result of sender', result }); - if (result.canonical_ids === 1 && userToken) { + if (result.canonical_ids === 1 && userToken && result.results?.[0].registration_id) { // This is an old device, token is replaced try { _replaceToken({ gcm: userToken }, { gcm: result.results[0].registration_id }); diff --git a/apps/meteor/app/push/server/logger.ts b/apps/meteor/app/push/server/logger.ts index f770aef378f6..5793949089dd 100644 --- a/apps/meteor/app/push/server/logger.ts +++ b/apps/meteor/app/push/server/logger.ts @@ -1,3 +1,3 @@ -import { Logger } from '../../../server/lib/logger/Logger'; +import { Logger } from '@rocket.chat/logger'; export const logger = new Logger('Push'); diff --git a/apps/meteor/app/push/server/methods.ts b/apps/meteor/app/push/server/methods.ts index 864903b16820..364a16cbdb5e 100644 --- a/apps/meteor/app/push/server/methods.ts +++ b/apps/meteor/app/push/server/methods.ts @@ -14,7 +14,7 @@ declare module '@rocket.chat/ui-contexts' { interface ServerMethods { 'raix:push-update'(options: { id?: string; - token: string; + token: IAppsTokens['token']; authToken: string; appName: string; userId?: string; diff --git a/apps/meteor/app/push/server/push.js b/apps/meteor/app/push/server/push.ts similarity index 52% rename from apps/meteor/app/push/server/push.js rename to apps/meteor/app/push/server/push.ts index 73525605266b..fe08f7b0a214 100644 --- a/apps/meteor/app/push/server/push.js +++ b/apps/meteor/app/push/server/push.ts @@ -1,28 +1,75 @@ +import type { IAppsTokens, RequiredField, Optional, IPushNotificationConfig } from '@rocket.chat/core-typings'; import { AppsTokens } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { pick } from '@rocket.chat/tools'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import _ from 'underscore'; import { settings } from '../../settings/server'; import { initAPN, sendAPN } from './apn'; +import type { PushOptions, PendingPushNotification } from './definition'; import { sendGCM } from './gcm'; import { logger } from './logger'; export const _matchToken = Match.OneOf({ apn: String }, { gcm: String }); +// This type must match the type defined in the push gateway +type GatewayNotification = { + uniqueId: string; + from: string; + title: string; + text: string; + badge?: number; + sound?: string; + notId?: number; + contentAvailable?: 1 | 0; + forceStart?: number; + topic?: string; + apn?: { + from?: string; + title?: string; + text?: string; + badge?: number; + sound?: string; + notId?: number; + category?: string; + }; + gcm?: { + from?: string; + title?: string; + text?: string; + image?: string; + style?: string; + summaryText?: string; + picture?: string; + badge?: number; + sound?: string; + notId?: number; + actions?: any[]; + }; + query?: { + userId: any; + }; + token?: IAppsTokens['token']; + tokens?: IAppsTokens['token'][]; + payload?: Record; + delayUntil?: Date; + createdAt: Date; + createdBy?: string; +}; + class PushClass { - options = {}; + options: PushOptions = { + uniqueId: '', + }; isConfigured = false; - configure(options) { - this.options = Object.assign( - { - sendTimeout: 60000, // Timeout period for notification send - }, - options, - ); + public configure(options: PushOptions): void { + this.options = { + sendTimeout: 60000, // Timeout period for notification send + ...options, + }; // https://npmjs.org/package/apn // After requesting the certificate from Apple, export your private key as @@ -46,57 +93,44 @@ class PushClass { logger.debug('Configure', this.options); if (this.options.apn) { - initAPN({ options: this.options, absoluteUrl: Meteor.absoluteUrl() }); + initAPN({ options: this.options as RequiredField, absoluteUrl: Meteor.absoluteUrl() }); } } - sendWorker(task, interval) { - logger.debug(`Send worker started, using interval: ${interval}`); - - return setInterval(() => { - try { - task(); - } catch (error) { - logger.debug(`Error while sending: ${error.message}`); - } - }, interval); - } - - _replaceToken(currentToken, newToken) { + private replaceToken(currentToken: IAppsTokens['token'], newToken: IAppsTokens['token']): void { void AppsTokens.updateMany({ token: currentToken }, { $set: { token: newToken } }); } - _removeToken(token) { + private removeToken(token: IAppsTokens['token']): void { void AppsTokens.deleteOne({ token }); } - _shouldUseGateway() { - return !!this.options.gateways && settings.get('Register_Server') && settings.get('Cloud_Service_Agree_PrivacyTerms'); + private shouldUseGateway(): boolean { + return Boolean(!!this.options.gateways && settings.get('Register_Server') && settings.get('Cloud_Service_Agree_PrivacyTerms')); } - sendNotificationNative(app, notification, countApn, countGcm) { + private sendNotificationNative(app: IAppsTokens, notification: PendingPushNotification, countApn: string[], countGcm: string[]): void { logger.debug('send to token', app.token); - if (app.token.apn) { + if ('apn' in app.token && app.token.apn) { countApn.push(app._id); // Send to APN if (this.options.apn) { - notification.topic = app.appName; - sendAPN({ userToken: app.token.apn, notification, _removeToken: this._removeToken }); + sendAPN({ userToken: app.token.apn, notification: { topic: app.appName, ...notification }, _removeToken: this.removeToken }); } - } else if (app.token.gcm) { + } else if ('gcm' in app.token && app.token.gcm) { countGcm.push(app._id); // Send to GCM // We do support multiple here - so we should construct an array // and send it bulk - Investigate limit count of id's - if (this.options.gcm && this.options.gcm.apiKey) { + if (this.options.gcm?.apiKey) { sendGCM({ userTokens: app.token.gcm, notification, - _replaceToken: this._replaceToken, - _removeToken: this._removeToken, - options: this.options, + _replaceToken: this.replaceToken, + _removeToken: this.removeToken, + options: this.options as RequiredField, }); } } else { @@ -104,7 +138,13 @@ class PushClass { } } - async sendGatewayPush(gateway, service, token, notification, tries = 0) { + private async sendGatewayPush( + gateway: string, + service: 'apn' | 'gcm', + token: string, + notification: Optional, + tries = 0, + ): Promise { notification.uniqueId = this.options.uniqueId; const options = { @@ -156,32 +196,55 @@ class PushClass { logger.log('Trying sending push to gateway again in', ms, 'milliseconds'); - return setTimeout(() => this.sendGatewayPush(gateway, service, token, notification, tries + 1), ms); + setTimeout(() => this.sendGatewayPush(gateway, service, token, notification, tries + 1), ms); } } - async sendNotificationGateway(app, notification, countApn, countGcm) { + private getGatewayNotificationData(notification: PendingPushNotification): Omit { + // Gateway accepts every attribute from the PendingPushNotification type, except for the priority and apn.topicSuffix + const { priority: _priority, apn, ...notifData } = notification; + const { topicSuffix: _topicSuffix, ...apnData } = apn || ({} as RequiredField['apn']); + + return { + ...notifData, + ...(notification.apn ? { apn: { ...apnData } } : {}), + }; + } + + private async sendNotificationGateway( + app: IAppsTokens, + notification: PendingPushNotification, + countApn: string[], + countGcm: string[], + ): Promise { + if (!this.options.gateways) { + return; + } + + const { topicSuffix = '' } = notification.apn || {}; + + const gatewayNotification = this.getGatewayNotificationData(notification); + for (const gateway of this.options.gateways) { logger.debug('send to token', app.token); - if (app.token.apn) { + if ('apn' in app.token && app.token.apn) { countApn.push(app._id); - notification.topic = app.appName; - return this.sendGatewayPush(gateway, 'apn', app.token.apn, notification); + return this.sendGatewayPush(gateway, 'apn', app.token.apn, { topic: `${app.appName}${topicSuffix}`, ...gatewayNotification }); } - if (app.token.gcm) { + if ('gcm' in app.token && app.token.gcm) { countGcm.push(app._id); - return this.sendGatewayPush(gateway, 'gcm', app.token.gcm, notification); + return this.sendGatewayPush(gateway, 'gcm', app.token.gcm, gatewayNotification); } } } - async sendNotification(notification = { badge: 0 }) { + private async sendNotification(notification: PendingPushNotification): Promise<{ apn: string[]; gcm: string[] }> { logger.debug('Sending notification', notification); - const countApn = []; - const countGcm = []; + const countApn: string[] = []; + const countGcm: string[] = []; if (notification.from !== String(notification.from)) { throw new Error('Push.send: option "from" not a string'); @@ -200,15 +263,18 @@ class PushClass { $or: [{ 'token.apn': { $exists: true } }, { 'token.gcm': { $exists: true } }], }; - await AppsTokens.find(query).forEach((app) => { + const appTokens = AppsTokens.find(query); + + for await (const app of appTokens) { logger.debug('send to token', app.token); - if (this._shouldUseGateway()) { - return this.sendNotificationGateway(app, notification, countApn, countGcm); + if (this.shouldUseGateway()) { + await this.sendNotificationGateway(app, notification, countApn, countGcm); + continue; } - return this.sendNotificationNative(app, notification, countApn, countGcm); - }); + this.sendNotificationNative(app, notification, countApn, countGcm); + } if (settings.get('Log_Level') === '2') { logger.debug(`Sent message "${notification.title}" to ${countApn.length} ios apps ${countGcm.length} android apps`); @@ -238,7 +304,7 @@ class PushClass { // This is a general function to validate that the data added to notifications // is in the correct format. If not this function will throw errors - _validateDocument(notification) { + private _validateDocument(notification: PendingPushNotification): void { // Check the general notification check(notification, { from: String, @@ -250,35 +316,19 @@ class PushClass { sound: Match.Optional(String), notId: Match.Optional(Match.Integer), contentAvailable: Match.Optional(Match.Integer), - forceStart: Match.Optional(Match.Integer), apn: Match.Optional({ - from: Match.Optional(String), - title: Match.Optional(String), - text: Match.Optional(String), - badge: Match.Optional(Match.Integer), - sound: Match.Optional(String), - notId: Match.Optional(Match.Integer), - actions: Match.Optional([Match.Any]), category: Match.Optional(String), + topicSuffix: Match.Optional(String), }), gcm: Match.Optional({ - from: Match.Optional(String), - title: Match.Optional(String), - text: Match.Optional(String), image: Match.Optional(String), style: Match.Optional(String), - summaryText: Match.Optional(String), - picture: Match.Optional(String), - badge: Match.Optional(Match.Integer), - sound: Match.Optional(String), - notId: Match.Optional(Match.Integer), }), - android_channel_id: Match.Optional(String), userId: String, payload: Match.Optional(Object), - delayUntil: Match.Optional(Date), createdAt: Date, createdBy: Match.OneOf(String, null), + priority: Match.Optional(Match.Integer), }); if (!notification.userId) { @@ -286,64 +336,47 @@ class PushClass { } } - async send(options) { - // If on the client we set the user id - on the server we need an option - // set or we default to "" as the creator of the notification - // If current user not set see if we can set it to the logged in user - // this will only run on the client if Meteor.userId is available - const currentUser = options.createdBy || ''; - - // Rig the notification object - const notification = Object.assign( - { - createdAt: new Date(), - createdBy: currentUser, - sent: false, - sending: 0, - }, - _.pick(options, 'from', 'title', 'text', 'userId'), - ); - - // Add extra - Object.assign(notification, _.pick(options, 'payload', 'badge', 'sound', 'notId', 'delayUntil', 'android_channel_id')); - - if (Match.test(options.apn, Object)) { - notification.apn = _.pick(options.apn, 'from', 'title', 'text', 'badge', 'sound', 'notId', 'category'); - } - - if (Match.test(options.gcm, Object)) { - notification.gcm = _.pick( - options.gcm, - 'image', - 'style', - 'summaryText', - 'picture', - 'from', - 'title', - 'text', - 'badge', - 'sound', - 'notId', - 'actions', - 'android_channel_id', - ); - } + private hasApnOptions(options: IPushNotificationConfig): options is RequiredField { + return Match.test(options.apn, Object); + } - if (options.contentAvailable != null) { - notification.contentAvailable = options.contentAvailable; - } + private hasGcmOptions(options: IPushNotificationConfig): options is RequiredField { + return Match.test(options.gcm, Object); + } - if (options.forceStart != null) { - notification.forceStart = options.forceStart; - } + public async send(options: IPushNotificationConfig) { + const notification: PendingPushNotification = { + createdAt: new Date(), + // createdBy is no longer used, but the gateway still expects it + createdBy: '', + sent: false, + sending: 0, + + ...pick(options, 'from', 'title', 'text', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority'), + + ...(this.hasApnOptions(options) + ? { + apn: { + ...pick(options.apn, 'category', 'topicSuffix'), + }, + } + : {}), + ...(this.hasGcmOptions(options) + ? { + gcm: { + ...pick(options.gcm, 'image', 'style'), + }, + } + : {}), + }; // Validate the notification this._validateDocument(notification); try { await this.sendNotification(notification); - } catch (error) { - logger.debug(`Could not send notification id: "${notification._id}", Error: ${error.message}`); + } catch (error: any) { + logger.debug(`Could not send notification to user "${notification.userId}", Error: ${error.message}`); logger.debug(error.stack); } } diff --git a/apps/meteor/app/reactions/client/init.ts b/apps/meteor/app/reactions/client/init.ts index 53fe884febfc..24840b9de7cf 100644 --- a/apps/meteor/app/reactions/client/init.ts +++ b/apps/meteor/app/reactions/client/init.ts @@ -40,6 +40,6 @@ Meteor.startup(() => { return true; }, order: -3, - group: ['message', 'menu'], + group: 'message', }); }); diff --git a/apps/meteor/app/search/server/logger/logger.ts b/apps/meteor/app/search/server/logger/logger.ts index f002e70073d4..ac103d5da06f 100644 --- a/apps/meteor/app/search/server/logger/logger.ts +++ b/apps/meteor/app/search/server/logger/logger.ts @@ -1,3 +1,3 @@ -import { Logger } from '../../../logger/server'; +import { Logger } from '@rocket.chat/logger'; export const SearchLogger = new Logger('Search Logger'); diff --git a/apps/meteor/app/slackbridge/server/RocketAdapter.js b/apps/meteor/app/slackbridge/server/RocketAdapter.js index 2ddeaf21be2b..d0ef8157137d 100644 --- a/apps/meteor/app/slackbridge/server/RocketAdapter.js +++ b/apps/meteor/app/slackbridge/server/RocketAdapter.js @@ -230,11 +230,11 @@ export default class RocketAdapter { } async getChannel(slackMessage) { - return slackMessage.channel ? this.findChannel(slackMessage.channel) || this.addChannel(slackMessage.channel) : null; + return slackMessage.channel ? (await this.findChannel(slackMessage.channel)) || this.addChannel(slackMessage.channel) : null; } async getUser(slackUser) { - return slackUser ? this.findUser(slackUser) || this.addUser(slackUser) : null; + return slackUser ? (await this.findUser(slackUser)) || this.addUser(slackUser) : null; } createRocketID(slackChannel, ts) { @@ -259,7 +259,7 @@ export default class RocketAdapter { } async getRocketUserCreator(slackChannel) { - return slackChannel.creator ? this.findUser(slackChannel.creator) || this.addUser(slackChannel.creator) : null; + return slackChannel.creator ? (await this.findUser(slackChannel.creator)) || this.addUser(slackChannel.creator) : null; } async addChannel(slackChannelID, hasRetried = false) { @@ -271,9 +271,9 @@ export default class RocketAdapter { return; } - const slackChannel = slack.slackAPI.getRoomInfo(slackChannelID); + const slackChannel = await slack.slackAPI.getRoomInfo(slackChannelID); if (slackChannel) { - const members = slack.slackAPI.getMembers(slackChannelID); + const members = await slack.slackAPI.getMembers(slackChannelID); if (!members) { rocketLogger.error('Could not fetch room members'); return; @@ -286,7 +286,7 @@ export default class RocketAdapter { await Rooms.addImportIds(slackChannel.rocketId, slackChannel.id); } else { const rocketUsers = await this.getRocketUsers(members, slackChannel); - const rocketUserCreator = this.getRocketUserCreator(slackChannel); + const rocketUserCreator = await this.getRocketUserCreator(slackChannel); if (!rocketUserCreator) { rocketLogger.error({ msg: 'Could not fetch room creator information', creator: slackChannel.creator }); @@ -296,13 +296,13 @@ export default class RocketAdapter { try { const isPrivate = slackChannel.is_private; const rocketChannel = await createRoom(isPrivate ? 'p' : 'c', slackChannel.name, rocketUserCreator.username, rocketUsers); - rocketChannel.rocketId = rocketChannel.rid; + slackChannel.rocketId = rocketChannel.rid; } catch (e) { if (!hasRetried) { rocketLogger.debug('Error adding channel from Slack. Will retry in 1s.', e.message); // If first time trying to create channel fails, could be because of multiple messages received at the same time. Try again once after 1s. await sleep(1000); - return this.findChannel(slackChannelID) || this.addChannel(slackChannelID, true); + return (await this.findChannel(slackChannelID)) || this.addChannel(slackChannelID, true); } rocketLogger.error(e); } @@ -320,7 +320,6 @@ export default class RocketAdapter { if (slackChannel.purpose && slackChannel.purpose.value && slackChannel.purpose.last_set > lastSetTopic) { roomUpdate.topic = slackChannel.purpose.value; } - await Rooms.addImportIds(slackChannel.rocketId, slackChannel.id); slack.addSlackChannel(slackChannel.rocketId, slackChannelID); } @@ -354,7 +353,7 @@ export default class RocketAdapter { return; } - const user = slack.slackAPI.getUser(slackUserID); + const user = await slack.slackAPI.getUser(slackUserID); if (user) { const rocketUserData = user; const isBot = rocketUserData.is_bot === true; @@ -465,7 +464,7 @@ export default class RocketAdapter { } } else { rocketMsgObj = { - msg: this.convertSlackMsgTxtToRocketTxtFormat(slackMessage.text), + msg: await this.convertSlackMsgTxtToRocketTxtFormat(slackMessage.text), rid: rocketChannel._id, u: { _id: rocketUser._id, diff --git a/apps/meteor/app/slackbridge/server/SlackAPI.js b/apps/meteor/app/slackbridge/server/SlackAPI.js index 404a2bf9edb3..63774024dc7e 100644 --- a/apps/meteor/app/slackbridge/server/SlackAPI.js +++ b/apps/meteor/app/slackbridge/server/SlackAPI.js @@ -13,7 +13,7 @@ export class SlackAPI { types: 'public_channel', exclude_archived: true, limit: 1000, - cursor, + ...(cursor && { cursor }), }, }); const response = await request.json(); @@ -37,7 +37,7 @@ export class SlackAPI { types: 'private_channel', exclude_archived: true, limit: 1000, - cursor, + ...(cursor && { cursor }), }, }); const response = await request.json(); @@ -77,7 +77,7 @@ export class SlackAPI { token: this.apiToken, channel: channelId, limit: MAX_MEMBERS_PER_CALL, - cursor: currentCursor, + ...(currentCursor && { cursor: currentCursor }), }, }); // eslint-disable-next-line no-await-in-loop @@ -96,7 +96,7 @@ export class SlackAPI { async react(data) { const request = await fetch('https://slack.com/api/reactions.add', { method: 'POST', - body: data, + params: data, }); const response = await request.json(); return response && request.status === 200 && response && request.ok; @@ -105,7 +105,7 @@ export class SlackAPI { async removeReaction(data) { const request = await fetch('https://slack.com/api/reactions.remove', { method: 'POST', - body: data, + params: data, }); const response = await request.json(); return response && request.status === 200 && response && request.ok; @@ -114,7 +114,7 @@ export class SlackAPI { async removeMessage(data) { const request = await fetch('https://slack.com/api/chat.delete', { method: 'POST', - body: data, + params: data, }); const response = await request.json(); return response && request.status === 200 && response && request.ok; @@ -123,7 +123,7 @@ export class SlackAPI { async sendMessage(data) { const request = await fetch('https://slack.com/api/chat.postMessage', { method: 'POST', - body: data, + params: data, }); return request.json(); } @@ -131,7 +131,7 @@ export class SlackAPI { async updateMessage(data) { const request = await fetch('https://slack.com/api/chat.update', { method: 'POST', - body: data, + params: data, }); const response = await request.json(); return response && request.status === 200 && response && request.ok; diff --git a/apps/meteor/app/slackbridge/server/SlackAdapter.js b/apps/meteor/app/slackbridge/server/SlackAdapter.js index 38a6a17f784f..d5379c082507 100644 --- a/apps/meteor/app/slackbridge/server/SlackAdapter.js +++ b/apps/meteor/app/slackbridge/server/SlackAdapter.js @@ -3,7 +3,7 @@ import https from 'https'; import url from 'url'; import { Message } from '@rocket.chat/core-services'; -import { Messages, Rooms, Users } from '@rocket.chat/models'; +import { Messages, Rooms, Users, ReadReceipts } from '@rocket.chat/models'; import { RTMClient } from '@slack/rtm-api'; import { Meteor } from 'meteor/meteor'; @@ -362,14 +362,15 @@ export default class SlackAdapter { if (rocketMsg && rocketUser) { const rocketReaction = `:${slackReactionMsg.reaction}:`; + const theReaction = (rocketMsg.reactions || {})[rocketReaction]; // If the Rocket user has already been removed, then this is an echo back from slack - if (rocketMsg.reactions) { - const theReaction = rocketMsg.reactions[rocketReaction]; - if (theReaction) { - if (theReaction.usernames.indexOf(rocketUser.username) === -1) { - return; // Reaction already removed - } + if (rocketMsg.reactions && theReaction) { + if (rocketUser.roles.includes('bot')) { + return; + } + if (theReaction.usernames.indexOf(rocketUser.username) === -1) { + return; // Reaction already removed } } else { // Reaction already removed @@ -669,6 +670,10 @@ export default class SlackAdapter { }); } + createSlackMessageId(ts, channelId) { + return `slack${channelId ? `-${channelId}` : ''}-${ts.replace(/\./g, '-')}`; + } + async postMessage(slackChannel, rocketMessage) { if (slackChannel && slackChannel.id) { let iconUrl = getUserAvatarURL(rocketMessage.u && rocketMessage.u.username); @@ -974,7 +979,7 @@ export default class SlackAdapter { async processShareMessage(rocketChannel, rocketUser, slackMessage, isImporting) { if (slackMessage.file && slackMessage.file.url_private_download !== undefined) { const details = { - message_id: `slack-${slackMessage.ts.replace(/\./g, '-')}`, + message_id: this.createSlackMessageId(slackMessage.ts), name: slackMessage.file.name, size: slackMessage.file.size, type: slackMessage.file.mimetype, @@ -1011,13 +1016,12 @@ export default class SlackAdapter { ], }; - if (!isImporting) { - await Messages.setPinnedByIdAndUserId( - `slack-${slackMessage.attachments[0].channel_id}-${slackMessage.attachments[0].ts.replace(/\./g, '-')}`, - rocketMsgObj.u, - true, - new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), - ); + if (!isImporting && slackMessage.attachments[0].channel_id && slackMessage.attachments[0].ts) { + const messageId = this.createSlackMessageId(slackMessage.attachments[0].ts, slackMessage.attachments[0].channel_id); + await Messages.setPinnedByIdAndUserId(messageId, rocketMsgObj.u, true, new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000)); + if (settings.get('Message_Read_Receipt_Store_Users')) { + await ReadReceipts.setPinnedByMessageId(messageId, true); + } } return rocketMsgObj; @@ -1223,12 +1227,11 @@ export default class SlackAdapter { ], }; - await Messages.setPinnedByIdAndUserId( - `slack-${pin.channel}-${pin.message.ts.replace(/\./g, '-')}`, - msgObj.u, - true, - new Date(parseInt(pin.message.ts.split('.')[0]) * 1000), - ); + const messageId = this.createSlackMessageId(pin.message.ts, pin.channel); + await Messages.setPinnedByIdAndUserId(messageId, msgObj.u, true, new Date(parseInt(pin.message.ts.split('.')[0]) * 1000)); + if (settings.get('Message_Read_Receipt_Store_Users')) { + await ReadReceipts.setPinnedByMessageId(messageId, true); + } } } } diff --git a/apps/meteor/app/slackbridge/server/logger.ts b/apps/meteor/app/slackbridge/server/logger.ts index 4535a6af6b01..6c3586038090 100644 --- a/apps/meteor/app/slackbridge/server/logger.ts +++ b/apps/meteor/app/slackbridge/server/logger.ts @@ -1,4 +1,4 @@ -import { Logger } from '../../logger/server'; +import { Logger } from '@rocket.chat/logger'; const logger = new Logger('SlackBridge'); diff --git a/apps/meteor/app/slashcommands-create/server/server.ts b/apps/meteor/app/slashcommands-create/server/server.ts index a3c70f012fa1..104d50c56926 100644 --- a/apps/meteor/app/slashcommands-create/server/server.ts +++ b/apps/meteor/app/slashcommands-create/server/server.ts @@ -1,6 +1,6 @@ import { api } from '@rocket.chat/core-services'; import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; -import { Rooms } from '@rocket.chat/models'; +import { Rooms, Users } from '@rocket.chat/models'; import { i18n } from '../../../server/lib/i18n'; import { createChannelMethod } from '../../lib/server/methods/createChannel'; @@ -50,7 +50,11 @@ slashCommands.add({ } if (getParams(params).indexOf('private') > -1) { - await createPrivateGroupMethod(userId, channelStr, []); + const user = await Users.findOneById(userId); + if (!user) { + return; + } + await createPrivateGroupMethod(user, channelStr, []); return; } diff --git a/apps/meteor/app/slashcommands-invite/server/server.ts b/apps/meteor/app/slashcommands-invite/server/server.ts index ba1def1538ab..de525d8c6fc6 100644 --- a/apps/meteor/app/slashcommands-invite/server/server.ts +++ b/apps/meteor/app/slashcommands-invite/server/server.ts @@ -1,6 +1,7 @@ import { api } from '@rocket.chat/core-services'; import type { IUser, SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { Subscriptions, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; import { i18n } from '../../../server/lib/i18n'; import { addUsersToRoomMethod } from '../../lib/server/methods/addUsersToRoom'; @@ -57,13 +58,25 @@ slashCommands.add({ }); } + const inviter = await Users.findOneById(userId); + + if (!inviter) { + throw new Meteor.Error('error-user-not-found', 'Inviter not found', { + method: 'slashcommand-invite', + }); + } + await Promise.all( usersFiltered.map(async (user) => { try { - return await addUsersToRoomMethod(userId, { - rid: message.rid, - users: [user.username || ''], - }); + return await addUsersToRoomMethod( + userId, + { + rid: message.rid, + users: [user.username || ''], + }, + inviter, + ); } catch ({ error }: any) { if (typeof error !== 'string') { return; diff --git a/apps/meteor/app/slashcommands-inviteall/server/server.ts b/apps/meteor/app/slashcommands-inviteall/server/server.ts index 9917775aca06..5376bd6ae64b 100644 --- a/apps/meteor/app/slashcommands-inviteall/server/server.ts +++ b/apps/meteor/app/slashcommands-inviteall/server/server.ts @@ -37,6 +37,9 @@ function inviteAll(type: T): SlashCommand['callback'] { } const user = await Users.findOneById(userId); + if (!user) { + return; + } const lng = user?.language || settings.get('Language') || 'en'; const baseChannel = type === 'to' ? await Rooms.findOneById(message.rid) : await Rooms.findOneByName(channel); @@ -69,7 +72,7 @@ function inviteAll(type: T): SlashCommand['callback'] { const users = (await cursor.toArray()).map((s: ISubscription) => s.u.username).filter(isTruthy); if (!targetChannel && ['c', 'p'].indexOf(baseChannel.t) > -1) { - baseChannel.t === 'c' ? await createChannelMethod(userId, channel, users) : await createPrivateGroupMethod(userId, channel, users); + baseChannel.t === 'c' ? await createChannelMethod(userId, channel, users) : await createPrivateGroupMethod(user, channel, users); void api.broadcast('notify.ephemeralMessage', userId, message.rid, { msg: i18n.t('Channel_created', { postProcess: 'sprintf', diff --git a/apps/meteor/app/slashcommands-join/server/server.ts b/apps/meteor/app/slashcommands-join/server/server.ts index dfe27d8d5dc4..33d0278f81a3 100644 --- a/apps/meteor/app/slashcommands-join/server/server.ts +++ b/apps/meteor/app/slashcommands-join/server/server.ts @@ -1,10 +1,9 @@ -import { api } from '@rocket.chat/core-services'; +import { api, Room } from '@rocket.chat/core-services'; import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { i18n } from '../../../server/lib/i18n'; -import { joinRoomMethod } from '../../lib/server/methods/joinRoom'; import { settings } from '../../settings/server'; import { slashCommands } from '../../utils/lib/slashCommand'; @@ -16,13 +15,13 @@ slashCommands.add({ return; } - channel = channel.replace('#', ''); - const room = await Rooms.findOneByNameAndType(channel, 'c'); - if (!userId) { return; } + channel = channel.replace('#', ''); + + const room = await Rooms.findOneByNameAndType(channel, 'c'); if (!room) { void api.broadcast('notify.ephemeralMessage', userId, message.rid, { msg: i18n.t('Channel_doesnt_exist', { @@ -44,7 +43,7 @@ slashCommands.add({ }); } - await joinRoomMethod(userId, room._id); + await Room.join({ room, user: { _id: userId } }); }, options: { description: 'Join_the_given_channel', diff --git a/apps/meteor/app/statistics/server/lib/SAUMonitor.ts b/apps/meteor/app/statistics/server/lib/SAUMonitor.ts index 24bfd10dc5f0..b3aa68337106 100644 --- a/apps/meteor/app/statistics/server/lib/SAUMonitor.ts +++ b/apps/meteor/app/statistics/server/lib/SAUMonitor.ts @@ -1,5 +1,6 @@ import type { ISession, ISessionDevice, ISocketConnectionLogged, IUser } from '@rocket.chat/core-typings'; import { cronJobs } from '@rocket.chat/cron'; +import { Logger } from '@rocket.chat/logger'; import { Sessions, Users } from '@rocket.chat/models'; import mem from 'mem'; import { Meteor } from 'meteor/meteor'; @@ -7,7 +8,6 @@ import UAParser from 'ua-parser-js'; import { getMostImportantRole } from '../../../../lib/roles/getMostImportantRole'; import { getClientAddress } from '../../../../server/lib/getClientAddress'; -import { Logger } from '../../../../server/lib/logger/Logger'; import { aggregates } from '../../../../server/models/raw/Sessions'; import { sauEvents } from '../../../../server/services/sauMonitor/events'; import { UAParserMobile, UAParserDesktop } from './UAParserCustom'; @@ -46,7 +46,7 @@ export class SAUMonitorClass { constructor() { this._started = false; this._dailyComputeJobName = 'aggregate-sessions'; - this._dailyFinishSessionsJobName = 'aggregate-sessions'; + this._dailyFinishSessionsJobName = 'finish-sessions'; } async start(): Promise { @@ -318,33 +318,19 @@ export class SAUMonitorClass { return; } - logger.info('[aggregate] - Aggregating data.'); + const today = new Date(); - const date = new Date(); - date.setDate(date.getDate() - 0); // yesterday - const yesterday = getDateObj(date); + // get sessions from 3 days ago to make sure even if a few cron jobs were skipped, we still have the data + const threeDaysAgo = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 3, 0, 0, 0, 0); - for await (const record of aggregates.dailySessionsOfYesterday(Sessions.col, yesterday)) { - await Sessions.updateOne( - { _id: `${record.userId}-${record.year}-${record.month}-${record.day}` }, - { $set: record }, - { upsert: true }, - ); + const period = { start: getDateObj(threeDaysAgo), end: getDateObj(today) }; + + logger.info({ msg: '[aggregate] - Aggregating data.', period }); + + for await (const record of aggregates.dailySessions(Sessions.col, period)) { + await Sessions.updateDailySessionById(`${record.userId}-${record.year}-${record.month}-${record.day}`, record); } - await Sessions.updateMany( - { - type: 'session', - year: { $lte: yesterday.year }, - month: { $lte: yesterday.month }, - day: { $lte: yesterday.day }, - }, - { - $set: { - type: 'computed-session', - _computedAt: new Date(), - }, - }, - ); + await Sessions.updateAllSessionsByDateToComputed(period); } } diff --git a/apps/meteor/app/statistics/server/lib/statistics.ts b/apps/meteor/app/statistics/server/lib/statistics.ts index d6af563638ba..64543deb88a1 100644 --- a/apps/meteor/app/statistics/server/lib/statistics.ts +++ b/apps/meteor/app/statistics/server/lib/statistics.ts @@ -24,10 +24,12 @@ import { LivechatCustomField, Subscriptions, Users, + LivechatRooms, } from '@rocket.chat/models'; import { MongoInternals } from 'meteor/mongo'; +import moment from 'moment'; -import { getStatistics as getEnterpriseStatistics } from '../../../../ee/app/license/server'; +import { getStatistics as getEnterpriseStatistics } from '../../../../ee/app/license/server/getStatistics'; import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred'; import { isRunningMs } from '../../../../server/lib/isRunningMs'; import { getControl } from '../../../../server/lib/migrations'; @@ -41,8 +43,6 @@ import { getAppsStatistics } from './getAppsStatistics'; import { getImporterStatistics } from './getImporterStatistics'; import { getServicesStatistics } from './getServicesStatistics'; -const wizardFields = ['Organization_Type', 'Industry', 'Size', 'Country', 'Language', 'Server_Type', 'Register_Server']; - const getUserLanguages = async (totalUsers: number): Promise<{ [key: string]: number }> => { const result = await Users.getUserLanguages(); @@ -70,17 +70,29 @@ export const statistics = { const statistics = {} as IStats; const statsPms = []; + const fetchWizardSettingValue = async (settingName: string): Promise => { + return ((await Settings.findOne(settingName))?.value as T | undefined) ?? undefined; + }; + // Setup Wizard - statistics.wizard = {}; - await Promise.all( - wizardFields.map(async (field) => { - const record = await Settings.findOne(field); - if (record) { - const wizardField = field.replace(/_/g, '').replace(field[0], field[0].toLowerCase()); - statistics.wizard[wizardField] = record.value; - } - }), - ); + const [organizationType, industry, size, country, language, serverType, registerServer] = await Promise.all([ + fetchWizardSettingValue('Organization_Type'), + fetchWizardSettingValue('Industry'), + fetchWizardSettingValue('Size'), + fetchWizardSettingValue('Country'), + fetchWizardSettingValue('Language'), + fetchWizardSettingValue('Server_Type'), + fetchWizardSettingValue('Register_Server'), + ]); + statistics.wizard = { + organizationType, + industry, + size, + country, + language, + serverType, + registerServer, + }; // Version const uniqueID = await Settings.findOne('uniqueID'); @@ -89,6 +101,9 @@ export const statistics = { statistics.installedAt = uniqueID.createdAt.toISOString(); } + statistics.deploymentFingerprintHash = settings.get('Deployment_FingerPrint_Hash'); + statistics.deploymentFingerprintVerified = settings.get('Deployment_FingerPrint_Verified'); + if (Info) { statistics.version = Info.version; statistics.tag = Info.tag; @@ -122,11 +137,11 @@ export const statistics = { statistics.totalThreads = await Messages.countThreads(); // livechat visitors - statistics.totalLivechatVisitors = await LivechatVisitors.col.estimatedDocumentCount(); + statistics.totalLivechatVisitors = await LivechatVisitors.estimatedDocumentCount(); // livechat agents statistics.totalLivechatAgents = await Users.countAgents(); - statistics.totalLivechatManagers = await Users.col.countDocuments({ roles: 'livechat-manager' }); + statistics.totalLivechatManagers = await Users.countDocuments({ roles: 'livechat-manager' }); // livechat enabled statistics.livechatEnabled = settings.get('Livechat_enabled'); @@ -147,14 +162,14 @@ export const statistics = { // Number of departments statsPms.push( - LivechatDepartment.col.count().then((count) => { + LivechatDepartment.estimatedDocumentCount().then((count) => { statistics.departments = count; }), ); // Number of archived departments statsPms.push( - LivechatDepartment.col.countDocuments({ archived: true }).then((count) => { + LivechatDepartment.countArchived().then((count) => { statistics.archivedDepartments = count; }), ); @@ -259,6 +274,36 @@ export const statistics = { }), ); + const defaultValue = { contactsCount: 0, conversationsCount: 0, sources: [] }; + const billablePeriod = moment.utc().format('YYYY-MM'); + statsPms.push( + LivechatRooms.getMACStatisticsForPeriod(billablePeriod).then(([result]) => { + statistics.omnichannelContactsBySource = result || defaultValue; + }), + ); + + const monthAgo = moment.utc().subtract(30, 'days').toDate(); + const today = moment.utc().toDate(); + statsPms.push( + LivechatRooms.getMACStatisticsBetweenDates(monthAgo, today).then(([result]) => { + statistics.uniqueContactsOfLastMonth = result || defaultValue; + }), + ); + + const weekAgo = moment.utc().subtract(7, 'days').toDate(); + statsPms.push( + LivechatRooms.getMACStatisticsBetweenDates(weekAgo, today).then(([result]) => { + statistics.uniqueContactsOfLastWeek = result || defaultValue; + }), + ); + + const yesterday = moment.utc().subtract(1, 'days').toDate(); + statsPms.push( + LivechatRooms.getMACStatisticsBetweenDates(yesterday, today).then(([result]) => { + statistics.uniqueContactsOfYesterday = result || defaultValue; + }), + ); + // Message statistics statistics.totalChannelMessages = (await Rooms.findByType('c', { projection: { msgs: 1 } }).toArray()).reduce( function _countChannelMessages(num: number, room: IRoom) { @@ -507,6 +552,15 @@ export const statistics = { statistics.totalWebRTCCalls = settings.get('WebRTC_Calls_Count'); statistics.uncaughtExceptionsCount = settings.get('Uncaught_Exceptions_Count'); + const defaultGateway = (await Settings.findOneById('Push_gateway', { projection: { packageValue: 1 } }))?.packageValue; + + // one bit for each of the following: + const pushEnabled = settings.get('Push_enable') ? 1 : 0; + const pushGatewayEnabled = settings.get('Push_enable_gateway') ? 2 : 0; + const pushGatewayChanged = settings.get('Push_gateway') !== defaultGateway ? 4 : 0; + + statistics.push = pushEnabled | pushGatewayEnabled | pushGatewayChanged; + const defaultHomeTitle = (await Settings.findOneById('Layout_Home_Title'))?.packageValue; statistics.homeTitleChanged = settings.get('Layout_Home_Title') !== defaultHomeTitle; diff --git a/apps/meteor/app/theme/client/imports/general/base.css b/apps/meteor/app/theme/client/imports/general/base.css index 311327fb6f73..d1f4b8d11fb6 100644 --- a/apps/meteor/app/theme/client/imports/general/base.css +++ b/apps/meteor/app/theme/client/imports/general/base.css @@ -52,12 +52,6 @@ body { a { cursor: pointer; - text-decoration: none; - - &:hover, - &:active { - text-decoration: none; - } } button { diff --git a/apps/meteor/app/theme/client/imports/general/base_old.css b/apps/meteor/app/theme/client/imports/general/base_old.css index d5e0372fb626..7f1ede6067fc 100644 --- a/apps/meteor/app/theme/client/imports/general/base_old.css +++ b/apps/meteor/app/theme/client/imports/general/base_old.css @@ -988,10 +988,6 @@ font-size: 12px; font-weight: 300; } - - & div.switch-language { - margin-top: 20px; - } } & .share { @@ -1106,9 +1102,9 @@ } .rc-old .code-colors { - color: #333333; - border-color: #cccccc; - background-color: #f8f8f8; + color: var(--rcx-color-font-default, #1f2329); + border-color: var(--rcx-color-stroke-extra-light, #ebecef); + background-color: var(--rcx-color-surface-neutral, #e4e7ea); } .rc-old .powered-by { diff --git a/apps/meteor/app/theme/client/imports/general/reset.css b/apps/meteor/app/theme/client/imports/general/reset.css index cc8a24b549fd..ad5cbd9cddf2 100644 --- a/apps/meteor/app/theme/client/imports/general/reset.css +++ b/apps/meteor/app/theme/client/imports/general/reset.css @@ -92,8 +92,6 @@ video { border: 0 solid; - font-size: 100%; - &::after, &::before { border: 0 solid; diff --git a/apps/meteor/app/theme/client/index.ts b/apps/meteor/app/theme/client/index.ts index f518de00b591..74f07de1b081 100644 --- a/apps/meteor/app/theme/client/index.ts +++ b/apps/meteor/app/theme/client/index.ts @@ -1,5 +1 @@ import './main.css'; -import './vendor/photoswipe.css'; -import './vendor/fontello/css/fontello.css'; -import './rocketchat.font.css'; -import './mentionLink.css'; diff --git a/apps/meteor/app/theme/client/main.css b/apps/meteor/app/theme/client/main.css index f8f9107e0c8a..6c0a20d844bb 100644 --- a/apps/meteor/app/theme/client/main.css +++ b/apps/meteor/app/theme/client/main.css @@ -21,5 +21,8 @@ /* Legacy theming */ @import 'imports/general/theme_old.css'; -@import '../../theme/client'; +@import './vendor/photoswipe.css'; +@import './vendor/fontello/css/fontello.css'; +@import './rocketchat.font.css'; +@import './mentionLink.css'; @import '../../../node_modules/@rocket.chat/fuselage/dist/fuselage.css'; diff --git a/apps/meteor/app/threads/client/messageAction/follow.ts b/apps/meteor/app/threads/client/messageAction/follow.ts index 21ad8daba901..b4ca1e63e9f9 100644 --- a/apps/meteor/app/threads/client/messageAction/follow.ts +++ b/apps/meteor/app/threads/client/messageAction/follow.ts @@ -18,6 +18,7 @@ Meteor.startup(() => { id: 'follow-message', icon: 'bell', label: 'Follow_message', + type: 'interaction', context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'], async action(_, { message }) { if (!message) { @@ -44,7 +45,7 @@ Meteor.startup(() => { } return user?._id ? !replies.includes(user._id) : false; }, - order: 2, + order: 1, group: 'menu', }); }); diff --git a/apps/meteor/app/threads/client/messageAction/replyInThread.ts b/apps/meteor/app/threads/client/messageAction/replyInThread.ts index 081752beb37b..03f6606a2073 100644 --- a/apps/meteor/app/threads/client/messageAction/replyInThread.ts +++ b/apps/meteor/app/threads/client/messageAction/replyInThread.ts @@ -16,7 +16,7 @@ Meteor.startup(() => { id: 'reply-in-thread', icon: 'thread', label: 'Reply_in_thread', - context: ['message', 'message-mobile', 'videoconf'], + context: ['message', 'message-mobile', 'federated', 'videoconf'], action(e, props) { const { message = messageArgs(this).msg } = props; e.stopPropagation(); @@ -37,7 +37,7 @@ Meteor.startup(() => { return Boolean(subscription); }, order: -1, - group: ['message', 'menu'], + group: 'message', }); }); }); diff --git a/apps/meteor/app/threads/client/messageAction/unfollow.ts b/apps/meteor/app/threads/client/messageAction/unfollow.ts index b76aa0b1189b..853e2adf535f 100644 --- a/apps/meteor/app/threads/client/messageAction/unfollow.ts +++ b/apps/meteor/app/threads/client/messageAction/unfollow.ts @@ -17,6 +17,7 @@ Meteor.startup(() => { id: 'unfollow-message', icon: 'bell-off', label: 'Unfollow_message', + type: 'interaction', context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'], async action(_, { message }) { if (!message) { diff --git a/apps/meteor/app/threads/server/functions.ts b/apps/meteor/app/threads/server/functions.ts index dd6f4ddb8bd4..e79eb72ba0c5 100644 --- a/apps/meteor/app/threads/server/functions.ts +++ b/apps/meteor/app/threads/server/functions.ts @@ -1,6 +1,6 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { isEditedMessage } from '@rocket.chat/core-typings'; -import { Messages, Subscriptions } from '@rocket.chat/models'; +import { Messages, Subscriptions, ReadReceipts } from '@rocket.chat/models'; import { getMentions } from '../../lib/server/lib/notifyUsersOnMessage'; @@ -21,6 +21,7 @@ export async function reply({ tmid }: { tmid?: string }, message: IMessage, pare ]; await Messages.updateRepliesByThreadId(tmid, addToReplies, ts); + await ReadReceipts.setAsThreadById(tmid); const replies = await Messages.getThreadFollowsByThreadId(tmid); diff --git a/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.ts b/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.ts index 5bd378cc3a11..9c970ccf697b 100644 --- a/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.ts +++ b/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.ts @@ -6,6 +6,7 @@ import { Mongo } from 'meteor/mongo'; import { ReactiveVar } from 'meteor/reactive-var'; import type { MinimongoCollection } from '../../../../client/definitions/MinimongoCollection'; +import { baseURI } from '../../../../client/lib/baseURI'; import { getConfig } from '../../../../client/lib/utils/getConfig'; import { isTruthy } from '../../../../lib/isTruthy'; import { withDebouncing } from '../../../../lib/utils/highOrderFunctions'; @@ -34,6 +35,10 @@ const hasUnserializedUpdatedAt = (record: T): record is T & { _updatedAt: Con '_updatedAt' in record && !((record as unknown as { _updatedAt: unknown })._updatedAt instanceof Date); +localforage.config({ + name: baseURI, +}); + export class CachedCollection extends Emitter<{ changed: T; removed: T }> { private static MAX_CACHE_TIME = 60 * 60 * 24 * 30; diff --git a/apps/meteor/app/ui-master/public/icons.svg b/apps/meteor/app/ui-master/public/icons.svg index 4ffaa8296f64..7eb09b8394a1 100644 --- a/apps/meteor/app/ui-master/public/icons.svg +++ b/apps/meteor/app/ui-master/public/icons.svg @@ -87,7 +87,7 @@ - + @@ -286,13 +286,13 @@ - + - + @@ -362,7 +362,7 @@ - + diff --git a/apps/meteor/app/ui-master/public/icons/discussion.svg b/apps/meteor/app/ui-master/public/icons/discussion.svg index f36cd50aa7a4..81bd3b498689 100644 --- a/apps/meteor/app/ui-master/public/icons/discussion.svg +++ b/apps/meteor/app/ui-master/public/icons/discussion.svg @@ -1,3 +1,3 @@ - + diff --git a/apps/meteor/app/ui-master/public/icons/reply-directly.svg b/apps/meteor/app/ui-master/public/icons/reply-directly.svg index 5334f969cc9b..b75848d5e96f 100644 --- a/apps/meteor/app/ui-master/public/icons/reply-directly.svg +++ b/apps/meteor/app/ui-master/public/icons/reply-directly.svg @@ -1,3 +1,3 @@ - + diff --git a/apps/meteor/app/ui-master/public/icons/report.svg b/apps/meteor/app/ui-master/public/icons/report.svg index a0d1db38d62b..69ef87baa9ed 100644 --- a/apps/meteor/app/ui-master/public/icons/report.svg +++ b/apps/meteor/app/ui-master/public/icons/report.svg @@ -1,3 +1,3 @@ - + diff --git a/apps/meteor/app/ui-master/public/icons/thread.svg b/apps/meteor/app/ui-master/public/icons/thread.svg index 7074dcf5cbad..9dbca758a678 100644 --- a/apps/meteor/app/ui-master/public/icons/thread.svg +++ b/apps/meteor/app/ui-master/public/icons/thread.svg @@ -1,3 +1,3 @@ - + diff --git a/apps/meteor/app/ui-master/server/inject.ts b/apps/meteor/app/ui-master/server/inject.ts index 78112bcee343..1e00a0e47433 100644 --- a/apps/meteor/app/ui-master/server/inject.ts +++ b/apps/meteor/app/ui-master/server/inject.ts @@ -32,7 +32,7 @@ const callback: NextHandleFunction = (req, res, next) => { return; } - const injection = headInjections.get(pathname.replace(/^\//, '')) as Injection | undefined; + const injection = headInjections.get(pathname.replace(/^\//, '').split('_')[0]) as Injection | undefined; if (!injection || typeof injection === 'string') { next(); @@ -76,27 +76,37 @@ export const injectIntoHead = (key: string, value: Injection): void => { }; export const addScript = (key: string, content: string): void => { + if (/_/.test(key)) { + throw new Error('inject.js > addScript - key cannot contain "_" (underscore)'); + } + if (!content.trim()) { - injectIntoHead(`${key}.js`, ''); + injectIntoHead(key, ''); return; } const currentHash = crypto.createHash('sha1').update(content).digest('hex'); - injectIntoHead(`${key}.js`, { + + injectIntoHead(key, { type: 'JS', - tag: ``, + tag: ``, content, }); }; export const addStyle = (key: string, content: string): void => { + if (/_/.test(key)) { + throw new Error('inject.js > addStyle - key cannot contain "_" (underscore)'); + } + if (!content.trim()) { - injectIntoHead(`${key}.css`, ''); + injectIntoHead(key, ''); return; } const currentHash = crypto.createHash('sha1').update(content).digest('hex'); - injectIntoHead(`${key}.css`, { + + injectIntoHead(key, { type: 'CSS', - tag: ``, + tag: ``, content, }); }; diff --git a/apps/meteor/app/ui-message/client/ActionManager.js b/apps/meteor/app/ui-message/client/ActionManager.js index fbb6f17a68c9..ebe9d1aed093 100644 --- a/apps/meteor/app/ui-message/client/ActionManager.js +++ b/apps/meteor/app/ui-message/client/ActionManager.js @@ -13,7 +13,7 @@ import { t } from '../../utils/lib/i18n'; const UiKitModal = lazy(() => import('../../../client/views/modal/uikit/UiKitModal')); -const events = new Emitter(); +export const events = new Emitter(); export const on = (...args) => { events.on(...args); @@ -168,6 +168,8 @@ export const handlePayloadUserInteraction = (type, { /* appId,*/ triggerId, ...d export const triggerAction = async ({ type, actionId, appId, rid, mid, viewId, container, tmid, ...rest }) => new Promise(async (resolve, reject) => { + events.emit('busy', { busy: true }); + const triggerId = generateTriggerId(appId); const payload = rest.payload || rest; @@ -190,6 +192,8 @@ export const triggerAction = async ({ type, actionId, appId, rid, mid, viewId, c } catch (e) { reject(e); return {}; + } finally { + events.emit('busy', { busy: false }); } })(); diff --git a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts index 4609797e6bd2..a926f8540d27 100644 --- a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts +++ b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts @@ -48,13 +48,15 @@ export const createComposerAPI = (input: HTMLTextAreaElement, storageID: string) text: string, { selection, + skipFocus, }: { selection?: | { readonly start?: number; readonly end?: number } | ((previous: { readonly start: number; readonly end: number }) => { readonly start?: number; readonly end?: number }); + skipFocus?: boolean; } = {}, ): void => { - focus(); + !skipFocus && focus(); const { selectionStart, selectionEnd } = input; const textAreaTxt = input.value; @@ -66,7 +68,7 @@ export const createComposerAPI = (input: HTMLTextAreaElement, storageID: string) if (selection) { if (!document.execCommand?.('insertText', false, text)) { input.value = textAreaTxt.substring(0, selectionStart) + text + textAreaTxt.substring(selectionStart); - focus(); + !skipFocus && focus(); } input.setSelectionRange(selection.start ?? 0, selection.end ?? text.length); } @@ -78,7 +80,7 @@ export const createComposerAPI = (input: HTMLTextAreaElement, storageID: string) triggerEvent(input, 'input'); triggerEvent(input, 'change'); - focus(); + !skipFocus && focus(); }; const insertText = (text: string): void => { @@ -260,7 +262,9 @@ export const createComposerAPI = (input: HTMLTextAreaElement, storageID: string) const insertNewLine = (): void => insertText('\n'); - setText(Meteor._localStorage.getItem(storageID) ?? ''); + setText(Meteor._localStorage.getItem(storageID) ?? '', { + skipFocus: true, + }); // Gets the text that is connected to the cursor and replaces it with the given text const replaceText = (text: string, selection: { readonly start: number; readonly end: number }): void => { diff --git a/apps/meteor/app/ui-message/client/popup/messagePopupConfig.ts b/apps/meteor/app/ui-message/client/popup/messagePopupConfig.ts index cff8c6d6e5d2..1d220f8bce8d 100644 --- a/apps/meteor/app/ui-message/client/popup/messagePopupConfig.ts +++ b/apps/meteor/app/ui-message/client/popup/messagePopupConfig.ts @@ -41,6 +41,7 @@ Meteor.startup(() => { fields: { 'u.username': 1, 'u.name': 1, + 'u._id': 1, 'ts': 1, }, sort: { ts: -1 }, @@ -52,9 +53,9 @@ Meteor.startup(() => { uniqueMessageUsersControl[username] = true; return notMapped; }) - .forEach(({ u: { username, name }, ts }) => - usersFromRoomMessages.upsert(username, { - _id: username, + .forEach(({ u: { username, name, _id }, ts }) => + usersFromRoomMessages.upsert(_id, { + _id, username, name, ts, diff --git a/apps/meteor/app/ui-utils/client/index.ts b/apps/meteor/app/ui-utils/client/index.ts index aaa5a0825706..6409db5a3592 100644 --- a/apps/meteor/app/ui-utils/client/index.ts +++ b/apps/meteor/app/ui-utils/client/index.ts @@ -2,7 +2,6 @@ import './lib/messageActionDefault'; export { MessageAction } from './lib/MessageAction'; export { messageBox } from './lib/messageBox'; -export { readMessage } from './lib/readMessages'; export { LegacyRoomManager } from './lib/LegacyRoomManager'; export { upsertMessage, RoomHistoryManager } from './lib/RoomHistoryManager'; export { mainReady } from './lib/mainReady'; diff --git a/apps/meteor/app/ui-utils/client/lib/MessageAction.ts b/apps/meteor/app/ui-utils/client/lib/MessageAction.ts index 780e094d7928..043dfe87b606 100644 --- a/apps/meteor/app/ui-utils/client/lib/MessageAction.ts +++ b/apps/meteor/app/ui-utils/client/lib/MessageAction.ts @@ -23,6 +23,8 @@ export type MessageActionContext = | 'search' | 'videoconf-threads'; +type MessageActionType = 'communication' | 'interaction' | 'duplication' | 'apps' | 'management'; + type MessageActionConditionProps = { message: IMessage; user: IUser | undefined; @@ -62,6 +64,7 @@ export type MessageActionConfig = { }, ) => any; condition?: (props: MessageActionConditionProps) => Promise | boolean; + type?: MessageActionType; }; class MessageAction { diff --git a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts index 27ac9d893b15..4e2f0c020a12 100644 --- a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts +++ b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts @@ -13,7 +13,6 @@ import { getConfig } from '../../../../client/lib/utils/getConfig'; import { waitForElement } from '../../../../client/lib/utils/waitForElement'; import { ChatMessage, ChatSubscription } from '../../../models/client'; import { getUserPreference } from '../../../utils/client'; -import { readMessage } from './readMessages'; export async function upsertMessage( { @@ -196,14 +195,12 @@ class RoomHistoryManagerClass extends Emitter { } waitAfterFlush(() => { + this.emit('loaded-messages'); const heightDiff = wrapper.scrollHeight - (previousHeight ?? NaN); wrapper.scrollTop = (scroll ?? NaN) + heightDiff; }); room.isLoading.set(false); - waitAfterFlush(() => { - readMessage.refreshUnreadMark(rid); - }); } public async getMoreNext(rid: IRoom['_id'], atBottomRef: MutableRefObject) { @@ -299,9 +296,8 @@ class RoomHistoryManagerClass extends Emitter { upsertMessageBulk({ msgs: Array.from(result.messages).filter((msg) => msg.t !== 'command'), subscription }); - readMessage.refreshUnreadMark(message.rid); - Tracker.afterFlush(async () => { + this.emit('loaded-messages'); room.isLoading.set(false); }); diff --git a/apps/meteor/app/ui-utils/client/lib/messageActionDefault.ts b/apps/meteor/app/ui-utils/client/lib/messageActionDefault.ts index 0805e723def6..5807673188e5 100644 --- a/apps/meteor/app/ui-utils/client/lib/messageActionDefault.ts +++ b/apps/meteor/app/ui-utils/client/lib/messageActionDefault.ts @@ -9,9 +9,9 @@ import { roomCoordinator } from '../../../../client/lib/rooms/roomCoordinator'; import { dispatchToastMessage } from '../../../../client/lib/toast'; import { messageArgs } from '../../../../client/lib/utils/messageArgs'; import { router } from '../../../../client/providers/RouterProvider'; +import ForwardMessageModal from '../../../../client/views/room/modals/ForwardMessageModal/ForwardMessageModal'; import ReactionList from '../../../../client/views/room/modals/ReactionListModal'; import ReportMessageModal from '../../../../client/views/room/modals/ReportMessageModal'; -import ShareMessageModal from '../../../../client/views/room/modals/ShareMessageModal'; import { hasAtLeastOnePermission, hasPermission } from '../../../authorization/client'; import { ChatRoom, Subscriptions } from '../../../models/client'; import { t } from '../../../utils/lib/i18n'; @@ -29,8 +29,9 @@ Meteor.startup(async () => { id: 'reply-directly', icon: 'reply-directly', label: 'Reply_in_direct_message', - context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'], + context: ['message', 'message-mobile', 'threads', 'federated'], role: 'link', + type: 'communication', action(_, props) { const { message = messageArgs(this).msg } = props; roomCoordinator.openRouteLink( @@ -65,15 +66,16 @@ Meteor.startup(async () => { }); MessageAction.addButton({ - id: 'share-message', + id: 'forward-message', icon: 'arrow-forward', - label: 'Share_Message', + label: 'Forward_message', context: ['message', 'message-mobile', 'threads'], + type: 'communication', async action(_, props) { const { message = messageArgs(this).msg } = props; const permalink = await getPermaLink(message._id); imperativeModal.open({ - component: ShareMessageModal, + component: ForwardMessageModal, props: { message, permalink, @@ -84,7 +86,7 @@ Meteor.startup(async () => { }); }, order: 0, - group: ['message', 'menu'], + group: 'message', }); MessageAction.addButton({ @@ -112,15 +114,16 @@ Meteor.startup(async () => { return true; }, order: -2, - group: ['message', 'menu'], + group: 'message', }); MessageAction.addButton({ id: 'permalink', icon: 'permalink', - label: 'Get_link', + label: 'Copy_link', // classes: 'clipboard', - context: ['message', 'message-mobile', 'threads', 'federated'], + context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'], + type: 'duplication', async action(_, props) { try { const { message = messageArgs(this).msg } = props; @@ -134,16 +137,17 @@ Meteor.startup(async () => { condition({ subscription }) { return !!subscription; }, - order: 4, + order: 5, group: 'menu', }); MessageAction.addButton({ id: 'copy', icon: 'copy', - label: 'Copy', + label: 'Copy_text', // classes: 'clipboard', context: ['message', 'message-mobile', 'threads', 'federated'], + type: 'duplication', async action(_, props) { const { message = messageArgs(this).msg } = props; const msgText = getMainMessageText(message).msg; @@ -153,7 +157,7 @@ Meteor.startup(async () => { condition({ subscription }) { return !!subscription; }, - order: 5, + order: 6, group: 'menu', }); @@ -162,20 +166,21 @@ Meteor.startup(async () => { icon: 'edit', label: 'Edit', context: ['message', 'message-mobile', 'threads', 'federated'], + type: 'management', async action(_, props) { const { message = messageArgs(this).msg, chat } = props; await chat?.messageEditing.editMessage(message); }, - condition({ message, subscription, settings, room }) { + condition({ message, subscription, settings, room, user }) { if (subscription == null) { return false; } if (isRoomFederated(room)) { - return message.u._id === Meteor.userId(); + return message.u._id === user?._id; } const canEditMessage = hasAtLeastOnePermission('edit-message', message.rid); const isEditAllowed = settings.Message_AllowEditing; - const editOwn = message.u && message.u._id === Meteor.userId(); + const editOwn = message.u && message.u._id === user?._id; if (!(canEditMessage || (isEditAllowed && editOwn))) { return false; } @@ -195,7 +200,7 @@ Meteor.startup(async () => { } return true; }, - order: 6, + order: 8, group: 'menu', }); @@ -203,17 +208,18 @@ Meteor.startup(async () => { id: 'delete-message', icon: 'trash', label: 'Delete', - context: ['message', 'message-mobile', 'threads', 'federated'], + context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'], color: 'alert', + type: 'management', async action(this: unknown, _, { message = messageArgs(this).msg, chat }) { await chat?.flows.requestMessageDeletion(message); }, - condition({ message, subscription, room, chat }) { + condition({ message, subscription, room, chat, user }) { if (!subscription) { return false; } if (isRoomFederated(room)) { - return message.u._id === Meteor.userId(); + return message.u._id === user?._id; } const isLivechatRoom = roomCoordinator.isLivechatRoom(room.t); if (isLivechatRoom) { @@ -222,7 +228,7 @@ Meteor.startup(async () => { return chat?.data.canDeleteMessage(message) ?? false; }, - order: 18, + order: 10, group: 'menu', }); @@ -230,8 +236,9 @@ Meteor.startup(async () => { id: 'report-message', icon: 'report', label: 'Report', - context: ['message', 'message-mobile', 'threads', 'federated'], + context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'], color: 'alert', + type: 'management', action(this: unknown, _, { message = messageArgs(this).msg }) { imperativeModal.open({ component: ReportMessageModal, @@ -241,14 +248,15 @@ Meteor.startup(async () => { }, }); }, - condition({ subscription, room }) { + condition({ subscription, room, message, user }) { const isLivechatRoom = roomCoordinator.isLivechatRoom(room.t); - if (isLivechatRoom) { + if (isLivechatRoom || message.u._id === user?._id) { return false; } + return Boolean(subscription); }, - order: 17, + order: 9, group: 'menu', }); @@ -256,7 +264,8 @@ Meteor.startup(async () => { id: 'reaction-list', icon: 'emoji', label: 'Reactions', - context: ['message', 'message-mobile', 'threads'], + context: ['message', 'message-mobile', 'threads', 'videoconf', 'videoconf-threads'], + type: 'interaction', action(this: unknown, _, { message: { reactions = {} } = messageArgs(this).msg }) { imperativeModal.open({ component: ReactionList, @@ -266,7 +275,7 @@ Meteor.startup(async () => { condition({ message: { reactions } }) { return !!reactions; }, - order: 18, + order: 9, group: 'menu', }); }); diff --git a/apps/meteor/app/ui-utils/client/lib/readMessages.ts b/apps/meteor/app/ui-utils/client/lib/readMessages.ts deleted file mode 100644 index 2d3951fef053..000000000000 --- a/apps/meteor/app/ui-utils/client/lib/readMessages.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { IRoom } from '@rocket.chat/core-typings'; -import { Emitter } from '@rocket.chat/emitter'; -import { Meteor } from 'meteor/meteor'; - -import { RoomManager } from '../../../../client/lib/RoomManager'; -import { ChatSubscription, ChatMessage } from '../../../models/client'; -import { sdk } from '../../../utils/client/lib/SDKClient'; -import { LegacyRoomManager } from './LegacyRoomManager'; -import { RoomHistoryManager } from './RoomHistoryManager'; - -class ReadMessage extends Emitter { - protected enabled: boolean; - - protected debug = false; - - constructor() { - super(); - this.enable(); - } - - protected log(...args: any[]) { - return this.debug && console.log(...args); - } - - public enable() { - this.enabled = document.hasFocus(); - } - - public disable() { - this.enabled = false; - } - - public isEnable() { - return this.enabled === true; - } - - public read(rid: IRoom['_id'] | undefined = RoomManager.opened) { - if (!this.enabled) { - this.log('readMessage -> readNow canceled by enabled: false'); - return; - } - - if (!rid) { - this.log('readMessage -> readNow canceled by rid: undefined'); - return; - } - - const subscription = ChatSubscription.findOne({ rid }); - if (!subscription) { - this.log('readMessage -> readNow canceled, no subscription found for rid:', rid); - return; - } - - if (subscription.alert === false && subscription.unread === 0) { - this.log('readMessage -> readNow canceled, alert', subscription.alert, 'and unread', subscription.unread); - return; - } - - const room = LegacyRoomManager.getOpenedRoomByRid(rid); - if (!room) { - this.log('readMessage -> readNow canceled, no room found for typeName:', subscription.t + subscription.name); - return; - } - - // Only read messages if user saw the first unread message - const unreadMark = document.querySelector('.message.first-unread, .rcx-message-divider--unread'); - if (unreadMark) { - const visible = unreadMark.offsetTop >= 0; - - if (!visible) { - this.log('readMessage -> readNow canceled, unread mark visible:', visible); - return; - } - // if unread mark is not visible and there is more more not loaded unread messages - } else if (RoomHistoryManager.getRoom(rid).unreadNotLoaded.get() > 0) { - return; - } - - return this.readNow(rid); - } - - public readNow(rid: IRoom['_id'] | undefined = RoomManager.opened) { - if (!rid) { - this.log('readMessage -> readNow canceled, no rid informed'); - return; - } - - const subscription = ChatSubscription.findOne({ rid }); - if (!subscription) { - this.log('readMessage -> readNow canceled, no subscription found for rid:', rid); - return; - } - - return sdk.rest.post('/v1/subscriptions.read', { rid }).then(() => { - RoomHistoryManager.getRoom(rid).unreadNotLoaded.set(0); - return this.emit(rid); - }); - } - - public refreshUnreadMark(rid: IRoom['_id']) { - if (!rid) { - return; - } - - const subscription = ChatSubscription.findOne({ rid }, { reactive: false }); - if (!subscription) { - return; - } - - const room = LegacyRoomManager.getOpenedRoomByRid(rid); - if (!room) { - return; - } - - if (!subscription.alert && subscription.unread === 0) { - document.querySelector('.message.first-unread')?.classList.remove('first-unread'); - room.unreadSince.set(undefined); - return; - } - - let lastReadRecord = ChatMessage.findOne( - { - rid: subscription.rid, - ts: { - $lt: subscription.ls, - }, - }, - { - sort: { - ts: -1, - }, - }, - ) as { ts: Date } | undefined; - const { unreadNotLoaded } = RoomHistoryManager.getRoom(rid); - - if (!lastReadRecord && unreadNotLoaded.get() === 0) { - lastReadRecord = { ts: new Date(0) }; - } - - room.unreadSince.set(lastReadRecord || unreadNotLoaded.get() > 0 ? subscription.ls : undefined); - - if (!lastReadRecord) { - return; - } - - const firstUnreadRecord = ChatMessage.findOne( - { - 'rid': subscription.rid, - 'ts': { - $gt: lastReadRecord.ts, - }, - 'u._id': { - $ne: Meteor.userId() ?? undefined, - }, - }, - { - sort: { - ts: 1, - }, - }, - ); - - if (firstUnreadRecord) { - room.unreadFirstId = firstUnreadRecord._id; - document.querySelector('.message.first-unread')?.classList.remove('first-unread'); - document.querySelector(`.message[data-id="${firstUnreadRecord._id}"]`)?.classList.add('first-unread'); - } - } -} - -export const readMessage = new ReadMessage(); diff --git a/apps/meteor/app/ui/client/lib/ChatMessages.ts b/apps/meteor/app/ui/client/lib/ChatMessages.ts index fad3625d0e2f..4a4b04f11833 100644 --- a/apps/meteor/app/ui/client/lib/ChatMessages.ts +++ b/apps/meteor/app/ui/client/lib/ChatMessages.ts @@ -12,11 +12,13 @@ import { replyBroadcast } from '../../../../client/lib/chats/flows/replyBroadcas import { requestMessageDeletion } from '../../../../client/lib/chats/flows/requestMessageDeletion'; 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, clearHighlightMessage, } from '../../../../client/views/room/MessageList/providers/messageHighlightSubscription'; +import * as ActionManager from '../../../ui-message/client/ActionManager'; import { UserAction } from './UserAction'; type DeepWritable = T extends (...args: any) => any @@ -37,8 +39,12 @@ export class ChatMessages implements ChatAPI { public data: DataAPI; + public readStateManager: ReadStateManager; + public uploads: UploadsAPI; + public ActionManager: any; + public userCard: { open(username: string): (event: UIEvent) => void; close(): void }; public emojiPicker: { @@ -144,11 +150,14 @@ export class ChatMessages implements ChatAPI { this.uid = params.uid; this.data = createDataAPI({ rid, tmid }); this.uploads = createUploadsAPI({ rid, tmid }); + this.ActionManager = ActionManager; const unimplemented = () => { throw new Error('Flow is not implemented'); }; + this.readStateManager = new ReadStateManager(rid); + this.userCard = { open: unimplemented, close: unimplemented, diff --git a/apps/meteor/app/utils/client/getURL.ts b/apps/meteor/app/utils/client/getURL.ts index 91ef0989bd19..040b6dfa9dc2 100644 --- a/apps/meteor/app/utils/client/getURL.ts +++ b/apps/meteor/app/utils/client/getURL.ts @@ -1,13 +1,19 @@ import { settings } from '../../settings/client'; import { getURLWithoutSettings } from '../lib/getURL'; +import { Info } from '../rocketchat.info'; export const getURL = function ( path: string, // eslint-disable-next-line @typescript-eslint/naming-convention params: Record = {}, cloudDeepLinkUrl?: string, + cacheKey?: boolean, ): string { const cdnPrefix = settings.get('CDN_PREFIX') || ''; const siteUrl = settings.get('Site_Url') || ''; + if (cacheKey) { + path += `${path.includes('?') ? '&' : '?'}cacheKey=${Info.version}`; + } + return getURLWithoutSettings(path, params, cdnPrefix, siteUrl, cloudDeepLinkUrl); }; diff --git a/apps/meteor/app/utils/lib/getDefaultSubscriptionPref.ts b/apps/meteor/app/utils/lib/getDefaultSubscriptionPref.ts index a388548c18a8..adb4c2ab1ae9 100644 --- a/apps/meteor/app/utils/lib/getDefaultSubscriptionPref.ts +++ b/apps/meteor/app/utils/lib/getDefaultSubscriptionPref.ts @@ -1,4 +1,4 @@ -import type { ISubscription, IUser } from '@rocket.chat/core-typings'; +import type { AtLeast, ISubscription, IUser } from '@rocket.chat/core-typings'; /** * @type {(userPref: Pick) => { @@ -7,7 +7,7 @@ import type { ISubscription, IUser } from '@rocket.chat/core-typings'; * emailPrefOrigin: 'user'; * }} */ -export const getDefaultSubscriptionPref = (userPref: IUser) => { +export const getDefaultSubscriptionPref = (userPref: AtLeast) => { const subscription: Partial = {}; const { desktopNotifications, pushNotifications, emailNotificationMode, highlights } = userPref.settings?.preferences || {}; diff --git a/apps/meteor/app/utils/lib/i18n.ts b/apps/meteor/app/utils/lib/i18n.ts index 309585ee284b..13d5c667709d 100644 --- a/apps/meteor/app/utils/lib/i18n.ts +++ b/apps/meteor/app/utils/lib/i18n.ts @@ -5,9 +5,7 @@ import { isObject } from '../../../lib/utils/isObject'; export const i18n = i18next.use(sprintf); -export const addSprinfToI18n = function (t: (typeof i18n)['t']): typeof t & { - (key: string, ...replaces: any): string; -} { +export const addSprinfToI18n = function (t: (typeof i18n)['t']) { return function (key: string, ...replaces: any): string { if (replaces[0] === undefined || isObject(replaces[0])) { return t(key, ...replaces); diff --git a/apps/meteor/app/utils/lib/templateVarHandler.ts b/apps/meteor/app/utils/lib/templateVarHandler.ts index 54f28ebee7a5..cf8279930bda 100644 --- a/apps/meteor/app/utils/lib/templateVarHandler.ts +++ b/apps/meteor/app/utils/lib/templateVarHandler.ts @@ -1,4 +1,4 @@ -import { Logger } from '../../../server/lib/logger/Logger'; +import { Logger } from '@rocket.chat/logger'; const logger = new Logger('TemplateVarHandler'); diff --git a/apps/meteor/app/utils/rocketchat.info b/apps/meteor/app/utils/rocketchat.info index a5d2fcb9bc57..b9e235456291 100644 --- a/apps/meteor/app/utils/rocketchat.info +++ b/apps/meteor/app/utils/rocketchat.info @@ -1,3 +1,3 @@ { - "version": "6.4.0-develop" + "version": "6.5.0-develop" } diff --git a/apps/meteor/app/version-check/server/functions/getNewUpdates.ts b/apps/meteor/app/version-check/server/functions/getNewUpdates.ts index ac0c0e443453..d17191a09be7 100644 --- a/apps/meteor/app/version-check/server/functions/getNewUpdates.ts +++ b/apps/meteor/app/version-check/server/functions/getNewUpdates.ts @@ -50,18 +50,16 @@ export const getNewUpdates = async () => { infoUrl: String, }), ], - alerts: [ - Match.Optional([ - Match.ObjectIncluding({ - id: String, - title: String, - text: String, - textArguments: [Match.Any], - modifiers: [String] as [StringConstructor], - infoUrl: String, - }), - ]), - ], + alerts: Match.Optional([ + Match.ObjectIncluding({ + id: String, + title: String, + text: String, + textArguments: [Match.Any], + modifiers: [String] as [StringConstructor], + infoUrl: String, + }), + ]), }), ); diff --git a/apps/meteor/app/version-check/server/logger.ts b/apps/meteor/app/version-check/server/logger.ts index dc35a0b0d50d..b12e06d9a14e 100644 --- a/apps/meteor/app/version-check/server/logger.ts +++ b/apps/meteor/app/version-check/server/logger.ts @@ -1,3 +1,3 @@ -import { Logger } from '../../logger/server'; +import { Logger } from '@rocket.chat/logger'; export default new Logger('VersionCheck'); diff --git a/apps/meteor/app/webdav/server/methods/uploadFileToWebdav.ts b/apps/meteor/app/webdav/server/methods/uploadFileToWebdav.ts index ed92d15c8d26..8a2e1badd27d 100644 --- a/apps/meteor/app/webdav/server/methods/uploadFileToWebdav.ts +++ b/apps/meteor/app/webdav/server/methods/uploadFileToWebdav.ts @@ -1,9 +1,9 @@ import { MeteorError } from '@rocket.chat/core-services'; import type { IWebdavAccount } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; import type { ServerMethods, TranslationKey } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; -import { Logger } from '../../../logger/server'; import { settings } from '../../../settings/server'; import { uploadFileToWebdav } from '../lib/uploadFileToWebdav'; diff --git a/apps/meteor/client/components/ActionManagerBusyState.tsx b/apps/meteor/client/components/ActionManagerBusyState.tsx new file mode 100644 index 000000000000..0374254a7de9 --- /dev/null +++ b/apps/meteor/client/components/ActionManagerBusyState.tsx @@ -0,0 +1,51 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React, { useEffect, useState } from 'react'; + +import { useUiKitActionManager } from '../hooks/useUiKitActionManager'; + +const ActionManagerBusyState = () => { + const t = useTranslation(); + const actionManager = useUiKitActionManager(); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!actionManager) { + return; + } + + actionManager.on('busy', ({ busy }: { busy: boolean }) => setBusy(busy)); + + return () => { + actionManager.off('busy'); + }; + }, [actionManager]); + + if (busy) { + return ( + + {t('Loading')} + + ); + } + + return null; +}; + +export default ActionManagerBusyState; diff --git a/apps/meteor/client/components/AutoCompleteDepartmentMultiple.tsx b/apps/meteor/client/components/AutoCompleteDepartmentMultiple.tsx index 50d53da351bc..38aadf0a840b 100644 --- a/apps/meteor/client/components/AutoCompleteDepartmentMultiple.tsx +++ b/apps/meteor/client/components/AutoCompleteDepartmentMultiple.tsx @@ -17,7 +17,7 @@ type AutoCompleteDepartmentMultipleProps = { }; const AutoCompleteDepartmentMultiple = ({ - value, + value = [], onlyMyDepartments = false, showArchived = false, enabled = false, @@ -37,6 +37,11 @@ const AutoCompleteDepartmentMultiple = ({ const { phase: departmentsPhase, items: departmentsItems, itemCount: departmentsTotal } = useRecordList(departmentsList); + const departmentOptions = useMemo(() => { + const pending = value.filter(({ value }) => !departmentsItems.find((dep) => dep.value === value)) || []; + return [...departmentsItems, ...pending]; + }, [departmentsItems, value]); + return ( { const t = useTranslation(); - return ; + return ; }; export default memo(ContextualbarClose); diff --git a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx index 4f62e0684647..5a0ab9555939 100644 --- a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx +++ b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx @@ -1,8 +1,23 @@ import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; -import { Modal, Field, FieldGroup, ToggleSwitch, TextInput, TextAreaInput, Button, Icon, Box } from '@rocket.chat/fuselage'; +import { + Modal, + Field, + FieldGroup, + ToggleSwitch, + TextInput, + TextAreaInput, + Button, + Icon, + Box, + FieldHint, + FieldLabel, + FieldRow, + FieldError, +} from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; -import type { ComponentProps, ReactElement } from 'react'; +import type { ReactElement } from 'react'; import React from 'react'; import { useForm, Controller } from 'react-hook-form'; @@ -30,12 +45,12 @@ const CreateDiscussion = ({ onClose, defaultParentRoom, parentMessageId, nameSug const t = useTranslation(); const { - register, - formState: { isDirty, errors }, + formState: { isDirty, isSubmitting, isValidating, errors }, handleSubmit, control, watch, } = useForm({ + mode: 'onBlur', defaultValues: { name: nameSuggestion || '', parentRoom: '', @@ -67,23 +82,29 @@ const CreateDiscussion = ({ onClose, defaultParentRoom, parentMessageId, nameSug }); }; + const targetChannelField = useUniqueId(); + const encryptedField = useUniqueId(); + const discussionField = useUniqueId(); + const usersField = useUniqueId(); + const firstMessageField = useUniqueId(); + return ( ) => } + wrapperFunction={(props) => } > {t('Discussion_title')} - + + {t('Discussion_description')} - {t('Discussion_description')} - - - {t('Discussion_target_channel')} - + + {t('Discussion_target_channel')} + + {defaultParentRoom && ( } /> )} - {!defaultParentRoom && ( ( + rules={{ required: t('error-the-field-is-required', { field: t('Discussion_target_channel') }) }} + render={({ field: { name, onBlur, onChange, value } }) => ( )} /> )} - - {errors.parentRoom && {errors.parentRoom.message}} + + {errors.parentRoom && ( + + {errors.parentRoom.message} + + )} - - - {t('Encrypted')} - - ( - + + {t('Encrypted')} + + } /> - )} - /> + + - {t('Discussion_name')} - - } + + {t('Discussion_name')} + + + ( + } + /> + )} /> - - {errors.name && {errors.name.message}} + + {errors.name && ( + + {errors.name.message} + + )} - {t('Invite_Users')} - + {t('Invite_Users')} + ( - + render={({ field: { name, onChange, value, onBlur } }) => ( + )} /> - + - {t('Discussion_first_message_title')} - - - - {encrypted && {t('Discussion_first_message_disabled_due_to_e2e')}} + {t('Discussion_first_message_title')} + + ( + + )} + /> + + {encrypted && {t('Discussion_first_message_disabled_due_to_e2e')}} - diff --git a/apps/meteor/client/components/CreateDiscussion/DefaultParentRoomField.tsx b/apps/meteor/client/components/CreateDiscussion/DefaultParentRoomField.tsx index 0a2717a65552..6036f14049a4 100644 --- a/apps/meteor/client/components/CreateDiscussion/DefaultParentRoomField.tsx +++ b/apps/meteor/client/components/CreateDiscussion/DefaultParentRoomField.tsx @@ -1,38 +1,42 @@ import { Skeleton, TextInput, Callout } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; +import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; -import { AsyncStatePhase } from '../../hooks/useAsyncState'; -import { useEndpointData } from '../../hooks/useEndpointData'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; const DefaultParentRoomField = ({ defaultParentRoom }: { defaultParentRoom: string }): ReactElement => { const t = useTranslation(); - const { value, phase } = useEndpointData('/v1/rooms.info', { - params: useMemo( - () => ({ - roomId: defaultParentRoom, - }), - [defaultParentRoom], - ), + + const query = useMemo( + () => ({ + roomId: defaultParentRoom, + }), + [defaultParentRoom], + ); + + const roomsInfoEndpoint = useEndpoint('GET', '/v1/rooms.info'); + + const { data, isLoading, isError } = useQuery(['defaultParentRoomInfo', query], async () => roomsInfoEndpoint(query), { + refetchOnWindowFocus: false, }); - if (phase === AsyncStatePhase.LOADING) { + if (isLoading) { return ; } - if (!value || !value.room) { + if (!data?.room || isError) { return {t('Error')}; } return ( diff --git a/apps/meteor/client/components/FingerprintChangeModal.tsx b/apps/meteor/client/components/FingerprintChangeModal.tsx new file mode 100644 index 000000000000..db4c33654a92 --- /dev/null +++ b/apps/meteor/client/components/FingerprintChangeModal.tsx @@ -0,0 +1,44 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import GenericModal from './GenericModal'; + +type FingerprintChangeModalProps = { + onConfirm: () => void; + onCancel: () => void; + onClose: () => void; +}; + +const FingerprintChangeModal = ({ onConfirm, onCancel, onClose }: FingerprintChangeModalProps): ReactElement => { + const t = useTranslation(); + return ( + + + + + ); +}; + +export default FingerprintChangeModal; diff --git a/apps/meteor/client/components/FingerprintChangeModalConfirmation.tsx b/apps/meteor/client/components/FingerprintChangeModalConfirmation.tsx new file mode 100644 index 000000000000..77718de0f441 --- /dev/null +++ b/apps/meteor/client/components/FingerprintChangeModalConfirmation.tsx @@ -0,0 +1,47 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import GenericModal from './GenericModal'; + +type FingerprintChangeModalConfirmationProps = { + onConfirm: () => void; + onCancel: () => void; + newWorkspace: boolean; +}; + +const FingerprintChangeModalConfirmation = ({ + onConfirm, + onCancel, + newWorkspace, +}: FingerprintChangeModalConfirmationProps): ReactElement => { + const t = useTranslation(); + return ( + + + + + ); +}; + +export default FingerprintChangeModalConfirmation; diff --git a/apps/meteor/client/components/GazzodownText.tsx b/apps/meteor/client/components/GazzodownText.tsx index 7912e44ad925..76868f84e3af 100644 --- a/apps/meteor/client/components/GazzodownText.tsx +++ b/apps/meteor/client/components/GazzodownText.tsx @@ -49,6 +49,7 @@ const GazzodownText = ({ mentions, channels, searchText, children }: GazzodownTe const useEmoji = Boolean(useUserPreference('useEmojis')); const useRealName = Boolean(useSetting('UI_Use_Real_Name')); const ownUserId = useUserId(); + const showMentionSymbol = Boolean(useUserPreference('mentionsWithSymbol')); const chat = useChat(); @@ -122,6 +123,7 @@ const GazzodownText = ({ mentions, channels, searchText, children }: GazzodownTe useRealName, isMobile, ownUserId, + showMentionSymbol, }} > {children} diff --git a/apps/meteor/client/components/GenericMenu/GenericMenu.tsx b/apps/meteor/client/components/GenericMenu/GenericMenu.tsx index e02d6dc4e746..f660b4b85f35 100644 --- a/apps/meteor/client/components/GenericMenu/GenericMenu.tsx +++ b/apps/meteor/client/components/GenericMenu/GenericMenu.tsx @@ -1,5 +1,4 @@ -import type { IconButton } from '@rocket.chat/fuselage'; -import { MenuItem, MenuSection, MenuV2 } from '@rocket.chat/fuselage'; +import { IconButton, MenuItem, MenuSection, MenuV2 } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps, ReactNode } from 'react'; import React from 'react'; @@ -43,6 +42,12 @@ const GenericMenu = ({ title, icon = 'menu', onAction, ...props }: GenericMenuPr const handleItems = (items: GenericMenuItemProps[]) => hasIcon ? items.map((item) => ({ ...item, gap: !item.icon && !item.status })) : items; + const isMenuEmpty = !(sections && sections.length > 0) && !(items && items.length > 0); + + if (isMenuEmpty) { + return ; + } + return ( <> {sections && ( diff --git a/apps/meteor/client/components/GenericModal/GenericModal.tsx b/apps/meteor/client/components/GenericModal/GenericModal.tsx index bf5d808f5b5b..8ae487642b92 100644 --- a/apps/meteor/client/components/GenericModal/GenericModal.tsx +++ b/apps/meteor/client/components/GenericModal/GenericModal.tsx @@ -1,4 +1,5 @@ import { Button, Modal } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import type { Keys as IconName } from '@rocket.chat/icons'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { FC, ComponentProps, ReactElement, ReactNode } from 'react'; @@ -73,16 +74,17 @@ const GenericModal: FC = ({ ...props }) => { const t = useTranslation(); + const genericModalId = useUniqueId(); return ( - + {renderIcon(icon, variant)} {tagline && {tagline}} - {title ?? t('Are_you_sure')} + {title ?? t('Are_you_sure')} - + {children} diff --git a/apps/meteor/client/components/GenericUpsellModal/GenericUpsellModal.tsx b/apps/meteor/client/components/GenericUpsellModal/GenericUpsellModal.tsx index 98621c82b27b..513b31dd81fa 100644 --- a/apps/meteor/client/components/GenericUpsellModal/GenericUpsellModal.tsx +++ b/apps/meteor/client/components/GenericUpsellModal/GenericUpsellModal.tsx @@ -42,13 +42,13 @@ const GenericUpsellModal = ({ {icon && } - {tagline ?? t('Enterprise_capability')} + {tagline ?? t('Premium_capability')} {title} - + - + {subtitle && ( {subtitle} diff --git a/apps/meteor/client/components/GenericUpsellModal/hooks/useUpsellActions.ts b/apps/meteor/client/components/GenericUpsellModal/hooks/useUpsellActions.ts index 70dfcaf31533..0c3b111630ca 100644 --- a/apps/meteor/client/components/GenericUpsellModal/hooks/useUpsellActions.ts +++ b/apps/meteor/client/components/GenericUpsellModal/hooks/useUpsellActions.ts @@ -1,4 +1,4 @@ -import { useRouter, useSetModal, useCurrentModal, useSetting } from '@rocket.chat/ui-contexts'; +import { useRouter, useSetModal, useSetting } from '@rocket.chat/ui-contexts'; import { useCallback } from 'react'; import { useExternalLink } from '../../../hooks/useExternalLink'; @@ -10,7 +10,6 @@ export const useUpsellActions = (hasLicenseModule = false) => { const router = useRouter(); const setModal = useSetModal(); const handleOpenLink = useExternalLink(); - const isModalOpen = useCurrentModal() !== null; const cloudWorkspaceHadTrial = useSetting('Cloud_Workspace_Had_Trial'); const { data } = useIsEnterprise(); @@ -26,5 +25,5 @@ export const useUpsellActions = (hasLicenseModule = false) => { setModal(null); }, [handleOpenLink, setModal]); - return { isModalOpen, shouldShowUpsell, cloudWorkspaceHadTrial, handleGoFullyFeatured, handleTalkToSales }; + return { shouldShowUpsell, cloudWorkspaceHadTrial, handleGoFullyFeatured, handleTalkToSales }; }; diff --git a/apps/meteor/client/components/Omnichannel/Tags.tsx b/apps/meteor/client/components/Omnichannel/Tags.tsx index 39564ca7f89f..88f5f1a5c6e7 100644 --- a/apps/meteor/client/components/Omnichannel/Tags.tsx +++ b/apps/meteor/client/components/Omnichannel/Tags.tsx @@ -1,4 +1,4 @@ -import { Field, TextInput, Chip, Button } from '@rocket.chat/fuselage'; +import { TextInput, Chip, Button, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import type { ChangeEvent, ReactElement } from 'react'; @@ -71,12 +71,12 @@ const Tags = ({ tags = [], handler, error, tagRequired, department }: TagsProps) return ( <> - + {t('Tags')} - + {EETagsComponent && tagsResult?.tags && tagsResult?.tags.length ? ( - + { @@ -85,10 +85,10 @@ const Tags = ({ tags = [], handler, error, tagRequired, department }: TagsProps) department={department} viewAll={!department} /> - + ) : ( <> - + {t('Add')} - + )} {customTags.length > 0 && ( - + {customTags?.map((tag, i) => ( removeTag(tag)} mie={8}> {tag} ))} - + )} ); diff --git a/apps/meteor/client/components/Omnichannel/hooks/useDepartmentsList.ts b/apps/meteor/client/components/Omnichannel/hooks/useDepartmentsList.ts index 3b39ea79d42f..fd3c0a29effe 100644 --- a/apps/meteor/client/components/Omnichannel/hooks/useDepartmentsList.ts +++ b/apps/meteor/client/components/Omnichannel/hooks/useDepartmentsList.ts @@ -20,7 +20,6 @@ type DepartmentListItem = { _id: string; label: string; value: string; - _updatedAt: Date; }; export const useDepartmentsList = ( @@ -66,7 +65,6 @@ export const useDepartmentsList = ( _id, label: department.archived ? `${name} [${t('Archived')}]` : name, value: _id, - _updatedAt: new Date(_updatedAt || ''), }; }); @@ -75,7 +73,6 @@ export const useDepartmentsList = ( _id: '', label: t('All'), value: 'all', - _updatedAt: new Date(), }); options.haveNone && @@ -83,7 +80,6 @@ export const useDepartmentsList = ( _id: '', label: t('None'), value: '', - _updatedAt: new Date(), }); return { diff --git a/apps/meteor/client/components/Omnichannel/hooks/useLivechatTags.ts b/apps/meteor/client/components/Omnichannel/hooks/useLivechatTags.ts index 4bd85be40342..ce5704b66482 100644 --- a/apps/meteor/client/components/Omnichannel/hooks/useLivechatTags.ts +++ b/apps/meteor/client/components/Omnichannel/hooks/useLivechatTags.ts @@ -1,6 +1,8 @@ import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; +import { useOmnichannel } from '../../../hooks/omnichannel/useOmnichannel'; + type Props = { department?: string; text?: string; @@ -9,13 +11,19 @@ type Props = { export const useLivechatTags = (options: Props) => { const getTags = useEndpoint('GET', '/v1/livechat/tags'); + const { isEnterprise } = useOmnichannel(); const { department, text, viewAll } = options; - return useQuery(['/v1/livechat/tags', text, department], () => - getTags({ - text: text || '', - ...(department && { department }), - viewAll: viewAll ? 'true' : 'false', - }), + return useQuery( + ['/v1/livechat/tags', text, department], + () => + getTags({ + text: text || '', + ...(department && { department }), + viewAll: viewAll ? 'true' : 'false', + }), + { + enabled: isEnterprise, + }, ); }; diff --git a/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx b/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx index 943c2c33a5fe..67d650186680 100644 --- a/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx @@ -1,5 +1,18 @@ import type { ILivechatDepartment } from '@rocket.chat/core-typings'; -import { Field, FieldGroup, Button, TextInput, Modal, Box, CheckBox, Divider, EmailInput } from '@rocket.chat/fuselage'; +import { + Field, + FieldGroup, + Button, + TextInput, + Modal, + Box, + CheckBox, + Divider, + EmailInput, + FieldLabel, + FieldRow, + FieldError, +} from '@rocket.chat/fuselage'; import { usePermission, useSetting, useTranslation, useUserPreference } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { useCallback, useState, useEffect, useMemo } from 'react'; @@ -68,7 +81,7 @@ const CloseChatModal = ({ }; const requestData = transcriptEmail && visitorEmail ? { email: visitorEmail, subject } : undefined; - if (!comment && commentRequired) { + if (!comment?.trim() && commentRequired) { setError('comment', { type: 'custom', message: t('The_field_is_required', t('Comment')) }); } @@ -89,7 +102,7 @@ const CloseChatModal = ({ const cannotSubmit = useMemo(() => { const cannotSendTag = (tagRequired && !tags?.length) || errors.tags; - const cannotSendComment = (commentRequired && !comment) || errors.comment; + const cannotSendComment = (commentRequired && !comment?.trim()) || errors.comment; const cannotSendTranscriptEmail = transcriptEmail && (!visitorEmail || !subject); return Boolean(cannotSendTag || cannotSendComment || cannotSendTranscriptEmail); @@ -134,8 +147,8 @@ const CloseChatModal = ({ {t('Close_room_description')} - {t('Comment')} - + {t('Comment')} + - - {errors.comment?.message} + + {errors.comment?.message} - {errors.tags?.message} + {errors.tags?.message} {canSendTranscript && ( <> - {t('Chat_transcript')} + {t('Chat_transcript')} {canSendTranscriptPDF && ( - + - + {t('Omnichannel_transcript_pdf')} - - + + )} {canSendTranscriptEmail && ( <> - + - + {t('Omnichannel_transcript_email')} - - + + {transcriptEmail && ( <> - {t('Contact_email')} - + {t('Contact_email')} + - + - {t('Subject')} - + {t('Subject')} + - - {errors.subject?.message} + + {errors.subject?.message} )} )} - + {canSendTranscriptPDF && canSendTranscriptEmail ? t('These_options_affect_this_conversation_only_To_set_default_selections_go_to_My_Account_Omnichannel') : t('This_option_affect_this_conversation_only_To_set_default_selection_go_to_My_Account_Omnichannel')} - + )} diff --git a/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx b/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx index 82c92d39cc8f..bdbde6b05acd 100644 --- a/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx @@ -1,5 +1,16 @@ import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; -import { Field, FieldGroup, Button, TextAreaInput, Modal, Box, PaginatedSelectFiltered, Divider } from '@rocket.chat/fuselage'; +import { + Field, + FieldGroup, + Button, + TextAreaInput, + Modal, + Box, + PaginatedSelectFiltered, + Divider, + FieldLabel, + FieldRow, +} from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { useEndpoint, useSetting, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -66,7 +77,7 @@ const ForwardChatModal = ({ const endReached = useCallback( (start) => { - if (departmentsPhase === AsyncStatePhase.LOADING) { + if (departmentsPhase !== AsyncStatePhase.LOADING) { loadMoreDepartments(start, Math.min(50, departmentsTotal)); } }, @@ -102,8 +113,8 @@ const ForwardChatModal = ({ - {t('Forward_to_department')} - + {t('Forward_to_department')} + - + - {t('Forward_to_user')} - + {t('Forward_to_user')} + - + - + {t('Leave_a_comment')}{' '} ({t('Optional')}) - - + + - + diff --git a/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx b/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx index e879caf38032..2757b5d9a88b 100644 --- a/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx @@ -1,5 +1,5 @@ import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; -import { Field, Button, TextInput, Modal, Box, FieldGroup } from '@rocket.chat/fuselage'; +import { Field, Button, TextInput, Modal, Box, FieldGroup, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { FC } from 'react'; import React, { useCallback, useEffect } from 'react'; @@ -78,28 +78,28 @@ const TranscriptModal: FC = ({ {!!transcriptRequest &&

{t('Livechat_transcript_already_requested_warning')}

} - {t('Email')}* - + {t('Email')}* + - - {errors.email?.message} + + {errors.email?.message} - {t('Subject')}* - + {t('Subject')}* + - - {errors.subject?.message} + + {errors.subject?.message}
diff --git a/apps/meteor/client/components/Page/Page.tsx b/apps/meteor/client/components/Page/Page.tsx index f3cabcce81a6..59f0849e5333 100644 --- a/apps/meteor/client/components/Page/Page.tsx +++ b/apps/meteor/client/components/Page/Page.tsx @@ -5,7 +5,7 @@ import React, { useState } from 'react'; import PageContext from './PageContext'; type PageProps = Omit, 'backgroundColor'> & { - background?: 'light' | 'tint' | 'neutral'; + background?: 'light' | 'tint' | 'neutral' | 'room'; }; const Page = ({ background = 'light', ...props }: PageProps): ReactElement => { @@ -20,7 +20,6 @@ const Page = ({ background = 'light', ...props }: PageProps): ReactElement => { flexShrink={1} height='full' overflow='hidden' - aria-labelledby='PageHeader-title' bg={background} color='default' {...props} diff --git a/apps/meteor/client/components/Page/PageHeader.tsx b/apps/meteor/client/components/Page/PageHeader.tsx index e7b8a0c764d7..6725c2da6f5c 100644 --- a/apps/meteor/client/components/Page/PageHeader.tsx +++ b/apps/meteor/client/components/Page/PageHeader.tsx @@ -1,4 +1,5 @@ import { Box, IconButton } from '@rocket.chat/fuselage'; +import { useAutoFocus } from '@rocket.chat/fuselage-hooks'; import { HeaderToolbox } from '@rocket.chat/ui-client'; import { useLayout, useTranslation } from '@rocket.chat/ui-contexts'; import type { FC, ComponentProps, ReactNode } from 'react'; @@ -17,32 +18,27 @@ const PageHeader: FC = ({ children = undefined, title, onClickB const t = useTranslation(); const [border] = useContext(PageContext); const { isMobile } = useLayout(); + const headerAutoFocus = useAutoFocus(); return ( - + {isMobile && ( )} {onClickBack && } - + {title} {children} diff --git a/apps/meteor/client/components/RoomAutoCompleteMultiple/RoomAutoCompleteMultiple.tsx b/apps/meteor/client/components/RoomAutoCompleteMultiple/RoomAutoCompleteMultiple.tsx index 7026c6cab35f..eb0414e7da14 100644 --- a/apps/meteor/client/components/RoomAutoCompleteMultiple/RoomAutoCompleteMultiple.tsx +++ b/apps/meteor/client/components/RoomAutoCompleteMultiple/RoomAutoCompleteMultiple.tsx @@ -49,8 +49,8 @@ const RoomAutoCompleteMultiple = ({ value, onChange, ...props }: RoomAutoComplet filter={filter} setFilter={setFilter} multiple - renderSelected={({ selected: { value, label }, onRemove }): ReactElement => ( - + renderSelected={({ selected: { value, label }, onRemove, ...props }): ReactElement => ( + {label?.name} diff --git a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelAppSourceRoomIcon.tsx b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelAppSourceRoomIcon.tsx index 504e9d2b461c..60f3af65eb9d 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 { IOmnichannelRoomFromAppSource } from '@rocket.chat/core-typings'; +import { UserStatus, type IOmnichannelRoomFromAppSource } from '@rocket.chat/core-typings'; import { Icon, Box } from '@rocket.chat/fuselage'; import type { ComponentProps, ReactElement } from 'react'; import React from 'react'; @@ -11,6 +11,7 @@ const colors = { away: 'status-font-on-warning', online: 'status-font-on-success', offline: 'annotation', + disabled: 'annotation', }; const convertBoxSizeToNumber = (boxSize: ComponentProps['size']): number => { @@ -37,7 +38,7 @@ export const OmnichannelAppSourceRoomIcon = ({ size: ComponentProps['size']; placement: 'sidebar' | 'default'; }): ReactElement => { - const color = colors[room.v.status || 'offline']; + const color = colors[room.v.status || UserStatus.OFFLINE]; const icon = (placement === 'sidebar' && room.source.sidebarIcon) || room.source.defaultIcon; const { phase, value } = useOmnichannelRoomIcon(room.source.id, icon || ''); const fontSize = convertBoxSizeToNumber(size); diff --git a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelCoreSourceRoomIcon.tsx b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelCoreSourceRoomIcon.tsx index c8c18cd7ab53..03a49e26cf9d 100644 --- a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelCoreSourceRoomIcon.tsx +++ b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelCoreSourceRoomIcon.tsx @@ -8,6 +8,7 @@ const colors = { away: 'status-font-on-warning', online: 'status-font-on-success', offline: 'annotation', + disabled: 'annotation', }; const iconMap = { diff --git a/apps/meteor/client/components/Sidebar/Header.tsx b/apps/meteor/client/components/Sidebar/Header.tsx index bbe64d71bd06..4e87a96d6ec5 100644 --- a/apps/meteor/client/components/Sidebar/Header.tsx +++ b/apps/meteor/client/components/Sidebar/Header.tsx @@ -12,15 +12,15 @@ const Header: FC = ({ title, onClose, children, ...props }) => { const t = useTranslation(); return ( - + {(title || onClose) && ( {title && ( - + {title} )} - {onClose && } + {onClose && } )} {children} diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx index 1b7936cafd3e..19074f7f6b34 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx @@ -1,4 +1,4 @@ -import { Box, FieldGroup, TextInput, Field } from '@rocket.chat/fuselage'; +import { Box, FieldGroup, TextInput, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; import { useAutoFocus, useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ChangeEvent, SyntheticEvent } from 'react'; @@ -59,13 +59,13 @@ const TwoFactorEmailModal = ({ onConfirm, onClose, emailOrUsername, invalidAttem > - + {t('Verify_your_email_with_the_code_we_sent')} - - + + - - {invalidAttempt && {t('Invalid_password')}} + + {invalidAttempt && {t('Invalid_password')}} diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorPasswordModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorPasswordModal.tsx index 3d604fc5004b..4c91e274de68 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorPasswordModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorPasswordModal.tsx @@ -1,4 +1,4 @@ -import { Box, PasswordInput, FieldGroup, Field } from '@rocket.chat/fuselage'; +import { Box, PasswordInput, FieldGroup, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; import { useAutoFocus, useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ChangeEvent, Ref, SyntheticEvent } from 'react'; @@ -43,10 +43,10 @@ const TwoFactorPasswordModal = ({ onConfirm, onClose, invalidAttempt }: TwoFacto > - + {t('For_your_security_you_must_enter_your_current_password_to_continue')} - - + + } @@ -54,8 +54,8 @@ const TwoFactorPasswordModal = ({ onConfirm, onClose, invalidAttempt }: TwoFacto onChange={onChange} placeholder={t('Password')} > - - {invalidAttempt && {t('Invalid_password')}} + + {invalidAttempt && {t('Invalid_password')}} diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx index 266e92586944..04aa7a4f94f0 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx @@ -1,4 +1,4 @@ -import { Box, TextInput, Field, FieldGroup } from '@rocket.chat/fuselage'; +import { Box, TextInput, Field, FieldGroup, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; import { useAutoFocus, useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ChangeEvent, SyntheticEvent } from 'react'; @@ -42,13 +42,13 @@ const TwoFactorTotpModal = ({ onConfirm, onClose, invalidAttempt }: TwoFactorTot > - + {t('Open_your_authentication_app_and_enter_the_code')} - - + + - - {invalidAttempt && {t('Invalid_password')}} + + {invalidAttempt && {t('Invalid_password')}} diff --git a/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple.tsx/UserAndRoomAutoCompleteMultiple.tsx b/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/UserAndRoomAutoCompleteMultiple.tsx similarity index 90% rename from apps/meteor/client/components/UserAndRoomAutoCompleteMultiple.tsx/UserAndRoomAutoCompleteMultiple.tsx rename to apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/UserAndRoomAutoCompleteMultiple.tsx index 96d11cf44cb7..f96c198865cc 100644 --- a/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple.tsx/UserAndRoomAutoCompleteMultiple.tsx +++ b/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/UserAndRoomAutoCompleteMultiple.tsx @@ -18,7 +18,16 @@ const UserAndRoomAutoCompleteMultiple = ({ value, onChange, ...props }: UserAndR const debouncedFilter = useDebouncedValue(filter, 1000); const rooms = useUserSubscriptions( - useMemo(() => ({ open: { $ne: false }, lowerCaseName: new RegExp(escapeRegExp(debouncedFilter), 'i') }), [debouncedFilter]), + useMemo( + () => ({ + open: { $ne: false }, + $or: [ + { lowerCaseFName: new RegExp(escapeRegExp(debouncedFilter), 'i') }, + { lowerCaseName: new RegExp(escapeRegExp(debouncedFilter), 'i') }, + ], + }), + [debouncedFilter], + ), ).filter((room) => { if (!user) { return; diff --git a/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple.tsx/index.ts b/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/index.ts similarity index 100% rename from apps/meteor/client/components/UserAndRoomAutoCompleteMultiple.tsx/index.ts rename to apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/index.ts diff --git a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx index dfda12cdd2e4..857af5e9c43f 100644 --- a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx +++ b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx @@ -31,7 +31,7 @@ const UserAutoCompleteMultiple = ({ onChange, ...props }: UserAutoCompleteMultip setFilter={setFilter} onChange={onChange} multiple - renderSelected={({ selected: { value, label }, onRemove }): ReactElement => ( + renderSelected={({ selected: { value, label }, onRemove, ...props }): ReactElement => ( diff --git a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated.tsx b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated.tsx index 7ee1078c859d..457593e2c5db 100644 --- a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated.tsx +++ b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated.tsx @@ -2,7 +2,7 @@ import { MultiSelectFiltered, Icon, Box, Chip } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; -import type { ReactElement } from 'react'; +import type { ReactElement, AllHTMLAttributes } from 'react'; import React, { memo, useState, useCallback, useMemo } from 'react'; import UserAvatar from '../avatar/UserAvatar'; @@ -12,7 +12,7 @@ type UserAutoCompleteMultipleFederatedProps = { onChange: (value: Array) => void; value: Array; placeholder?: string; -}; +} & Omit, 'is' | 'onChange'>; type UserAutoCompleteOptionType = { name: string; @@ -93,17 +93,18 @@ const UserAutoCompleteMultipleFederated = ({ return ( void }): ReactElement => { + renderSelected={({ value, onMouseDown }: { value: string; onMouseDown: () => void }) => { const currentCachedOption = selectedCache[value] || {}; return ( - + {currentCachedOption._federated ? : } {currentCachedOption.name || currentCachedOption.username || value} diff --git a/apps/meteor/client/components/UserStatusMenu.tsx b/apps/meteor/client/components/UserStatusMenu.tsx index b5d317b444ac..8f2dd24d6017 100644 --- a/apps/meteor/client/components/UserStatusMenu.tsx +++ b/apps/meteor/client/components/UserStatusMenu.tsx @@ -87,6 +87,7 @@ const UserStatusMenu = ({ onKeyUp={handleKeyUp} onKeyDown={handleKeyDown} margin={margin} + aria-label={t('User_status_menu')} > diff --git a/apps/meteor/client/components/avatar/RoomAvatarEditor.tsx b/apps/meteor/client/components/avatar/RoomAvatarEditor.tsx index 32948eb42243..04b07e9cd627 100644 --- a/apps/meteor/client/components/avatar/RoomAvatarEditor.tsx +++ b/apps/meteor/client/components/avatar/RoomAvatarEditor.tsx @@ -71,7 +71,7 @@ const RoomAvatarEditor = ({ disabled = false, room, roomAvatar, onChangeAvatar } danger icon='trash' title={t('Accounts_SetDefaultAvatar')} - disabled={roomAvatar === null || isRoomFederated(room) || disabled} + disabled={!roomAvatar || isRoomFederated(room) || disabled} onClick={clickReset} /> diff --git a/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarEditor.tsx b/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarEditor.tsx index ff5ece376a33..6d6ed93d3929 100644 --- a/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarEditor.tsx +++ b/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarEditor.tsx @@ -17,21 +17,15 @@ const toDataURL = (file: File, callback: (result: FileReader['result']) => void) reader.readAsDataURL(file); }; -type AvatarSuggestion = { - service: string; - url: string; -}; - type UserAvatarEditorType = { currentUsername: IUser['username']; username: IUser['username']; setAvatarObj: (obj: AvatarObject) => void; - suggestions: AvatarSuggestion[] | undefined; - disabled: boolean; + disabled?: boolean; etag: IUser['avatarETag']; }; -function UserAvatarEditor({ currentUsername, username, setAvatarObj, suggestions, disabled, etag }: UserAvatarEditorType): ReactElement { +function UserAvatarEditor({ currentUsername, username, setAvatarObj, disabled, etag }: UserAvatarEditorType): ReactElement { const t = useTranslation(); const rotateImages = useSetting('FileUpload_RotateImages'); const [avatarFromUrl, setAvatarFromUrl] = useState(''); @@ -100,14 +94,7 @@ function UserAvatarEditor({ currentUsername, username, setAvatarObj, suggestions disabled={disabled || !avatarFromUrl} title={t('Add_URL')} /> - {suggestions && ( - - )} + diff --git a/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestions.js b/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestions.js deleted file mode 100644 index 56985e592ad6..000000000000 --- a/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestions.js +++ /dev/null @@ -1,26 +0,0 @@ -import { Box, Button, Margins, Avatar } from '@rocket.chat/fuselage'; -import React, { useCallback } from 'react'; - -function UserAvatarSuggestions({ suggestions, setAvatarObj, setNewAvatarSource, disabled, ...props }) { - const handleClick = useCallback( - (suggestion) => () => { - setAvatarObj(suggestion); - setNewAvatarSource(suggestion.blob); - }, - [setAvatarObj, setNewAvatarSource], - ); - - return ( - - {Object.values(suggestions).map((suggestion) => ( - - ))} - - ); -} - -export default UserAvatarSuggestions; diff --git a/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestions.tsx b/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestions.tsx new file mode 100644 index 000000000000..04b0c92acd95 --- /dev/null +++ b/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestions.tsx @@ -0,0 +1,43 @@ +import type { AvatarObject } from '@rocket.chat/core-typings'; +import { Box, Button, Margins, Avatar } from '@rocket.chat/fuselage'; +import React, { useCallback } from 'react'; + +import { useAvatarSuggestions } from '../../../hooks/useAvatarSuggestions'; + +type UserAvatarSuggestionsProps = { + setAvatarObj: (obj: AvatarObject) => void; + setNewAvatarSource: (source: string) => void; + disabled?: boolean; +}; + +const UserAvatarSuggestions = ({ setAvatarObj, setNewAvatarSource, disabled }: UserAvatarSuggestionsProps) => { + const handleClick = useCallback( + (suggestion) => () => { + setAvatarObj(suggestion); + setNewAvatarSource(suggestion.blob); + }, + [setAvatarObj, setNewAvatarSource], + ); + + const { data } = useAvatarSuggestions(); + const suggestions = Object.values(data?.suggestions || {}); + + return ( + + {suggestions && + suggestions.length > 0 && + suggestions.map( + (suggestion) => + suggestion.blob && ( + + ), + )} + + ); +}; + +export default UserAvatarSuggestions; diff --git a/apps/meteor/client/components/message/MessageContentBody.tsx b/apps/meteor/client/components/message/MessageContentBody.tsx index 4674528a483f..5552e6da0745 100644 --- a/apps/meteor/client/components/message/MessageContentBody.tsx +++ b/apps/meteor/client/components/message/MessageContentBody.tsx @@ -46,7 +46,7 @@ const MessageContentBody = ({ mentions, channels, md, searchText }: MessageConte text-decoration: underline; } &:focus { - border: 2px solid ${Palette.stroke['stroke-extra-light-highlight']}; + box-shadow: 0 0 0 2px ${Palette.stroke['stroke-extra-light-highlight']}; border-radius: 2px; } } diff --git a/apps/meteor/client/components/message/MessageToolboxHolder.tsx b/apps/meteor/client/components/message/MessageToolboxHolder.tsx index 54f4ebab823d..06a9fbf42b77 100644 --- a/apps/meteor/client/components/message/MessageToolboxHolder.tsx +++ b/apps/meteor/client/components/message/MessageToolboxHolder.tsx @@ -2,7 +2,7 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { MessageToolboxWrapper } from '@rocket.chat/fuselage'; import { useQuery } from '@tanstack/react-query'; import type { ReactElement } from 'react'; -import React, { Suspense, lazy, memo, useRef } from 'react'; +import React, { Suspense, lazy, memo, useRef, useState } from 'react'; import type { MessageActionContext } from '../../../app/ui-utils/client/lib/MessageAction'; import { useChat } from '../../views/room/contexts/ChatContext'; @@ -18,7 +18,10 @@ const MessageToolbox = lazy(() => import('./toolbox/MessageToolbox')); const MessageToolboxHolder = ({ message, context }: MessageToolboxHolderProps): ReactElement => { const ref = useRef(null); - const [visible] = useIsVisible(ref); + const [isVisible] = useIsVisible(ref); + const [kebabOpen, setKebabOpen] = useState(false); + + const showToolbox = isVisible || kebabOpen; const chat = useChat(); @@ -32,10 +35,11 @@ const MessageToolboxHolder = ({ message, context }: MessageToolboxHolderProps): }); return ( - - {visible && depsQueryResult.isSuccess && depsQueryResult.data.room && ( + + {showToolbox && depsQueryResult.isSuccess && depsQueryResult.data.room && ( { - // Log debugging information - }, - warn: (..._) => { - // Log warning - }, - error: (..._) => { - // Log error - }, - }, -}); - -beforeEach(() => { - queryClient.clear(); -}); - it('should be call rest `POST /v1/chat.react` method', async () => { const fn = jest.fn(); - const { result, waitFor } = renderHook(() => useToggleReactionMutation(), { - wrapper: ({ children }) => ( - - { - if (method === 'POST' && pathPattern === '/v1/chat.react') { - fn(params); - return { success: true } as any; - } - throw new Error(`Endpoint not mocked: ${method} ${pathPattern}`); - }} - > - - - {children} - - - - - ), + const { result, waitFor } = renderHook(() => useToggleReactionMutation(), { + wrapper: mockAppRoot().withEndpoint('POST', '/v1/chat.react', fn).withJohnDoe().build(), }); await act(async () => { @@ -71,36 +24,17 @@ it('should be call rest `POST /v1/chat.react` method', async () => { it('should not work for non-logged in users', async () => { const fn = jest.fn(); - const { result, waitForValueToChange } = renderHook(() => useToggleReactionMutation(), { - wrapper: ({ children }) => ( - - { - if (method === 'POST' && pathPattern === '/v1/chat.react') { - fn(params); - return { success: true } as any; - } - throw new Error(`Endpoint not mocked: ${method} ${pathPattern}`); - }} - > - - {children} - - - - ), + const { result } = renderHook(() => useToggleReactionMutation(), { + wrapper: mockAppRoot().withEndpoint('POST', '/v1/chat.react', fn).build(), }); - act(() => { - return result.current.mutate({ mid: 'MID', reaction: 'smile' }); + await act(async () => { + expect(result.current.mutateAsync({ mid: 'MID', reaction: 'smile' })).rejects.toThrowError(); }); - await waitForValueToChange(() => result.current.status); - expect(fn).not.toHaveBeenCalled(); expect(result.current.status).toBe('error'); - expect(result.current.error).toEqual(new Error('Not logged in')); }); diff --git a/apps/meteor/client/components/message/list/MessageListSkeleton.tsx b/apps/meteor/client/components/message/list/MessageListSkeleton.tsx index 7c780fae5946..5581f5ffc63d 100644 --- a/apps/meteor/client/components/message/list/MessageListSkeleton.tsx +++ b/apps/meteor/client/components/message/list/MessageListSkeleton.tsx @@ -10,11 +10,7 @@ type MessageListSkeletonProps = { const MessageListSkeleton = ({ messageCount = 2 }: MessageListSkeletonProps): ReactElement => { const widths = useMemo( - () => - Array.from( - { length: messageCount }, - () => `${availablePercentualWidths[Math.floor(Math.random() * availablePercentualWidths.length)]}%`, - ), + () => Array.from({ length: messageCount }, (_, index) => `${availablePercentualWidths[index % availablePercentualWidths.length]}%`), [messageCount], ); diff --git a/apps/meteor/client/components/message/toolbox/DesktopToolboxDropdown.tsx b/apps/meteor/client/components/message/toolbox/DesktopToolboxDropdown.tsx index 86cc8c58f434..0310cf44c013 100644 --- a/apps/meteor/client/components/message/toolbox/DesktopToolboxDropdown.tsx +++ b/apps/meteor/client/components/message/toolbox/DesktopToolboxDropdown.tsx @@ -1,36 +1,6 @@ -import { Tile } from '@rocket.chat/fuselage'; -import { useMergedRefs, usePosition } from '@rocket.chat/fuselage-hooks'; +import { Tile, PositionAnimated } from '@rocket.chat/fuselage'; import type { ReactNode, Ref, RefObject } from 'react'; -import React, { useMemo, useRef, forwardRef } from 'react'; - -const getDropdownContainer = (descendant: HTMLElement | null) => { - for (let element = descendant ?? document.body; element !== document.body; element = element.parentElement ?? document.body) { - if ( - getComputedStyle(element).transform !== 'none' || - getComputedStyle(element).position === 'fixed' || - getComputedStyle(element).willChange === 'transform' - ) { - return element; - } - } - - return document.body; -}; - -const useDropdownPosition = (reference: RefObject, target: RefObject) => { - const innerContainer = getDropdownContainer(reference.current); - const boundingRect = innerContainer.getBoundingClientRect(); - - const { style } = usePosition(reference, target, { - placement: 'bottom-end', - container: innerContainer, - }); - - const left = `${parseFloat(String(style?.left ?? '0')) - boundingRect.left}px`; - const top = `${parseFloat(String(style?.top ?? '0')) - boundingRect.top}px`; - - return useMemo(() => ({ ...style, left, top }), [style, left, top]); -}; +import React, { forwardRef } from 'react'; type DesktopToolboxDropdownProps = { children: ReactNode; @@ -41,15 +11,12 @@ const DesktopToolboxDropdown = forwardRef(function ToolboxDropdownDesktop( { reference, children }: DesktopToolboxDropdownProps, ref: Ref, ) { - const targetRef = useRef(null); - const mergedRef = useMergedRefs(ref, targetRef); - - const style = useDropdownPosition(reference, targetRef); - return ( - - {children} - + + + {children} + + ); }); diff --git a/apps/meteor/client/components/message/toolbox/MessageActionMenu.tsx b/apps/meteor/client/components/message/toolbox/MessageActionMenu.tsx index b8417cfb6cd0..54a320ebf3d7 100644 --- a/apps/meteor/client/components/message/toolbox/MessageActionMenu.tsx +++ b/apps/meteor/client/components/message/toolbox/MessageActionMenu.tsx @@ -1,58 +1,95 @@ -import { MessageToolboxItem, Option, OptionDivider, Box } from '@rocket.chat/fuselage'; +import { MessageToolboxItem, Option, OptionDivider, OptionTitle } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { ComponentProps, UIEvent, ReactElement } from 'react'; -import React, { useState, Fragment, useRef } from 'react'; +import type { ComponentProps, MouseEvent, MouseEventHandler, ReactElement } from 'react'; +import React, { Fragment, useCallback, useRef, useState } from 'react'; import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction'; import { useEmbeddedLayout } from '../../../hooks/useEmbeddedLayout'; import ToolboxDropdown from './ToolboxDropdown'; type MessageActionConfigOption = Omit & { - action: (event: UIEvent) => void; + action: ((event: MouseEvent) => void) & MouseEventHandler; }; type MessageActionMenuProps = { + onChangeMenuVisibility: (visible: boolean) => void; options: MessageActionConfigOption[]; }; -const MessageActionMenu = ({ options, ...props }: MessageActionMenuProps): ReactElement => { - const ref = useRef(null); +const getSectionOrder = (section: string): number => { + switch (section) { + case 'communication': + return 0; + case 'interaction': + return 1; + case 'duplication': + return 2; + case 'apps': + return 3; + case 'management': + return 4; + default: + return 5; + } +}; +const MessageActionMenu = ({ options, onChangeMenuVisibility, ...props }: MessageActionMenuProps): ReactElement => { + const buttonRef = useRef(null); const t = useTranslation(); const [visible, setVisible] = useState(false); const isLayoutEmbedded = useEmbeddedLayout(); - const groupOptions = options - .map(({ color, ...option }) => ({ - ...option, - ...(color === 'alert' && { variant: 'danger' as const }), - })) - .reduce((acc, option) => { - const group = option.variant ? option.variant : ''; - acc[group] = acc[group] || []; - if (!(isLayoutEmbedded && option.id === 'reply-directly')) acc[group].push(option); + const handleChangeMenuVisibility = useCallback( + (visible: boolean): void => { + setVisible(visible); + onChangeMenuVisibility(visible); + }, + [onChangeMenuVisibility], + ); + + const groupOptions = options.reduce((acc, option) => { + const { type = '' } = option; + + if (option.color === 'alert') { + option.variant = 'danger' as const; + } + + const order = getSectionOrder(type); + const [sectionType, options] = acc[getSectionOrder(type)] ?? [type, []]; + + if (!(isLayoutEmbedded && option.id === 'reply-directly')) { + options.push(option); + } + + if (options.length === 0) { return acc; - }, {} as { [key: string]: MessageActionConfigOption[] }) as { - [key: string]: MessageActionConfigOption[]; - }; + } + + acc[order] = [sectionType, options]; + + return acc; + }, [] as unknown as [section: string, options: Array][]); + const handleClose = useCallback(() => { + handleChangeMenuVisibility(false); + }, [handleChangeMenuVisibility]); return ( <> setVisible(!visible)} + onClick={(): void => handleChangeMenuVisibility(!visible)} data-qa-id='menu' data-qa-type='message-action-menu' title={t('More')} /> {visible && ( <> - setVisible(!visible)} /> - - {Object.entries(groupOptions).map(([, options], index, arr) => ( + + {groupOptions.map(([section, options], index, arr) => ( + {section === 'apps' && Apps} {options.map((option) => (