diff --git a/.circleci/config.yml b/.circleci/config.yml index d6530e0aec67..739cee66a47d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,8 +9,8 @@ test-install-dependencies: &test-install-dependencies command: | wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add - sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 9DA31620334BD75D9DCB49F368818C72E52529D4 - echo "deb [ arch=amd64 ] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google.list - echo "deb [ arch=amd64 ] http://repo.mongodb.org/apt/ubuntu trusty/mongodb-org/4.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-4.0.list + echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google.list + echo "deb http://repo.mongodb.org/apt/debian stretch/mongodb-org/4.0 main" | sudo tee /etc/apt/sources.list.d/mongodb-org-4.0.list sudo apt-get update sudo apt-get install -y mongodb-org-shell google-chrome-stable @@ -33,20 +33,23 @@ test-configure-replicaset: &test-configure-replicaset mongo --eval 'rs.initiate({_id:"rs0", members: [{"_id":1, "host":"localhost:27017"}]})' mongo --eval 'rs.status()' -test-docker-image: &test-docker-image - circleci/node:8.11-browsers +test-restore-npm-cache: &test-restore-npm-cache + keys: + - node-modules-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "package.json" }} -test: &test - <<: *defaults - environment: &test-environment - TEST_MODE: "true" - MONGO_URL: mongodb://localhost:27017/rocketchat +test-save-npm-cache: &test-save-npm-cache + key: node-modules-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "package.json" }} + paths: + - ./node_modules +test-docker-image: &test-docker-image + circleci/node:8.11-stretch-browsers test-with-oplog: &test-with-oplog - <<: *test + <<: *defaults environment: - <<: *test-environment + TEST_MODE: "true" + MONGO_URL: mongodb://localhost:27017/rocketchat MONGO_OPLOG_URL: mongodb://localhost:27017/local steps: @@ -54,38 +57,29 @@ test-with-oplog: &test-with-oplog - checkout - run: *test-install-dependencies - run: *test-configure-replicaset + - restore_cache: *test-restore-npm-cache - run: *test-npm-install - run: *test-run + - save_cache: *test-save-npm-cache - store_artifacts: *test-store_artifacts -test-without-oplog: &test-without-oplog - <<: *test - steps: - - attach_workspace: *attach_workspace - - checkout - - run: *test-install-dependencies - - run: *test-npm-install - - run: *test-run - - store_artifacts: *test-store_artifacts - - version: 2 jobs: build: <<: *defaults docker: - - image: circleci/node:8.11 + - image: circleci/node:8.11-stretch steps: - checkout - # - restore_cache: - # keys: - # - node-modules-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "package.json" }} + - restore_cache: + keys: + - node-modules-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "package.json" }} - # - restore_cache: - # keys: - # - meteor-{{ checksum ".circleci/config.yml" }}-{{ checksum ".meteor/release" }} + - restore_cache: + keys: + - meteor-{{ checksum ".circleci/config.yml" }}-{{ checksum ".meteor/release" }} - run: name: Install Meteor @@ -123,6 +117,9 @@ jobs: # rm -rf node_modules # rm -f package-lock.json meteor npm install + cd packages/rocketchat-livechat/.app + meteor npm install + cd - - run: name: Lint @@ -134,28 +131,34 @@ jobs: command: | meteor npm run testunit - # - restore_cache: - # keys: - # - meteor-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum ".meteor/versions" }} + - restore_cache: + keys: + - meteor-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum ".meteor/versions" }} - # - restore_cache: - # keys: - # - livechat-meteor-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "packages/rocketchat-livechat/app/.meteor/versions" }} + - restore_cache: + keys: + - livechat-meteor-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "packages/rocketchat-livechat/.app/.meteor/versions" }} - # - restore_cache: - # keys: - # - livechat-node-modules-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "packages/rocketchat-livechat/app/package.json" }} + - restore_cache: + keys: + - livechat-node-modules-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "packages/rocketchat-livechat/.app/package.json" }} - run: name: Build Rocket.Chat environment: - TOOL_NODE_FLAGS: --max_old_space_size=4096 + TOOL_NODE_FLAGS: --max_old_space_size=3072 command: | - if [[ $CIRCLE_TAG ]]; then meteor reset; fi - set +e - meteor add rocketchat:lib - set -e - meteor build --server-only --directory /tmp/build-test + if [[ $CIRCLE_TAG ]] || [[ $CIRCLE_BRANCH == 'develop' ]]; then + meteor reset; + fi + + export CIRCLE_PR_NUMBER="${CIRCLE_PR_NUMBER:-${CIRCLE_PULL_REQUEST##*/}}" + if [[ -z $CIRCLE_PR_NUMBER ]]; then + meteor build --server-only --directory /tmp/build-test + else + export METEOR_PROFILE=1000 + meteor build --server-only --directory --debug /tmp/build-test + fi; - run: name: Prepare build @@ -166,30 +169,30 @@ jobs: cd /tmp/build-test/bundle/programs/server npm install - # - save_cache: - # key: node-modules-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "package.json" }} - # paths: - # - ./node_modules + - save_cache: + key: node-modules-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "package.json" }} + paths: + - ./node_modules - # - save_cache: - # key: meteor-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum ".meteor/versions" }} - # paths: - # - ./.meteor/local + - save_cache: + key: meteor-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum ".meteor/versions" }} + paths: + - ./.meteor/local - # - save_cache: - # key: livechat-node-modules-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "packages/rocketchat-livechat/app/package.json" }} - # paths: - # - ./packages/rocketchat-livechat/app/node_modules + - save_cache: + key: livechat-node-modules-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "packages/rocketchat-livechat/.app/package.json" }} + paths: + - ./packages/rocketchat-livechat/app/node_modules - # - save_cache: - # key: livechat-meteor-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "packages/rocketchat-livechat/app/.meteor/versions" }} - # paths: - # - ./packages/rocketchat-livechat/app/.meteor/local + - save_cache: + key: livechat-meteor-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "packages/rocketchat-livechat/.app/.meteor/versions" }} + paths: + - ./packages/rocketchat-livechat/app/.meteor/local - # - save_cache: - # key: meteor-{{ checksum ".circleci/config.yml" }}-{{ checksum ".meteor/release" }} - # paths: - # - ~/.meteor + - save_cache: + key: meteor-{{ checksum ".circleci/config.yml" }}-{{ checksum ".meteor/release" }} + paths: + - ~/.meteor - persist_to_workspace: root: /tmp/ @@ -229,36 +232,10 @@ jobs: - image: mongo:4.0 command: [mongod, --noprealloc, --smallfiles, --replSet=rs0] - - test-without-oplog-mongo-3-2: - <<: *test-without-oplog - docker: - - image: *test-docker-image - - image: mongo:3.2 - - test-without-oplog-mongo-3-4: - <<: *test-without-oplog - docker: - - image: *test-docker-image - - image: mongo:3.4 - - test-without-oplog-mongo-3-6: - <<: *test-without-oplog - docker: - - image: *test-docker-image - - image: mongo:3.6 - - test-without-oplog-mongo-4-0: - <<: *test-without-oplog - docker: - - image: *test-docker-image - - image: mongo:4.0 - - deploy: <<: *defaults docker: - - image: circleci/node:8.11 + - image: circleci/node:8.11-stretch steps: - attach_workspace: @@ -272,9 +249,9 @@ jobs: if [[ $CIRCLE_PULL_REQUESTS ]]; then exit 0; fi; sudo apt-get -y -qq update - sudo apt-get -y -qq install python3.4-dev + sudo apt-get -y -qq install python3.5-dev curl -O https://bootstrap.pypa.io/get-pip.py - python3.4 get-pip.py --user + python3.5 get-pip.py --user export PATH=~/.local/bin:$PATH pip install awscli --upgrade --user @@ -290,7 +267,6 @@ jobs: source .circleci/setdeploydir.sh bash .circleci/setupsig.sh bash .circleci/namefiles.sh - # echo ".circleci/sandstorm.sh" aws s3 cp $ROCKET_DEPLOY_DIR/ s3://download.rocket.chat/build/ --recursive @@ -414,13 +390,13 @@ workflows: - build: filters: tags: - only: /^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$/ + only: /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|beta)\.[0-9]+)?$/ - test-with-oplog-mongo-3-2: &test-mongo requires: - build filters: tags: - only: /^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$/ + only: /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|beta)\.[0-9]+)?$/ - test-with-oplog-mongo-3-4: &test-mongo-no-pr requires: - build @@ -428,28 +404,20 @@ workflows: branches: only: develop tags: - only: /^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$/ + only: /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|beta)\.[0-9]+)?$/ - test-with-oplog-mongo-3-6: *test-mongo-no-pr - test-with-oplog-mongo-4-0: *test-mongo - - test-without-oplog-mongo-3-2: *test-mongo-no-pr - - test-without-oplog-mongo-3-4: *test-mongo-no-pr - - test-without-oplog-mongo-3-6: *test-mongo-no-pr - - test-without-oplog-mongo-4-0: *test-mongo-no-pr - deploy: requires: - test-with-oplog-mongo-3-2 - test-with-oplog-mongo-3-4 - test-with-oplog-mongo-3-6 - test-with-oplog-mongo-4-0 - - test-without-oplog-mongo-3-2 - - test-without-oplog-mongo-3-4 - - test-without-oplog-mongo-3-6 - - test-without-oplog-mongo-4-0 filters: branches: only: develop tags: - only: /^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$/ + only: /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|beta)\.[0-9]+)?$/ - image-build: requires: - deploy @@ -457,7 +425,7 @@ workflows: branches: only: develop tags: - only: /^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$/ + only: /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|beta)\.[0-9]+)?$/ - hold: type: approval requires: @@ -466,7 +434,7 @@ workflows: branches: ignore: develop tags: - only: /^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$/ + only: /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|beta)\.[0-9]+)?$/ - pr-image-build: requires: - hold @@ -474,5 +442,5 @@ workflows: branches: ignore: develop tags: - only: /^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$/ + only: /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|beta)\.[0-9]+)?$/ diff --git a/.docker-mongo/Dockerfile b/.docker-mongo/Dockerfile index 8a5faf28da37..bc250d596713 100644 --- a/.docker-mongo/Dockerfile +++ b/.docker-mongo/Dockerfile @@ -6,8 +6,8 @@ ADD entrypoint.sh /app/bundle/ MAINTAINER buildmaster@rocket.chat RUN set -x \ - && apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 2930ADAE8CAF5059EE73BB4B58712A2291FA4AD5 \ - && echo "deb http://repo.mongodb.org/apt/debian jessie/mongodb-org/3.6 main" | tee /etc/apt/sources.list.d/mongodb-org-3.6.list \ + && apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 9DA31620334BD75D9DCB49F368818C72E52529D4 \ + && echo "deb http://repo.mongodb.org/apt/debian stretch/mongodb-org/4.0 main" | tee /etc/apt/sources.list.d/mongodb-org-4.0.list \ && apt-get update \ && apt-get install -y --force-yes pwgen mongodb-org \ && echo "mongodb-org hold" | dpkg --set-selections \ diff --git a/.docker/Dockerfile.rhel b/.docker/Dockerfile.rhel index 4414ab91bd4d..df89bd239089 100644 --- a/.docker/Dockerfile.rhel +++ b/.docker/Dockerfile.rhel @@ -1,6 +1,6 @@ FROM registry.access.redhat.com/rhscl/nodejs-8-rhel7 -ENV RC_VERSION 0.74.3 +ENV RC_VERSION 1.0.0 MAINTAINER buildmaster@rocket.chat diff --git a/.eslintignore b/.eslintignore index a557dee6c2f5..76f542a4915e 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,23 +2,23 @@ node_modules packages/autoupdate/ packages/meteor-streams/ packages/meteor-timesync/ -packages/rocketchat-emoji-emojione/generateEmojiIndex.js -packages/rocketchat-favico/favico.js -packages/rocketchat-katex/client/katex/katex.min.js +app/emoji-emojione/generateEmojiIndex.js +app/favico/favico.js +app/katex/client/katex/katex.min.js packages/rocketchat-livechat/.app/node_modules packages/rocketchat-livechat/.app/.meteor packages/rocketchat-livechat/assets/rocketchat-livechat.min.js packages/rocketchat-livechat/assets/rocket-livechat.js -packages/rocketchat_theme/client/minicolors/jquery.minicolors.js -packages/rocketchat_theme/client/vendor/ -packages/rocketchat-ui/client/lib/customEventPolyfill.js -packages/rocketchat-ui/client/lib/Modernizr.js -packages/rocketchat-ui/client/lib/recorderjs/recorder.js -packages/rocketchat-videobridge/client/public/external_api.js +app/theme/client/minicolors/jquery.minicolors.js +app/theme/client/vendor/ +app/ui/client/lib/customEventPolyfill.js +app/ui/client/lib/Modernizr.js +public/mp3-realtime-worker.js +public/lame.min.js +public/packages/rocketchat_videobridge/client/public/external_api.js packages/tap-i18n/lib/tap_i18next/tap_i18next-1.7.3.js private/moment-locales/ public/livechat/ -public/mp3-realtime-worker.js -public/lame.min.js !.scripts !packages/rocketchat-livechat/.app +public/pdf.worker.min.js diff --git a/.eslintrc b/.eslintrc index 6edb1a5fb690..e3663d24a88f 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,21 +1,29 @@ { "extends": ["@rocket.chat/eslint-config"], "parser": "babel-eslint", + "rules": { + "import/no-unresolved": [2, { + "commonjs": true, + "amd": true, + "ignore": [ + "^meteor\/.+$" + ] + }], + "import/named": 0, + "import/namespace": 0, + "import/default": 0, + "import/export": 2, + "import/no-cycle": 0, + "import/no-useless-path-segments": 2, + "import/no-duplicates": 2, + "import/no-named-as-default": 0, + "import/no-named-as-default-member": 0 + }, "globals": { "__meteor_bootstrap__" : false, "__meteor_runtime_config__" : false, - "Apps" : false, "Assets" : false, "chrome" : false, - "DynamicCss" : false, - "handleError" : false, - "getAvatarSuggestionForUser" : false, - "JitsiMeetExternalAPI" : false, - "jscolor" : false, - "msgStream" : false, - "openRoom" : false, - "RocketChat" : true, - "roomExit" : true, - "Settings" : false + "jscolor" : false } } diff --git a/.github/history.json b/.github/history.json index 8e3d1683fc40..739115d3c09a 100644 --- a/.github/history.json +++ b/.github/history.json @@ -25632,6 +25632,4107 @@ "4.0" ], "pull_requests": [ + { + "pr": "13474", + "title": "Release 0.74.3", + "userLogin": "sampaiodiego", + "contributors": [ + "tassoevan", + "sampaiodiego", + "graywolf336", + "Hudell", + "d-gubert", + "rodrigok", + "BehindLoader", + "leonboot", + "renatobecker" + ] + }, + { + "pr": "13471", + "title": "Room loading improvements", + "userLogin": "rodrigok", + "milestone": "0.74.3", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13360", + "title": "[FIX] Invalid condition on getting next livechat agent over REST API endpoint", + "userLogin": "renatobecker", + "milestone": "0.74.3", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "13417", + "title": "[IMPROVE] Open rooms quicker", + "userLogin": "rodrigok", + "milestone": "0.74.3", + "contributors": [ + "rodrigok", + "web-flow" + ] + }, + { + "pr": "13457", + "title": "[FIX] \"Test Desktop Notifications\" not triggering a notification", + "userLogin": "tassoevan", + "milestone": "0.74.3", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "13463", + "title": "[FIX] Translated and incorrect i18n variables", + "userLogin": "leonboot", + "milestone": "0.74.3", + "contributors": [ + "leonboot", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13456", + "title": "Regression: Remove console.log on email translations", + "userLogin": "sampaiodiego", + "milestone": "0.74.3", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13408", + "title": "[FIX] Properly escape custom emoji names for pattern matching", + "userLogin": "tassoevan", + "milestone": "0.74.3", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "13452", + "title": "[FIX] Not translated emails", + "userLogin": "sampaiodiego", + "milestone": "0.74.3", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13437", + "title": "[FIX] XML-decryption module not found", + "userLogin": "Hudell", + "milestone": "0.74.3", + "contributors": [ + "Hudell" + ] + }, + { + "pr": "13244", + "title": "[FIX] Update Russian localization", + "userLogin": "BehindLoader", + "milestone": "0.74.3", + "contributors": [ + "BehindLoader", + "web-flow", + "tassoevan" + ] + }, + { + "pr": "13436", + "title": "[IMPROVE] Allow configure Prometheus port per process via Environment Variable", + "userLogin": "rodrigok", + "milestone": "0.74.3", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13430", + "title": "[IMPROVE] Add API option \"permissionsRequired\"", + "userLogin": "d-gubert", + "milestone": "0.74.3", + "contributors": [ + "d-gubert", + "web-flow" + ] + }, + { + "pr": "13336", + "title": "[FIX] Several Problems on HipChat Importer", + "userLogin": "Hudell", + "milestone": "0.74.3", + "contributors": [ + "rodrigok", + "Hudell", + "web-flow" + ] + }, + { + "pr": "13423", + "title": "[FIX] Invalid push gateway configuration, requires the uniqueId", + "userLogin": "graywolf336", + "milestone": "0.74.3", + "contributors": [ + "graywolf336" + ] + }, + { + "pr": "13369", + "title": "[FIX] Notify private settings changes even on public settings changed", + "userLogin": "tassoevan", + "milestone": "0.74.3", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "13407", + "title": "[FIX] Misaligned upload progress bar \"cancel\" button", + "userLogin": "tassoevan", + "milestone": "0.74.3", + "contributors": [ + "tassoevan" + ] + } + ] + }, + "1.0.0-rc.0": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "13757", + "title": "[IMPROVE] UI of page not found", + "userLogin": "fliptrail", + "milestone": "1.0.0", + "contributors": [ + "fliptrail", + "engelgabriel", + "web-flow", + "sampaiodiego", + "geekgonecrazy" + ] + }, + { + "pr": "13951", + "title": "[FIX] Opening a Livechat room from another agent", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "13938", + "title": "[FIX] Directory and Apps logs page", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "13521", + "title": "[FIX] Minor issues detected after testing the new Livechat client", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "13896", + "title": "[FIX] Display first message when taking Livechat inquiry", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "13953", + "title": "[FIX] Loading theme CSS on first server startup", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13755", + "title": "[FIX] OTR dialog issue", + "userLogin": "knrt10", + "milestone": "1.0.0", + "contributors": [ + "knrt10", + "engelgabriel", + "web-flow" + ] + }, + { + "pr": "13966", + "title": "Update eslint config", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13949", + "title": "[FIX] Limit App’s HTTP calls to 500ms", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok", + "sampaiodiego", + "web-flow", + "d-gubert" + ] + }, + { + "pr": "13954", + "title": "Remove some bad references to messageBox", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan", + "web-flow" + ] + }, + { + "pr": "13964", + "title": "LingoHub based on develop", + "userLogin": "engelgabriel", + "contributors": [ + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13948", + "title": "[IMPROVE] Show rooms with mentions on unread category even with hide counter", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13947", + "title": "Update preview Dockerfile to use Stretch dependencies", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13946", + "title": "Small improvements to federation callbacks/hooks", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13936", + "title": "Improve: Support search and adding federated users through regular endpoints", + "userLogin": "alansikora", + "contributors": [ + "alansikora", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "13935", + "title": "Remove bitcoin link in Readme.md since the link is broken", + "userLogin": "ashwaniYDV", + "contributors": [ + "ashwaniYDV", + "geekgonecrazy", + "web-flow" + ] + }, + { + "pr": "13832", + "title": "[FIX] Read Receipt for Livechat Messages fixed", + "userLogin": "knrt10", + "milestone": "1.0.0", + "contributors": [ + "knrt10", + "renatobecker", + "web-flow" + ] + }, + { + "pr": "13809", + "title": "[NEW] Marketplace integration with Rocket.Chat Cloud", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "graywolf336", + "rodrigok", + "geekgonecrazy", + "web-flow", + "d-gubert" + ] + }, + { + "pr": "12626", + "title": "[NEW] Add message action to copy message to input as reply", + "userLogin": "mrsimpson", + "milestone": "1.0.0", + "contributors": [ + "mrsimpson", + "rodrigok", + "web-flow", + "d-gubert" + ] + }, + { + "pr": "13914", + "title": "[FIX] Avatar image being shrinked on autocomplete", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13910", + "title": "Fix missing dependencies on stretch CI image", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13855", + "title": "[FIX] VIDEO/JITSI multiple calls before video call", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "engelgabriel", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "13772", + "title": "Remove some index.js files routing for server/client files", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13906", + "title": "Use CircleCI Debian Stretch images", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13895", + "title": "[FIX] Some Safari bugs", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "13851", + "title": "[FIX] wrong width/height for tile_70 (mstile 70x70 (png))", + "userLogin": "ulf-f", + "contributors": [ + "ulf-f", + "web-flow" + ] + }, + { + "pr": "13891", + "title": "LingoHub based on develop", + "userLogin": "engelgabriel", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13874", + "title": "User remove role dialog fixed", + "userLogin": "bhardwajaditya", + "contributors": [ + "bhardwajaditya" + ] + }, + { + "pr": "13863", + "title": "[FIX] wrong importing of e2e", + "userLogin": "marceloschmidt", + "milestone": "1.0.0", + "contributors": [ + "marceloschmidt" + ] + }, + { + "pr": "13752", + "title": "[IMPROVE] Join channels by sending a message or join button (#13752)", + "userLogin": "bhardwajaditya", + "milestone": "1.0.0", + "contributors": [ + "bhardwajaditya", + "engelgabriel", + "web-flow", + "ggazzo", + "sampaiodiego" + ] + }, + { + "pr": "13819", + "title": "[NEW] Allow sending long messages as attachments", + "userLogin": "marceloschmidt", + "milestone": "1.0.0", + "contributors": [ + "marceloschmidt", + "ggazzo" + ] + }, + { + "pr": "13782", + "title": "Rename Threads to Discussion", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13730", + "title": "[IMPROVE] Filter agents with autocomplete input instead of select element", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker", + "engelgabriel", + "web-flow" + ] + }, + { + "pr": "13818", + "title": "[IMPROVE] Ignore agent status when queuing incoming livechats via Guest Pool", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker", + "web-flow" + ] + }, + { + "pr": "13806", + "title": "[BUG] Icon Fixed for Knowledge base on Livechat ", + "userLogin": "knrt10", + "milestone": "1.0.0", + "contributors": [ + "knrt10" + ] + }, + { + "pr": "13803", + "title": "Add support to search for all users in directory", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13839", + "title": "LingoHub based on develop", + "userLogin": "engelgabriel", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13834", + "title": "Remove unused style", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "13833", + "title": "Remove unused files", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "13825", + "title": "Lingohub sync and additional fixes", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego", + "engelgabriel", + "web-flow" + ] + }, + { + "pr": "13783", + "title": "[FIX] Forwarded Livechat visitor name is not getting updated on the sidebar", + "userLogin": "zolbayars", + "milestone": "1.0.0", + "contributors": [ + "zolbayars", + "renatobecker", + "web-flow" + ] + }, + { + "pr": "13801", + "title": "[FIX] Remove spaces in some i18n files", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13789", + "title": "Fix: addRoomAccessValidator method created for Threads", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "13796", + "title": "[IMPROVE] Replaces color #13679A to #1d74f5", + "userLogin": "fliptrail", + "contributors": [ + "fliptrail" + ] + }, + { + "pr": "13751", + "title": "[FIX] Translation interpolations for many languages", + "userLogin": "fliptrail", + "milestone": "1.0.0", + "contributors": [ + "fliptrail", + "engelgabriel", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "13779", + "title": "Adds French translation of Personal Access Token", + "userLogin": "ashwaniYDV", + "milestone": "1.0.0", + "contributors": [ + "ashwaniYDV" + ] + }, + { + "pr": "13775", + "title": "[NEW] Add e-mail field on Livechat Departments", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker", + "engelgabriel", + "web-flow" + ] + }, + { + "pr": "13559", + "title": "[FIX] Fixed grammatical error.", + "userLogin": "gsunit", + "milestone": "1.0.0", + "contributors": [ + "gsunit", + "web-flow" + ] + }, + { + "pr": "13784", + "title": "[FIX] In home screen Rocket.Chat+ is dispalyed as Rocket.Chat", + "userLogin": "ashwaniYDV", + "contributors": [ + "ashwaniYDV" + ] + }, + { + "pr": "13753", + "title": "[FIX] No new room created when conversation is closed", + "userLogin": "knrt10", + "milestone": "1.0.0", + "contributors": [ + "knrt10", + "renatobecker", + "web-flow" + ] + }, + { + "pr": "13773", + "title": "Remove Sandstorm support", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13769", + "title": "[FIX] Loading user list from room messages", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego", + "engelgabriel", + "web-flow" + ] + }, + { + "pr": "13767", + "title": "Removing (almost) every dynamic imports", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13744", + "title": "[FIX] User is unable to enter multiple emojis by clicking on the emoji icon", + "userLogin": "Kailash0311", + "milestone": "1.0.0", + "contributors": [ + "Kailash0311", + "engelgabriel", + "web-flow" + ] + }, + { + "pr": "13743", + "title": "[IMPROVE] Remove unnecessary \"File Upload\".", + "userLogin": "knrt10", + "milestone": "1.0.0", + "contributors": [ + "knrt10" + ] + }, + { + "pr": "13741", + "title": "Regression: Threads styles improvement", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "13723", + "title": "[NEW] Provide new Livechat client as community feature", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "13727", + "title": "[FIX] Audio message recording", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "13740", + "title": "Convert imports to relative paths", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13729", + "title": "Regression: removed backup files", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "13725", + "title": "Remove unused files", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13724", + "title": "[BREAK] Remove deprecated file upload engine Slingshot", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "12429", + "title": "[FIX] Remove Room info for Direct Messages (#9383)", + "userLogin": "vinade", + "milestone": "1.0.0", + "contributors": [ + "vinade", + "ggazzo" + ] + }, + { + "pr": "13726", + "title": "[IMPROVE] Add index for room's ts", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13675", + "title": "[FIX] WebRTC wasn't working duo to design and browser's APIs changes", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "web-flow", + "sampaiodiego", + "tassoevan" + ] + }, + { + "pr": "13714", + "title": "[FIX] Adds Proper Language display name for many languages", + "userLogin": "fliptrail", + "milestone": "1.0.0", + "contributors": [ + "fliptrail" + ] + }, + { + "pr": "13707", + "title": "Add Houston config", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13705", + "title": "[FIX] Update bad-words to 3.0.2", + "userLogin": "trivoallan", + "contributors": [ + "trivoallan" + ] + }, + { + "pr": "13672", + "title": "[FIX] Changing Room name updates the webhook", + "userLogin": "knrt10", + "milestone": "1.0.0", + "contributors": [ + "knrt10" + ] + }, + { + "pr": "13702", + "title": "[FIX] Fix snap refresh hook", + "userLogin": "LuluGO", + "contributors": [ + "LuluGO" + ] + }, + { + "pr": "13695", + "title": "Change the way to resolve DNS for Federation", + "userLogin": "alansikora", + "contributors": [ + "alansikora" + ] + }, + { + "pr": "13687", + "title": "Update husky config", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13683", + "title": "Regression: Prune Threads", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13486", + "title": "[FIX] Audio message recording issues", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan", + "web-flow" + ] + }, + { + "pr": "13677", + "title": "[FIX] Legal pages' style", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13676", + "title": "[FIX] Stop livestream", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "13679", + "title": "Regression: Fix icon for DMs", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13681", + "title": "[FIX] Avatar fonts for PNG and JPG", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "12347", + "title": "[IMPROVE] Add decoding for commonName (cn) and displayName attributes for SAML", + "userLogin": "pkolmann", + "milestone": "1.0.0", + "contributors": [ + null, + "pkolmann", + "web-flow", + "engelgabriel", + "sampaiodiego" + ] + }, + { + "pr": "13630", + "title": "[FIX] Block User Icon", + "userLogin": "knrt10", + "contributors": [ + "knrt10" + ] + }, + { + "pr": "13670", + "title": "[FIX] Corrects UI background of forced F2A Authentication", + "userLogin": "fliptrail", + "milestone": "1.0.0", + "contributors": [ + "fliptrail", + "ggazzo" + ] + }, + { + "pr": "13674", + "title": "Regression: Add missing translations used in Apps pages", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego", + "tassoevan" + ] + }, + { + "pr": "13587", + "title": "[FIX] Race condition on the loading of Apps on the admin page", + "userLogin": "graywolf336", + "contributors": [ + "graywolf336", + "sampaiodiego" + ] + }, + { + "pr": "13656", + "title": "Regression: User Discussions join message", + "userLogin": "bhardwajaditya", + "contributors": [ + "bhardwajaditya" + ] + }, + { + "pr": "13658", + "title": "Regression: Sidebar create new channel hover text", + "userLogin": "bhardwajaditya", + "contributors": [ + "bhardwajaditya" + ] + }, + { + "pr": "13574", + "title": "Regression: Fix embedded layout", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13651", + "title": "Improve: Send cloud token to Federation Hub", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13646", + "title": "Regression: Discussions - Invite users and DM", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13541", + "title": "[NEW] Discussions", + "userLogin": "ggazzo", + "contributors": [ + "mrsimpson", + "vickyokrm" + ] + }, + { + "pr": "13635", + "title": "[NEW] Bosnian lang (BS)", + "userLogin": "fliptrail", + "milestone": "1.0.0", + "contributors": [ + "fliptrail" + ] + }, + { + "pr": "13629", + "title": "[FIX] Do not allow change avatars of another users without permission", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13623", + "title": "LingoHub based on develop", + "userLogin": "engelgabriel", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "12370", + "title": "[NEW] Federation", + "userLogin": "alansikora", + "milestone": "1.0.0", + "contributors": [ + "alansikora", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13612", + "title": "[FIX] link of k8s deploy", + "userLogin": "Mr-Linus", + "contributors": [ + "Mr-Linus", + "web-flow", + "geekgonecrazy" + ] + }, + { + "pr": "13245", + "title": "[FIX] Bugfix markdown Marked link new tab", + "userLogin": "DeviaVir", + "milestone": "1.0.0", + "contributors": [ + "DeviaVir", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "13530", + "title": "[NEW] Show department field on Livechat visitor panel", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "13599", + "title": "[FIX] Partially messaging formatting for bold letters", + "userLogin": "knrt10", + "milestone": "1.0.0", + "contributors": [ + "knrt10" + ] + }, + { + "pr": "13367", + "title": "Force some words to translate in other languages", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "soltanabadiyan", + "tassoevan" + ] + }, + { + "pr": "13442", + "title": "[FIX] Change userId of rate limiter, change to logged user", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "13199", + "title": "[FIX] Add retries to docker-compose.yml, to wait for MongoDB to be ready", + "userLogin": "tiangolo", + "milestone": "1.0.0", + "contributors": [ + "tiangolo", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "13601", + "title": "Fix wrong imports", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13310", + "title": "[NEW] Add offset parameter to channels.history, groups.history, dm.history", + "userLogin": "xbolshe", + "milestone": "1.0.0", + "contributors": [ + "xbolshe", + "web-flow" + ] + }, + { + "pr": "13467", + "title": "[FIX] Non-latin room names and other slugifications", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "13598", + "title": "[IMPROVE] Deprecate fixCordova helper", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "13056", + "title": "[FIX] Fixed rocketchat-oembed meta fragment pulling", + "userLogin": "wreiske", + "milestone": "1.0.0", + "contributors": [ + "wreiske", + "web-flow" + ] + }, + { + "pr": "13299", + "title": "Fix: Some german translations", + "userLogin": "soenkef", + "milestone": "1.0.0", + "contributors": [ + "soenkef", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "13428", + "title": "[FIX] Attachments without dates were showing December 31, 1970", + "userLogin": "wreiske", + "milestone": "1.0.0", + "contributors": [ + "wreiske", + "web-flow" + ] + }, + { + "pr": "13451", + "title": "[FIX] Restart required to apply changes in API Rate Limiter settings", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13472", + "title": "Add better positioning for tooltips on edges", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "13597", + "title": "[NEW] Permission to assign roles", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13563", + "title": "[FIX] Ability to activate an app installed by zip even offline", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "d-gubert", + "web-flow" + ] + }, + { + "pr": "12095", + "title": "[NEW] reply with a file", + "userLogin": "rssilva", + "milestone": "1.0.0", + "contributors": [ + "rssilva", + "geekgonecrazy", + "web-flow", + "ggazzo", + "tassoevan" + ] + }, + { + "pr": "13468", + "title": "[FIX] .bin extension added to attached file names", + "userLogin": "Hudell", + "milestone": "1.0.0", + "contributors": [ + "Hudell" + ] + }, + { + "pr": "12472", + "title": "[NEW] legal notice page", + "userLogin": "localguru", + "milestone": "1.0.0", + "contributors": [ + "localguru", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "13502", + "title": "[FIX] Right arrows in default HTML content", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan", + "web-flow", + "ggazzo" + ] + }, + { + "pr": "13469", + "title": "[FIX] Typo in a referrer header in inject.js file", + "userLogin": "algomaster99", + "milestone": "1.0.0", + "contributors": [ + "algomaster99", + "web-flow" + ] + }, + { + "pr": "12952", + "title": "[FIX] Fix issue cannot \u001dfilter channels by name", + "userLogin": "huydang284", + "milestone": "1.0.0", + "contributors": [ + "huydang284", + "web-flow", + "ggazzo" + ] + }, + { + "pr": "11745", + "title": "[FIX] mention-links not being always resolved", + "userLogin": "mrsimpson", + "contributors": [ + "mrsimpson", + "web-flow", + "ggazzo" + ] + }, + { + "pr": "13586", + "title": "Fix: Mongo.setConnectionOptions was not being set correctly", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13439", + "title": "[FIX] allow user to logout before set username", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "13584", + "title": "[IMPROVE] Remove dangling side-nav styles", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "13573", + "title": "Regression: Missing settings import at `packages/rocketchat-livechat/server/methods/saveAppearance.js`", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13553", + "title": "[FIX] Error when recording data into the connection object", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13508", + "title": "Depack: Use mainModule for root files", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13564", + "title": "[FIX] Handle showing/hiding input in messageBox", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "13567", + "title": "Regression: fix app pages styles", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "13388", + "title": "[IMPROVE] Disable X-Powered-By header in all known express middlewares", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "12981", + "title": "[IMPROVE] Allow custom rocketchat username for crowd users and enable login via email/crowd_username", + "userLogin": "steerben", + "milestone": "1.0.0", + "contributors": [ + "steerben", + "web-flow", + "rodrigok", + "engelgabriel" + ] + }, + { + "pr": "13531", + "title": "Move mongo config away from cors package", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13529", + "title": "Regression: Add debounce on admin users search to avoid blocking by DDP Rate Limiter", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13523", + "title": "Remove Package references", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13518", + "title": "Remove Npm.depends and Npm.require except those that are inside package.js", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13515", + "title": "[FIX]Fix wrong this scope in Notifications", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13519", + "title": "Update Meteor 1.8.0.2", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13520", + "title": "Convert rc-nrr and slashcommands open to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13522", + "title": "[BREAK] Remove internal hubot package", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13485", + "title": "[FIX] Get next Livechat agent endpoint", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker", + "web-flow" + ] + }, + { + "pr": "13491", + "title": "[IMPROVE] Add department field on find guest method", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker", + "web-flow", + "MarcosSpessatto" + ] + }, + { + "pr": "13516", + "title": "Regression: Fix wrong imports in rc-models", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "web-flow" + ] + }, + { + "pr": "13497", + "title": "Regression: Fix autolinker that was not parsing urls correctly", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13509", + "title": "Regression: Not updating subscriptions and not showing desktop notifcations", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13315", + "title": "[NEW] Add missing remove add leader channel", + "userLogin": "Montel", + "milestone": "1.0.0", + "contributors": [ + "Montel", + "MarcosSpessatto", + "web-flow" + ] + }, + { + "pr": "13443", + "title": "[NEW] users.setActiveStatus endpoint in rest api", + "userLogin": "thayannevls", + "milestone": "1.0.0", + "contributors": [ + "thayannevls", + "web-flow", + "MarcosSpessatto" + ] + }, + { + "pr": "13422", + "title": " Fix some imports from wrong packages, remove exports and files unused in rc-ui", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13421", + "title": " Remove functions from globals", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13420", + "title": " Remove unused files and code in rc-lib - step 3", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13419", + "title": " Remove unused files in rc-lib - step 2", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13416", + "title": " Remove unused files and code in rc-lib - step 1", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13415", + "title": " Convert rocketchat-lib to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13496", + "title": "Regression: Message box geolocation was throwing error", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan", + "MarcosSpessatto", + "web-flow" + ] + }, + { + "pr": "13414", + "title": " Import missed functions to remove dependency of RC namespace", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13409", + "title": " Convert rocketchat-apps to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13405", + "title": "Remove dependency of RC namespace in root server folder - step 6", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13402", + "title": "Remove dependency of RC namespace in root server folder - step 5", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13400", + "title": " Remove dependency of RC namespace in root server folder - step 4", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13398", + "title": "Remove dependency of RC namespace in root server folder - step 3", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13397", + "title": "Remove dependency of RC namespace in root server folder - step 2", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13390", + "title": " Remove dependency of RC namespace in root server folder - step 1", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13389", + "title": " Remove dependency of RC namespace in root client folder, imports/message-read-receipt and imports/personal-access-tokens", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13386", + "title": " Remove dependency of RC namespace in rc-integrations and importer-hipchat-enterprise", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13384", + "title": "Move rc-livechat server models to rc-models", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13383", + "title": " Remove dependency of RC namespace in rc-livechat/server/publications", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13382", + "title": "Remove dependency of RC namespace in rc-livechat/server/methods", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13379", + "title": "Remove dependency of RC namespace in rc-livechat/imports, lib, server/api, server/hooks and server/lib", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13378", + "title": " Remove LIvechat global variable from RC namespace", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13377", + "title": "Remove dependency of RC namespace in rc-livechat/server/models", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13370", + "title": " Remove dependency of RC namespace in livechat/client", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13492", + "title": "Remove dependency of RC namespace in rc-wordpress, chatpal-search and irc", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13366", + "title": " Remove dependency of RC namespace in rc-videobridge and webdav", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13365", + "title": " Remove dependency of RC namespace in rc-ui-master, ui-message- user-data-download and version-check", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13362", + "title": "Remove dependency of RC namespace in rc-ui-clean-history, ui-admin and ui-login", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13361", + "title": " Remove dependency of RC namespace in rc-ui, ui-account and ui-admin", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13359", + "title": " Remove dependency of RC namespace in rc-statistics and tokenpass", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13358", + "title": " Remove dependency of RC namespace in rc-smarsh-connector, sms and spotify", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13357", + "title": "Remove dependency of RC namespace in rc-slash-kick, leave, me, msg, mute, open, topic and unarchiveroom", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13356", + "title": " Remove dependency of RC namespace in rc-slash-archiveroom, create, help, hide, invite, inviteall and join", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13348", + "title": "Remove dependency of RC namespace in rc-setup-wizard, slackbridge and asciiarts", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13347", + "title": " Remove dependency of RC namespace in rc-reactions, retention-policy and search", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13345", + "title": " Remove dependency of RC namespace in rc-oembed and rc-otr", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13344", + "title": "Remove dependency of RC namespace in rc-oauth2-server and message-star", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13343", + "title": " Remove dependency of RC namespace in rc-message-pin and message-snippet", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13482", + "title": "[FIX] Sidenav mouse hover was slow", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok", + "tassoevan" + ] + }, + { + "pr": "13483", + "title": "Depackaging", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13447", + "title": "[FIX] Emoji detection at line breaks", + "userLogin": "savish28", + "milestone": "1.0.0", + "contributors": [ + "savish28", + "web-flow" + ] + }, + { + "pr": "13471", + "title": "Room loading improvements", + "userLogin": "rodrigok", + "milestone": "0.74.3", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13360", + "title": "[FIX] Invalid condition on getting next livechat agent over REST API endpoint", + "userLogin": "renatobecker", + "milestone": "0.74.3", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "13417", + "title": "[IMPROVE] Open rooms quicker", + "userLogin": "rodrigok", + "milestone": "0.74.3", + "contributors": [ + "rodrigok", + "web-flow" + ] + }, + { + "pr": "13457", + "title": "[FIX] \"Test Desktop Notifications\" not triggering a notification", + "userLogin": "tassoevan", + "milestone": "0.74.3", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "13463", + "title": "[FIX] Translated and incorrect i18n variables", + "userLogin": "leonboot", + "milestone": "0.74.3", + "contributors": [ + "leonboot", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13456", + "title": "Regression: Remove console.log on email translations", + "userLogin": "sampaiodiego", + "milestone": "0.74.3", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13408", + "title": "[FIX] Properly escape custom emoji names for pattern matching", + "userLogin": "tassoevan", + "milestone": "0.74.3", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "13444", + "title": "[FIX] Small improvements on message box", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "11698", + "title": "[IMPROVE] KaTeX and Autolinker message rendering", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan", + "web-flow" + ] + }, + { + "pr": "13452", + "title": "[FIX] Not translated emails", + "userLogin": "sampaiodiego", + "milestone": "0.74.3", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13435", + "title": "Merge master into develop & Set version to 1.0.0-develop", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego", + "rodrigok", + "web-flow", + "graywolf336", + "theundefined", + "TkTech", + "MarcosSpessatto", + "geekgonecrazy", + "d-gubert", + "renatobecker", + "Hudell" + ] + }, + { + "pr": "13244", + "title": "[FIX] Update Russian localization", + "userLogin": "BehindLoader", + "milestone": "0.74.3", + "contributors": [ + "BehindLoader", + "web-flow", + "tassoevan" + ] + }, + { + "pr": "13437", + "title": "[FIX] XML-decryption module not found", + "userLogin": "Hudell", + "milestone": "0.74.3", + "contributors": [ + "Hudell" + ] + }, + { + "pr": "13436", + "title": "[IMPROVE] Allow configure Prometheus port per process via Environment Variable", + "userLogin": "rodrigok", + "milestone": "0.74.3", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13430", + "title": "[IMPROVE] Add API option \"permissionsRequired\"", + "userLogin": "d-gubert", + "milestone": "0.74.3", + "contributors": [ + "d-gubert", + "web-flow" + ] + }, + { + "pr": "13336", + "title": "[FIX] Several Problems on HipChat Importer", + "userLogin": "Hudell", + "milestone": "0.74.3", + "contributors": [ + "rodrigok", + "Hudell", + "web-flow" + ] + }, + { + "pr": "13423", + "title": "[FIX] Invalid push gateway configuration, requires the uniqueId", + "userLogin": "graywolf336", + "milestone": "0.74.3", + "contributors": [ + "graywolf336" + ] + }, + { + "pr": "13396", + "title": "[IMPROVE] Update to MongoDB 4.0 in docker-compose file", + "userLogin": "ngulden", + "contributors": [ + "ngulden" + ] + }, + { + "pr": "7929", + "title": "[NEW] User avatars from external source", + "userLogin": "mjovanovic0", + "milestone": "1.0.0", + "contributors": [ + "mjovanovic0", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "13411", + "title": "Regression: Table admin pages", + "userLogin": "ggazzo", + "contributors": [ + "tassoevan", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "13410", + "title": "Regression: Template error", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan", + "ggazzo" + ] + }, + { + "pr": "13407", + "title": "[FIX] Misaligned upload progress bar \"cancel\" button", + "userLogin": "tassoevan", + "milestone": "0.74.3", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "13406", + "title": "Removed old templates", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "13393", + "title": "[IMPROVE] Admin ui", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "11451", + "title": "[FIX] Fixing rooms find by type and name", + "userLogin": "hmagarotto", + "milestone": "1.0.0", + "contributors": [ + "hmagarotto", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "13363", + "title": "[FIX] linear-gradient background on safari", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "13401", + "title": "[IMPROVE] End to end tests", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego", + "ggazzo" + ] + }, + { + "pr": "12380", + "title": "[IMPROVE] Update deleteUser errors to be more semantic", + "userLogin": "timkinnane", + "contributors": [ + "timkinnane", + "web-flow", + "ggazzo" + ] + }, + { + "pr": "13369", + "title": "[FIX] Notify private settings changes even on public settings changed", + "userLogin": "tassoevan", + "milestone": "0.74.3", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "11558", + "title": "[FIX] Fixed text for \"bulk-register-user\"", + "userLogin": "the4ndy", + "milestone": "1.0.0", + "contributors": [ + "the4ndy", + "web-flow", + "tassoevan" + ] + }, + { + "pr": "13350", + "title": "[FIX] Pass token for cloud register", + "userLogin": "geekgonecrazy", + "milestone": "0.74.2", + "contributors": [ + "geekgonecrazy" + ] + }, + { + "pr": "13342", + "title": "[IMPROVE] Send `uniqueID` to all clients so Jitsi rooms can be created correctly", + "userLogin": "sampaiodiego", + "milestone": "0.74.2", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13349", + "title": "[FIX] Setup wizard calling 'saveSetting' for each field/setting", + "userLogin": "ggazzo", + "milestone": "0.74.2", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "13326", + "title": "[FIX] Rate Limiter was limiting communication between instances", + "userLogin": "rodrigok", + "milestone": "0.74.2", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "11673", + "title": "[IMPROVE] Line height on static content pages", + "userLogin": "timkinnane", + "milestone": "1.0.0", + "contributors": [ + "timkinnane", + "web-flow", + "ggazzo" + ] + }, + { + "pr": "13289", + "title": "[IMPROVE] new icons", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "13311", + "title": "[NEW] Limit all DDP/Websocket requests (configurable via admin panel)", + "userLogin": "rodrigok", + "milestone": "0.74.1", + "contributors": [ + "rodrigok", + "web-flow" + ] + }, + { + "pr": "13322", + "title": "[FIX] Mobile view and re-enable E2E tests", + "userLogin": "sampaiodiego", + "milestone": "0.74.1", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13308", + "title": "[NEW] REST endpoint to forward livechat rooms", + "userLogin": "renatobecker", + "milestone": "0.74.1", + "contributors": [ + "renatobecker", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13293", + "title": "[FIX] Hipchat Enterprise Importer not generating subscriptions", + "userLogin": "Hudell", + "milestone": "0.74.1", + "contributors": [ + "Hudell", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13294", + "title": "[FIX] Message updating by Apps", + "userLogin": "sampaiodiego", + "milestone": "0.74.1", + "contributors": [ + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13306", + "title": "[FIX] REST endpoint for creating custom emojis", + "userLogin": "sampaiodiego", + "milestone": "0.74.1", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13303", + "title": "[FIX] Preview of image uploads were not working when apps framework is enable", + "userLogin": "rodrigok", + "milestone": "0.74.1", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13221", + "title": "[FIX] HipChat Enterprise importer fails when importing a large amount of messages (millions)", + "userLogin": "Hudell", + "milestone": "0.74.1", + "contributors": [ + "Hudell", + "tassoevan" + ] + }, + { + "pr": "11525", + "title": "[NEW] Collect data for Monthly/Daily Active Users for a future dashboard", + "userLogin": "renatobecker", + "milestone": "0.74.1", + "contributors": [ + "renatobecker", + "rodrigok" + ] + }, + { + "pr": "13248", + "title": "[NEW] Add parseUrls field to the apps message converter", + "userLogin": "d-gubert", + "milestone": "0.74.1", + "contributors": [ + "d-gubert", + "web-flow" + ] + }, + { + "pr": "13282", + "title": "Fix: Missing export in cloud package", + "userLogin": "geekgonecrazy", + "milestone": "0.74.1", + "contributors": [ + "geekgonecrazy", + "web-flow" + ] + }, + { + "pr": "12341", + "title": "[FIX] Fix bug when user try recreate channel or group with same name and remove room from cache when user leaves room", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.1", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "13474", + "title": "Release 0.74.3", + "userLogin": "sampaiodiego", + "contributors": [ + "tassoevan", + "sampaiodiego", + "graywolf336", + "Hudell", + "d-gubert", + "rodrigok", + "BehindLoader", + "leonboot", + "renatobecker" + ] + }, + { + "pr": "13471", + "title": "Room loading improvements", + "userLogin": "rodrigok", + "milestone": "0.74.3", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13360", + "title": "[FIX] Invalid condition on getting next livechat agent over REST API endpoint", + "userLogin": "renatobecker", + "milestone": "0.74.3", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "13417", + "title": "[IMPROVE] Open rooms quicker", + "userLogin": "rodrigok", + "milestone": "0.74.3", + "contributors": [ + "rodrigok", + "web-flow" + ] + }, + { + "pr": "13457", + "title": "[FIX] \"Test Desktop Notifications\" not triggering a notification", + "userLogin": "tassoevan", + "milestone": "0.74.3", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "13463", + "title": "[FIX] Translated and incorrect i18n variables", + "userLogin": "leonboot", + "milestone": "0.74.3", + "contributors": [ + "leonboot", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13456", + "title": "Regression: Remove console.log on email translations", + "userLogin": "sampaiodiego", + "milestone": "0.74.3", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13408", + "title": "[FIX] Properly escape custom emoji names for pattern matching", + "userLogin": "tassoevan", + "milestone": "0.74.3", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "13452", + "title": "[FIX] Not translated emails", + "userLogin": "sampaiodiego", + "milestone": "0.74.3", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13437", + "title": "[FIX] XML-decryption module not found", + "userLogin": "Hudell", + "milestone": "0.74.3", + "contributors": [ + "Hudell" + ] + }, + { + "pr": "13244", + "title": "[FIX] Update Russian localization", + "userLogin": "BehindLoader", + "milestone": "0.74.3", + "contributors": [ + "BehindLoader", + "web-flow", + "tassoevan" + ] + }, + { + "pr": "13436", + "title": "[IMPROVE] Allow configure Prometheus port per process via Environment Variable", + "userLogin": "rodrigok", + "milestone": "0.74.3", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13430", + "title": "[IMPROVE] Add API option \"permissionsRequired\"", + "userLogin": "d-gubert", + "milestone": "0.74.3", + "contributors": [ + "d-gubert", + "web-flow" + ] + }, + { + "pr": "13336", + "title": "[FIX] Several Problems on HipChat Importer", + "userLogin": "Hudell", + "milestone": "0.74.3", + "contributors": [ + "rodrigok", + "Hudell", + "web-flow" + ] + }, + { + "pr": "13423", + "title": "[FIX] Invalid push gateway configuration, requires the uniqueId", + "userLogin": "graywolf336", + "milestone": "0.74.3", + "contributors": [ + "graywolf336" + ] + }, + { + "pr": "13369", + "title": "[FIX] Notify private settings changes even on public settings changed", + "userLogin": "tassoevan", + "milestone": "0.74.3", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "13407", + "title": "[FIX] Misaligned upload progress bar \"cancel\" button", + "userLogin": "tassoevan", + "milestone": "0.74.3", + "contributors": [ + "tassoevan" + ] + } + ] + }, + "1.0.0-rc.1": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "13815", + "title": "[NEW] Add an option to delete file in files list", + "userLogin": "marceloschmidt", + "milestone": "1.0.0", + "contributors": [ + "marceloschmidt", + "engelgabriel", + "web-flow", + "sampaiodiego", + "d-gubert" + ] + }, + { + "pr": "13996", + "title": "[NEW] Threads V 1.0", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "sampaiodiego" + ] + }, + { + "pr": "12834", + "title": "Add pagination to getUsersOfRoom", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "web-flow", + "engelgabriel", + "sampaiodiego", + "Hudell", + "rodrigok" + ] + }, + { + "pr": "13925", + "title": "OpenShift custom OAuth support", + "userLogin": "bsharrow", + "milestone": "1.0.0", + "contributors": [ + "bsharrow", + "web-flow", + "geekgonecrazy" + ] + }, + { + "pr": "14026", + "title": "Settings: disable reset button", + "userLogin": "alansikora", + "milestone": "1.0.0", + "contributors": [ + "alansikora", + "engelgabriel", + "web-flow" + ] + }, + { + "pr": "14025", + "title": "Settings: hiding reset button for readonly fields", + "userLogin": "alansikora", + "milestone": "1.0.0", + "contributors": [ + "alansikora", + "engelgabriel", + "web-flow" + ] + }, + { + "pr": "13510", + "title": "[NEW] Add support to updatedSince parameter in emoji-custom.list and deprecated old endpoint", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "13884", + "title": "[IMPROVE] Add permission to change other user profile avatar", + "userLogin": "knrt10", + "milestone": "1.0.0", + "contributors": [ + "knrt10", + "marceloschmidt", + "web-flow", + "rodrigok" + ] + }, + { + "pr": "13732", + "title": "[IMPROVE] UI of Permissions page", + "userLogin": "fliptrail", + "milestone": "1.0.0", + "contributors": [ + "fliptrail", + "engelgabriel", + "web-flow", + "marceloschmidt" + ] + }, + { + "pr": "13829", + "title": "[NEW] Chatpal: Enable custom search parameters", + "userLogin": "Peym4n", + "milestone": "1.0.0", + "contributors": [ + "Peym4n", + "web-flow" + ] + }, + { + "pr": "13842", + "title": "[FIX] Closing sidebar when room menu is clicked.", + "userLogin": "Kailash0311", + "milestone": "1.0.0", + "contributors": [ + "Kailash0311", + "sampaiodiego", + "web-flow", + "engelgabriel", + "rodrigok" + ] + }, + { + "pr": "14021", + "title": "[FIX] Check settings for name requirement before validating", + "userLogin": "marceloschmidt", + "contributors": [ + "marceloschmidt", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "13979", + "title": "Fix debug logging not being enabled by the setting", + "userLogin": "graywolf336", + "milestone": "1.0.0", + "contributors": [ + "graywolf336", + "geekgonecrazy", + "web-flow", + "engelgabriel", + "rodrigok" + ] + }, + { + "pr": "13982", + "title": "[FIX] Links and upload paths when running in a subdir", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13532", + "title": "[FIX] users.getPreferences when the user doesn't have any preferences", + "userLogin": "thayannevls", + "milestone": "1.0.0", + "contributors": [ + "thayannevls" + ] + }, + { + "pr": "13495", + "title": "[FIX] Real names were not displayed in the reactions (API/UI)", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "tassoevan", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "13798", + "title": "Deprecate /api/v1/info in favor of /api/info", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13776", + "title": "Change dynamic dependency of FileUpload in Messages models", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow", + "engelgabriel" + ] + }, + { + "pr": "14017", + "title": "Allow set env var METEOR_OPLOG_TOO_FAR_BEHIND", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok", + "geekgonecrazy", + "web-flow", + "engelgabriel" + ] + }, + { + "pr": "14015", + "title": "[FIX] Theme CSS loading in subdir env", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13250", + "title": "[FIX] Fix rendering of links in the announcement modal", + "userLogin": "supra08", + "milestone": "1.0.0", + "contributors": [ + "supra08", + "tassoevan" + ] + }, + { + "pr": "13791", + "title": "[IMPROVE] Use SessionId for credential token in SAML request", + "userLogin": "MohammedEssehemy", + "milestone": "1.0.0", + "contributors": [ + "MohammedEssehemy", + "web-flow", + "engelgabriel" + ] + }, + { + "pr": "13969", + "title": "[FIX] Add custom MIME types for *.ico extension", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "13994", + "title": "[FIX] Groups endpoints permission validations", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13981", + "title": "[FIX] Focus on input when emoji picker box is open was not working", + "userLogin": "d-gubert", + "milestone": "1.0.0", + "contributors": [ + "d-gubert", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "13984", + "title": "Improve: Decrease padding for app buy modal", + "userLogin": "geekgonecrazy", + "milestone": "1.0.0", + "contributors": [ + "geekgonecrazy" + ] + }, + { + "pr": "13983", + "title": "[NEW] - Add setting to request a comment when closing Livechat room", + "userLogin": "knrt10", + "milestone": "1.0.0", + "contributors": [ + "knrt10", + "renatobecker", + "web-flow" + ] + }, + { + "pr": "13824", + "title": "[FIX] Auto hide Livechat room from sidebar on close", + "userLogin": "knrt10", + "milestone": "1.0.0", + "contributors": [ + "knrt10", + "renatobecker", + "web-flow" + ] + }, + { + "pr": "13927", + "title": "[BREAK] Prevent start if incompatible mongo version", + "userLogin": "geekgonecrazy", + "milestone": "1.0.0", + "contributors": [ + "geekgonecrazy" + ] + }, + { + "pr": "13820", + "title": "[FIX] Improve cloud section", + "userLogin": "geekgonecrazy", + "milestone": "1.0.0", + "contributors": [ + "geekgonecrazy", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13746", + "title": "[FIX] Wrong permalink when running in subdir", + "userLogin": "ura14h", + "milestone": "1.0.0", + "contributors": [ + "ura14h", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13968", + "title": "[FIX] Change localStorage keys to work when server is running in a subdir", + "userLogin": "MarcosSpessatto", + "contributors": [ + "MarcosSpessatto" + ] + } + ] + }, + "1.0.0-rc.2": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "14057", + "title": "Prioritize user-mentions badge", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "14047", + "title": "[IMPROVE] Include more information to help with bug reports and debugging", + "userLogin": "geekgonecrazy", + "milestone": "1.0.0", + "contributors": [ + "geekgonecrazy", + "sampaiodiego" + ] + }, + { + "pr": "14030", + "title": "[IMPROVE] New sidebar item badges, mention links, and ticks", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "14049", + "title": "Proper thread quote, clear message box on send, and other nice things to have", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "14054", + "title": "Fix: Tests were not exiting RC instances", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "14048", + "title": "Fix shield indentation", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "14052", + "title": "Fix modal scroll", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "14041", + "title": "Fix race condition of lastMessage set", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "14044", + "title": "Fix room re-rendering", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "sampaiodiego" + ] + }, + { + "pr": "14043", + "title": "Fix sending notifications to mentions on threads and discussion email sender", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "14018", + "title": "Fix discussions issues after room deletion and translation actions not being shown", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "web-flow", + "engelgabriel", + "sampaiodiego" + ] + } + ] + }, + "1.0.0-rc.3": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "14053", + "title": "Show discussion avatar", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego", + "d-gubert", + "web-flow", + "rodrigok" + ] + }, + { + "pr": "14179", + "title": "[FIX] SAML certificate settings don't follow a pattern", + "userLogin": "Hudell", + "milestone": "1.0.0", + "contributors": [ + "Hudell" + ] + }, + { + "pr": "14180", + "title": "Fix threads tests", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "14160", + "title": "Prevent error for ldap login with invalid characters", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13992", + "title": "[IMPROVE] Remove setting to show a livechat is waiting", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker", + "web-flow", + "engelgabriel", + "sampaiodiego" + ] + }, + { + "pr": "14174", + "title": "[REGRESSION] Messages sent by livechat's guests are losing sender info", + "userLogin": "d-gubert", + "milestone": "1.0.0", + "contributors": [ + "d-gubert", + "renatobecker", + "web-flow" + ] + }, + { + "pr": "14045", + "title": "[NEW] Rest threads", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "web-flow" + ] + }, + { + "pr": "14171", + "title": "Faster CI build for PR", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "14161", + "title": "Regression: Message box does not go back to initial state after sending a message", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok", + "tassoevan" + ] + }, + { + "pr": "14170", + "title": "Prevent error on normalize thread message for preview", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "14137", + "title": "[IMPROVE] Attachment download caching", + "userLogin": "wreiske", + "milestone": "1.0.0", + "contributors": [ + "wreiske", + "d-gubert", + "web-flow" + ] + }, + { + "pr": "14147", + "title": "[NEW] Add GET method to fetch Livechat message through REST API", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker", + "web-flow" + ] + }, + { + "pr": "14121", + "title": "[FIX] Custom Oauth store refresh and id tokens with expiresIn", + "userLogin": "ralfbecker", + "contributors": [ + "ralfbecker", + "geekgonecrazy", + "web-flow", + "rodrigok" + ] + }, + { + "pr": "14071", + "title": "Update badges and mention links colors", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "14028", + "title": "[FIX] Apps converters delete fields on message attachments", + "userLogin": "d-gubert", + "milestone": "1.0.0", + "contributors": [ + "d-gubert", + "web-flow", + "rodrigok" + ] + }, + { + "pr": "14131", + "title": "[IMPROVE] Get avatar from oauth", + "userLogin": "geekgonecrazy", + "contributors": [ + "geekgonecrazy", + "web-flow" + ] + }, + { + "pr": "13761", + "title": "[IMPROVE] OAuth Role Sync", + "userLogin": "hypery2k", + "contributors": [ + "hypery2k", + "engelgabriel", + "web-flow", + "geekgonecrazy" + ] + }, + { + "pr": "14113", + "title": "[FIX] Custom Oauth login not working with accessToken", + "userLogin": "knrt10", + "milestone": "1.0.0", + "contributors": [ + "knrt10", + "geekgonecrazy", + "web-flow" + ] + }, + { + "pr": "14099", + "title": "Smaller thread replies and system messages", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan", + "sampaiodiego", + "web-flow", + "rodrigok", + "engelgabriel" + ] + }, + { + "pr": "14148", + "title": "[FIX] renderField template to correct short property usage", + "userLogin": "d-gubert", + "contributors": [ + "d-gubert" + ] + }, + { + "pr": "14129", + "title": "[FIX] Updating a message from apps if keep history is on", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego", + "d-gubert", + "web-flow" + ] + }, + { + "pr": "13697", + "title": "[NEW] Add Voxtelesys to list of SMS providers", + "userLogin": "john08burke", + "milestone": "1.0.0", + "contributors": [ + "jhnburke8", + "engelgabriel", + "web-flow", + "john08burke", + "sampaiodiego" + ] + }, + { + "pr": "14130", + "title": "[FIX] Missing connection headers on Livechat REST API", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "14125", + "title": "Regression: User autocomplete was not listing users from correct room", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "14097", + "title": "Regression: Role creation and deletion error fixed", + "userLogin": "knrt10", + "milestone": "1.0.0", + "contributors": [ + "knrt10" + ] + }, + { + "pr": "14111", + "title": "[Regression] Fix integrations message example", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "geekgonecrazy", + "web-flow" + ] + }, + { + "pr": "14118", + "title": "Fix update apps capability of updating messages", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "14100", + "title": "Fix: Skip thread notifications on message edit", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "14116", + "title": "Fix: Remove message class `sequential` if `new-day` is present", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "14103", + "title": "[FIX] Receiving agent for new livechats from REST API", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker", + "web-flow" + ] + }, + { + "pr": "14102", + "title": "Fix top bar unread message counter", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "13987", + "title": "[NEW] Rest endpoints of discussions", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "engelgabriel", + "web-flow", + "rodrigok", + "d-gubert" + ] + }, + { + "pr": "10695", + "title": "[FIX] Livechat user registration in another department", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker", + "sampaiodiego", + "web-flow", + "ggazzo", + "engelgabriel" + ] + }, + { + "pr": "14046", + "title": "LingoHub based on develop", + "userLogin": "engelgabriel", + "contributors": [ + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "14074", + "title": "[FIX] Support for handling SAML LogoutRequest SLO", + "userLogin": "geekgonecrazy", + "milestone": "1.0.0", + "contributors": [ + "geekgonecrazy", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "14101", + "title": "Fix sending message from action buttons in messages", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "14089", + "title": "Fix: Error when version check endpoint was returning invalid data", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "14066", + "title": "Wait port release to finish tests", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok", + "d-gubert", + "web-flow" + ] + }, + { + "pr": "14059", + "title": "Fix threads rendering performance", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "web-flow", + "tassoevan", + "sampaiodiego" + ] + }, + { + "pr": "14076", + "title": "Unstuck observers every minute", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "14031", + "title": "[FIX] Livechat office hours", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker", + "MarcosSpessatto", + "web-flow" + ] + }, + { + "pr": "14051", + "title": "Fix messages losing thread titles on editing or reaction and improve message actions", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "14072", + "title": "[IMPROVE] Update the Apps Engine version to v1.4.1", + "userLogin": "graywolf336", + "contributors": [ + "graywolf336" + ] + } + ] + }, + "1.0.0-rc.4": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "14262", + "title": "[FIX] Auto-translate toggle not updating rendered messages", + "userLogin": "marceloschmidt", + "contributors": [ + "marceloschmidt", + "web-flow" + ] + }, + { + "pr": "14265", + "title": "[FIX] Align burger menu in header with content matching room header", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan", + "web-flow" + ] + }, + { + "pr": "14266", + "title": "Improve message validation", + "userLogin": "d-gubert", + "milestone": "1.0.0", + "contributors": [ + "d-gubert" + ] + }, + { + "pr": "14007", + "title": "Added federation ping, loopback and dashboard", + "userLogin": "alansikora", + "contributors": [ + "alansikora", + "rodrigok" + ] + }, + { + "pr": "14012", + "title": "[FIX] Normalize TAPi18n language string on Livechat widget", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker", + "web-flow" + ] + }, + { + "pr": "14163", + "title": "[FIX] Autogrow not working properly for many message boxes", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "14207", + "title": "[FIX] Image attachment re-renders on message update", + "userLogin": "Kailash0311", + "milestone": "1.0.0", + "contributors": [ + "Kailash0311", + "web-flow" + ] + }, + { + "pr": "14251", + "title": "Regression: Exception on notification when adding someone in room via mention", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "14246", + "title": "Regression: fix grouping for reactive message", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "d-gubert", + "web-flow" + ] + }, + { + "pr": "11346", + "title": "[NEW] Multiple slackbridges", + "userLogin": "Hudell", + "milestone": "1.0.0", + "contributors": [ + "kable-wilmoth", + "Hudell", + "web-flow", + "engelgabriel" + ] + }, + { + "pr": "14010", + "title": "[FIX] Sidenav does not open on some admin pages", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto", + "tassoevan" + ] + }, + { + "pr": "14245", + "title": "Regression: Cursor position set to beginning when editing a message", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok", + "tassoevan" + ] + }, + { + "pr": "14244", + "title": "[FIX] Empty result when getting badge count notification", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker", + "web-flow" + ] + }, + { + "pr": "14224", + "title": "[NEW] option to not use nrr (experimental)", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "14238", + "title": "Regression: grouping messages on threads", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "14236", + "title": "[NEW]Set up livechat connections created from new client", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker", + "web-flow" + ] + }, + { + "pr": "14235", + "title": "Regression: Remove border from unstyled message body", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "14234", + "title": "Move LDAP Escape to login handler", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "14227", + "title": "[BREAK] Require OPLOG/REPLICASET to run Rocket.Chat", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "14216", + "title": "[Regression] Personal Access Token list fixed", + "userLogin": "knrt10", + "milestone": "1.0.0", + "contributors": [ + "knrt10" + ] + }, + { + "pr": "14226", + "title": "ESLint: Add more import rules", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "14225", + "title": "Regression: fix drop file", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "d-gubert", + "web-flow" + ] + }, + { + "pr": "14222", + "title": "Broken styles in Administration's contextual bar", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "14223", + "title": "Regression: Broken UI for messages", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "14220", + "title": "Exit process on unhandled rejection", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok", + "geekgonecrazy", + "web-flow" + ] + }, + { + "pr": "14217", + "title": "Unify mime-type package configuration", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "14219", + "title": "Regression: Prevent startup errors for mentions parsing", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "14189", + "title": "Regression: System messages styling", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan", + "web-flow" + ] + }, + { + "pr": "14214", + "title": "[NEW] allow drop files on thread", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "web-flow", + "tassoevan" + ] + }, + { + "pr": "14188", + "title": "[FIX] Obey audio notification preferences", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "14215", + "title": "Prevent click on reply thread to trigger flex tab closing", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "14177", + "title": "created function to allow change default values, fix loading search users", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "14213", + "title": "Use main message as thread tab title", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "14210", + "title": "Use own logic to get thread infos via REST", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "14192", + "title": "Regression: wrong expression at messageBox.actions.remove()", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "14185", + "title": "Increment user counter on DMs", + "userLogin": "sampaiodiego", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "14184", + "title": "[REGRESSION] Fix variable name references in message template", + "userLogin": "d-gubert", + "milestone": "1.0.0", + "contributors": [ + "d-gubert" + ] + } + ] + }, + "1.0.0-rc.5": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "14276", + "title": "Regression: Active room was not being marked", + "userLogin": "rodrigok", + "milestone": "1.0.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "14211", + "title": "Rename Cloud to Connectivity Services & split Apps in Apps and Marketplace", + "userLogin": "geekgonecrazy", + "milestone": "1.0.0", + "contributors": [ + "geekgonecrazy", + "engelgabriel", + "web-flow", + "rodrigok" + ] + }, + { + "pr": "14178", + "title": "LingoHub based on develop", + "userLogin": "engelgabriel", + "milestone": "1.0.0", + "contributors": [ + "sampaiodiego", + "rodrigok" + ] + }, + { + "pr": "13986", + "title": "[IMPROVE] Replace livechat inquiry dialog with preview room", + "userLogin": "renatobecker", + "milestone": "1.0.0", + "contributors": [ + "renatobecker", + "web-flow", + "engelgabriel", + "sampaiodiego", + "tassoevan" + ] + }, + { + "pr": "14050", + "title": "Regression: Discussions were not showing on Tab Bar", + "userLogin": "knrt10", + "milestone": "1.0.0", + "contributors": [ + "knrt10", + "tassoevan" + ] + }, + { + "pr": "14274", + "title": "Force unstyling of blockquote under .message-body--unstyled", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "14273", + "title": "[FIX] Slackbridge private channels", + "userLogin": "Hudell", + "milestone": "1.0.0", + "contributors": [ + "nylen", + "web-flow", + "MarcosSpessatto", + "sampaiodiego", + "Hudell" + ] + }, + { + "pr": "14081", + "title": "[FIX] View All members button now not in direct room", + "userLogin": "knrt10", + "milestone": "1.0.0", + "contributors": [ + "knrt10", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "14229", + "title": "Regression: Admin embedded layout", + "userLogin": "tassoevan", + "milestone": "1.0.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "14268", + "title": "[NEW] Update message actions", + "userLogin": "MarcosSpessatto", + "milestone": "1.0.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "14269", + "title": "New threads layout", + "userLogin": "ggazzo", + "milestone": "1.0.0", + "contributors": [ + "ggazzo", + "rodrigok" + ] + }, + { + "pr": "14258", + "title": "Improve: Marketplace auth inside Rocket.Chat instead of inside the iframe. ", + "userLogin": "geekgonecrazy", + "contributors": [ + "geekgonecrazy", + "d-gubert", + "web-flow" + ] + }, + { + "pr": "14150", + "title": "[New] Reply privately to group messages", + "userLogin": "bhardwajaditya", + "milestone": "1.0.0", + "contributors": [ + "bhardwajaditya", + "engelgabriel", + "web-flow", + "MarcosSpessatto" + ] + } + ] + }, + "1.0.0": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "13474", + "title": "Release 0.74.3", + "userLogin": "sampaiodiego", + "contributors": [ + "tassoevan", + "sampaiodiego", + "graywolf336", + "Hudell", + "d-gubert", + "rodrigok", + "BehindLoader", + "leonboot", + "renatobecker" + ] + }, { "pr": "13471", "title": "Room loading improvements", diff --git a/.gitignore b/.gitignore index ae7a0245e404..1d778eda57fe 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,6 @@ settings.json build.sh /public/livechat packages/rocketchat-i18n/i18n/livechat.* +tests/end-to-end/temporary_staged_test +.screenshots +/private/livechat diff --git a/.meteor/packages b/.meteor/packages index 59334fb36537..6e985dfac1d2 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -3,7 +3,7 @@ # 'meteor add' and 'meteor remove' will edit this file for you, # but you can also edit it by hand. -rocketchat:cors +rocketchat:mongo-config accounts-facebook@1.3.2 accounts-github@1.4.2 @@ -16,7 +16,7 @@ check@1.3.1 ddp-rate-limiter@1.0.7 ddp-common@1.4.0 dynamic-import@0.5.0 -ecmascript@0.12.3 +ecmascript@0.12.4 ejson@1.1.0 email@1.2.3 fastclick@1.0.13 @@ -38,124 +38,11 @@ spacebars standard-minifier-js@2.4.0 tracker@1.2.0 -rocketchat:2fa -rocketchat:action-links -rocketchat:accounts -rocketchat:analytics -rocketchat:api -rocketchat:assets -rocketchat:authorization -rocketchat:autolinker -rocketchat:autotranslate -rocketchat:bot-helpers -rocketchat:cas -rocketchat:channel-settings -rocketchat:channel-settings-mail-messages -rocketchat:cloud -rocketchat:colors -rocketchat:crowd -rocketchat:custom-oauth -rocketchat:custom-sounds -rocketchat:dolphin -rocketchat:drupal -rocketchat:emoji -rocketchat:emoji-custom -rocketchat:emoji-emojione -rocketchat:error-handler -rocketchat:favico -rocketchat:file -rocketchat:file-upload -rocketchat:github-enterprise -rocketchat:gitlab #rocketchat:google-natural-language -rocketchat:google-vision -rocketchat:grant -rocketchat:grant-facebook -rocketchat:grant-github -rocketchat:grant-google -rocketchat:graphql -rocketchat:highlight-words -rocketchat:iframe-login -rocketchat:importer -rocketchat:importer-csv -rocketchat:importer-hipchat -rocketchat:importer-hipchat-enterprise -rocketchat:importer-slack -rocketchat:importer-slack-users -rocketchat:integrations -rocketchat:irc -rocketchat:issuelinks -rocketchat:katex -rocketchat:ldap -rocketchat:lib rocketchat:livechat -rocketchat:livestream -rocketchat:logger -rocketchat:login-token -rocketchat:mailer -rocketchat:mapview -rocketchat:markdown -rocketchat:mentions -rocketchat:mentions-flextab -rocketchat:message-action -rocketchat:message-attachments -rocketchat:message-mark-as-unread -rocketchat:message-pin -rocketchat:message-snippet -rocketchat:message-star -rocketchat:migrations rocketchat:monitoring -rocketchat:oauth2-server-config -rocketchat:oembed -rocketchat:otr -rocketchat:push-notifications -rocketchat:reactions -rocketchat:retention-policy -rocketchat:apps -rocketchat:sandstorm -rocketchat:setup-wizard -rocketchat:slackbridge -rocketchat:slashcommands-archive -rocketchat:slashcommands-asciiarts -rocketchat:slashcommands-create -rocketchat:slashcommands-help -rocketchat:slashcommands-hide -rocketchat:slashcommands-invite -rocketchat:slashcommands-invite-all -rocketchat:slashcommands-join -rocketchat:slashcommands-kick -rocketchat:slashcommands-leave -rocketchat:slashcommands-me -rocketchat:slashcommands-msg -rocketchat:slashcommands-mute -rocketchat:slashcommands-open -rocketchat:slashcommands-topic -rocketchat:slashcommands-unarchive -rocketchat:slider -rocketchat:smarsh-connector -rocketchat:spotify -rocketchat:statistics rocketchat:streamer -rocketchat:theme -rocketchat:tokenpass -rocketchat:tooltip -rocketchat:ui -rocketchat:ui-account -rocketchat:ui-admin -rocketchat:ui-clean-history -rocketchat:ui-flextab -rocketchat:ui-login -rocketchat:ui-master -rocketchat:ui-message -rocketchat:ui-sidenav -rocketchat:ui-vrecord -rocketchat:user-data-download rocketchat:version -rocketchat:videobridge -rocketchat:webdav -rocketchat:webrtc -rocketchat:wordpress -rocketchat:nrr konecty:change-case konecty:delayed-task @@ -172,7 +59,6 @@ jparker:gravatar kadira:blaze-layout kadira:flow-router keepnox:perfect-scrollbar -kenton:accounts-sandstorm mizzao:autocomplete mizzao:timesync mrt:reactive-store @@ -184,29 +70,34 @@ pauli:accounts-linkedin raix:handlebar-helpers rocketchat:push raix:ui-dropped-event -steffo:meteor-accounts-saml todda00:friendly-slugs -yasaricli:slugify yasinuslu:blaze-meta -rocketchat:e2e -rocketchat:blockstack -rocketchat:version-check -rocketchat:search -chatpal:search -rocketchat:lazy-load tap:i18n underscore@1.0.10 -rocketchat:bigbluebutton -rocketchat:mailmessages juliancwirko:postcss littledata:synced-cron -rocketchat:utils -rocketchat:settings -rocketchat:models -rocketchat:metrics -rocketchat:callbacks -rocketchat:notifications -rocketchat:promises -rocketchat:ui-utils -rocketchat:ui-cached-collection \ No newline at end of file + +edgee:slingshot +jalik:ufs-local@0.2.5 +accounts-base +accounts-oauth +autoupdate +babel-compiler +emojione:emojione@2.2.6 +google-oauth +htmljs +less +matb33:collection-hooks +meteorhacks:inject-initial +oauth +oauth2 +raix:eventemitter +routepolicy +sha +swydo:graphql +templating +webapp +webapp-hashing +rocketchat:oauth2-server +rocketchat:i18n diff --git a/.meteor/release b/.meteor/release index 2299ae70d955..91e05fc15b2f 100644 --- a/.meteor/release +++ b/.meteor/release @@ -1 +1 @@ -METEOR@1.8.0.1 +METEOR@1.8.0.2 diff --git a/.meteor/versions b/.meteor/versions index 43a36c180102..89ee8528ed70 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -9,7 +9,7 @@ accounts-twitter@1.4.2 aldeed:simple-schema@1.5.4 allow-deny@1.1.0 autoupdate@1.5.0 -babel-compiler@7.2.3 +babel-compiler@7.2.4 babel-runtime@1.3.0 base64@1.0.11 binary-heap@1.0.11 @@ -21,7 +21,6 @@ caching-compiler@1.2.1 caching-html-compiler@1.1.3 callback-hook@1.1.0 cfs:http-methods@0.0.32 -chatpal:search@0.0.1 check@1.3.1 coffeescript@1.0.17 dandv:caret-position@2.1.1 @@ -35,7 +34,7 @@ deps@1.0.12 diff-sequence@1.1.1 dispatch:run-as-user@1.1.1 dynamic-import@0.5.1 -ecmascript@0.12.3 +ecmascript@0.12.4 ecmascript-runtime@0.7.0 ecmascript-runtime-client@0.8.0 ecmascript-runtime-server@0.7.1 @@ -68,7 +67,6 @@ juliancwirko:postcss@2.0.3 kadira:blaze-layout@2.3.0 kadira:flow-router@2.12.1 keepnox:perfect-scrollbar@0.6.8 -kenton:accounts-sandstorm@0.7.0 konecty:change-case@2.3.0 konecty:delayed-task@1.0.0 konecty:mongo-counter@0.0.5_3 @@ -117,7 +115,7 @@ ordered-dict@1.1.0 ostrio:cookies@2.3.0 pauli:accounts-linkedin@2.1.5 pauli:linkedin-oauth@1.2.0 -promise@0.11.1 +promise@0.11.2 raix:eventemitter@0.1.3 raix:eventstate@0.0.4 raix:handlebar-helpers@0.2.5 @@ -128,144 +126,14 @@ reactive-dict@1.2.1 reactive-var@1.0.11 reload@1.2.0 retry@1.1.0 -rocketchat:2fa@0.0.1 -rocketchat:accounts@0.0.1 -rocketchat:action-links@0.0.1 -rocketchat:analytics@0.0.2 -rocketchat:api@0.0.1 -rocketchat:apps@1.0.0 -rocketchat:assets@0.0.1 -rocketchat:authorization@0.0.1 -rocketchat:autolinker@0.0.1 -rocketchat:autotranslate@0.0.1 -rocketchat:bigbluebutton@0.0.1 -rocketchat:blockstack@0.0.1 -rocketchat:bot-helpers@0.0.1 -rocketchat:callbacks@0.0.1 -rocketchat:cas@1.0.0 -rocketchat:channel-settings@0.0.1 -rocketchat:channel-settings-mail-messages@0.0.1 -rocketchat:cloud@0.0.1 -rocketchat:colors@0.0.1 -rocketchat:cors@0.0.1 -rocketchat:crowd@1.0.0 -rocketchat:custom-oauth@1.0.0 -rocketchat:custom-sounds@1.0.0 -rocketchat:dolphin@0.0.2 -rocketchat:drupal@0.0.1 -rocketchat:e2e@0.0.1 -rocketchat:emoji@1.0.0 -rocketchat:emoji-custom@1.0.0 -rocketchat:emoji-emojione@0.0.1 -rocketchat:error-handler@1.0.0 -rocketchat:favico@0.0.1 -rocketchat:file@0.0.1 -rocketchat:file-upload@0.0.1 -rocketchat:github-enterprise@0.0.1 -rocketchat:gitlab@0.0.1 -rocketchat:google-vision@0.0.1 -rocketchat:grant@0.0.1 -rocketchat:grant-facebook@0.0.1 -rocketchat:grant-github@0.0.1 -rocketchat:grant-google@0.0.1 -rocketchat:graphql@0.0.1 -rocketchat:highlight-words@0.0.1 rocketchat:i18n@0.0.1 -rocketchat:iframe-login@1.0.0 -rocketchat:importer@0.0.1 -rocketchat:importer-csv@1.0.0 -rocketchat:importer-hipchat@0.0.1 -rocketchat:importer-hipchat-enterprise@1.0.0 -rocketchat:importer-slack@0.0.1 -rocketchat:importer-slack-users@1.0.0 -rocketchat:integrations@0.0.1 -rocketchat:irc@0.0.1 -rocketchat:issuelinks@0.0.1 -rocketchat:katex@0.0.1 -rocketchat:lazy-load@0.0.1 -rocketchat:ldap@0.0.1 -rocketchat:lib@0.0.1 rocketchat:livechat@0.0.1 -rocketchat:livestream@0.0.5 -rocketchat:logger@0.0.1 -rocketchat:login-token@1.0.0 -rocketchat:mailer@0.0.1 -rocketchat:mailmessages@0.0.1 -rocketchat:mapview@0.0.1 -rocketchat:markdown@0.0.2 -rocketchat:metrics@0.0.1 -rocketchat:mentions@0.0.1 -rocketchat:mentions-flextab@0.0.1 -rocketchat:message-action@0.0.1 -rocketchat:message-attachments@0.0.1 -rocketchat:message-mark-as-unread@0.0.1 -rocketchat:message-pin@0.0.1 -rocketchat:message-snippet@0.0.1 -rocketchat:message-star@0.0.1 -rocketchat:migrations@0.0.1 -rocketchat:models@1.0.0 +rocketchat:mongo-config@0.0.1 rocketchat:monitoring@2.30.2_3 -rocketchat:notifications@0.0.1 -rocketchat:nrr@1.0.0 rocketchat:oauth2-server@2.0.0 -rocketchat:oauth2-server-config@1.0.0 -rocketchat:oembed@0.0.1 -rocketchat:otr@0.0.1 -rocketchat:promises@0.0.1 rocketchat:push@3.3.1 -rocketchat:push-notifications@0.0.1 -rocketchat:reactions@0.0.1 -rocketchat:retention-policy@0.0.1 -rocketchat:sandstorm@0.0.1 -rocketchat:search@0.0.1 -rocketchat:settings@0.0.1 -rocketchat:setup-wizard@0.0.1 -rocketchat:slackbridge@0.0.1 -rocketchat:slashcommands-archive@0.0.1 -rocketchat:slashcommands-asciiarts@0.0.1 -rocketchat:slashcommands-create@0.0.1 -rocketchat:slashcommands-help@0.0.1 -rocketchat:slashcommands-hide@0.0.1 -rocketchat:slashcommands-invite@0.0.1 -rocketchat:slashcommands-invite-all@0.0.1 -rocketchat:slashcommands-join@0.0.1 -rocketchat:slashcommands-kick@0.0.1 -rocketchat:slashcommands-leave@0.0.1 -rocketchat:slashcommands-me@0.0.1 -rocketchat:slashcommands-msg@0.0.1 -rocketchat:slashcommands-mute@0.0.1 -rocketchat:slashcommands-open@0.0.1 -rocketchat:slashcommands-topic@0.0.1 -rocketchat:slashcommands-unarchive@0.0.1 -rocketchat:slider@0.0.1 -rocketchat:smarsh-connector@0.0.1 -rocketchat:sms@0.0.1 -rocketchat:spotify@0.0.1 -rocketchat:statistics@0.0.1 rocketchat:streamer@1.0.1 -rocketchat:theme@0.0.1 -rocketchat:tokenpass@0.0.1 -rocketchat:tooltip@0.0.1 -rocketchat:ui@0.1.0 -rocketchat:ui-account@0.1.0 -rocketchat:ui-admin@0.1.0 -rocketchat:ui-cached-collection@0.0.1 -rocketchat:ui-clean-history@0.0.1 -rocketchat:ui-flextab@0.1.0 -rocketchat:ui-login@0.1.0 -rocketchat:ui-master@0.1.0 -rocketchat:ui-message@0.1.0 -rocketchat:ui-sidenav@0.1.0 -rocketchat:ui-utils@0.0.1 -rocketchat:ui-vrecord@0.0.1 -rocketchat:user-data-download@1.0.0 -rocketchat:utils@0.0.1 rocketchat:version@1.0.0 -rocketchat:version-check@0.0.1 -rocketchat:videobridge@0.2.0 -rocketchat:webdav@0.0.1 -rocketchat:webrtc@0.0.1 -rocketchat:wordpress@0.0.1 routepolicy@1.1.0 service-configuration@1.0.11 session@1.2.0 @@ -277,7 +145,6 @@ spacebars@1.0.15 spacebars-compiler@1.1.3 srp@1.0.12 standard-minifier-js@2.4.0 -steffo:meteor-accounts-saml@0.0.1 swydo:graphql@0.4.0 tap:i18n@1.8.2 templating@1.3.2 @@ -293,5 +160,4 @@ underscore@1.0.10 url@1.2.0 webapp@1.7.2 webapp-hashing@1.0.9 -yasaricli:slugify@0.0.7 yasinuslu:blaze-meta@0.3.3 diff --git a/.sandstorm/.gitignore b/.sandstorm/.gitignore deleted file mode 100644 index 8000dd9db47c..000000000000 --- a/.sandstorm/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.vagrant diff --git a/.sandstorm/CHANGELOG.md b/.sandstorm/CHANGELOG.md deleted file mode 100644 index 8d14253d53f6..000000000000 --- a/.sandstorm/CHANGELOG.md +++ /dev/null @@ -1 +0,0 @@ -### FIRST Sandstorm VERSION of Rocket.Chat diff --git a/.sandstorm/README.md b/.sandstorm/README.md deleted file mode 100644 index 17e5bc5bc534..000000000000 --- a/.sandstorm/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Publish commands - -``` -cd Rocket.Chat -vagrant-spk vm up && vagrant-spk dev -^C -vagrant-spk pack ../rocketchat.spk && vagrant-spk publish ../rocketchat.spk && vagrant-spk vm halt -``` - -# Reset commands - -``` -vagrant-spk vm halt && vagrant-spk vm destroy -``` diff --git a/.sandstorm/Vagrantfile b/.sandstorm/Vagrantfile deleted file mode 100644 index c7eee5ae79ea..000000000000 --- a/.sandstorm/Vagrantfile +++ /dev/null @@ -1,104 +0,0 @@ -# -*- mode: ruby -*- -# vi: set ft=ruby : - -# Guess at a reasonable name for the VM based on the folder vagrant-spk is -# run from. The timestamp is there to avoid conflicts if you have multiple -# folders with the same name. -VM_NAME = File.basename(File.dirname(File.dirname(__FILE__))) + "_sandstorm_#{Time.now.utc.to_i}" - -# Vagrantfile API/syntax version. Don't touch unless you know what you're doing! -VAGRANTFILE_API_VERSION = "2" - -# ugly hack to prevent hashicorp's bitrot. See https://github.com/hashicorp/vagrant/issues/9442 -# this setting is required for pre-2.0 vagrant, but causes an error as of 2.0.3, -# remove entirely when confident nobody uses vagrant 1.x for anything. -unless Vagrant::DEFAULT_SERVER_URL.frozen? - Vagrant::DEFAULT_SERVER_URL.replace('https://vagrantcloud.com') -end - -Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| - # Base on the Sandstorm snapshots of the official Debian 9 (stretch) box with vboxsf support. - config.vm.box = "debian/contrib-stretch64" - config.vm.box_version = "9.3.0" - - if Vagrant.has_plugin?("vagrant-vbguest") then - # vagrant-vbguest is a Vagrant plugin that upgrades - # the version of VirtualBox Guest Additions within each - # guest. If you have the vagrant-vbguest plugin, then it - # needs to know how to compile kernel modules, etc., and so - # we give it this hint about operating system type. - config.vm.guest = "debian" - end - - # We forward port 6080, the Sandstorm web port, so that developers can - # visit their sandstorm app from their browser as local.sandstorm.io:6080 - # (aka 127.0.0.1:6080). - config.vm.network :forwarded_port, guest: 6080, host: 6080 - - # Use a shell script to "provision" the box. This installs Sandstorm using - # the bundled installer. - config.vm.provision "shell", inline: "sudo bash /opt/app/.sandstorm/global-setup.sh", keep_color: true - # Then, do stack-specific and app-specific setup. - config.vm.provision "shell", inline: "sudo bash /opt/app/.sandstorm/setup.sh", keep_color: true - - # Shared folders are configured per-provider since vboxsf can't handle >4096 open files, - # NFS requires privilege escalation every time you bring a VM up, - # and 9p is only available on libvirt. - - # Calculate the number of CPUs and the amount of RAM the system has, - # in a platform-dependent way; further logic below. - cpus = nil - total_kB_ram = nil - - host = RbConfig::CONFIG['host_os'] - if host =~ /darwin/ - cpus = `sysctl -n hw.ncpu`.to_i - total_kB_ram = `sysctl -n hw.memsize`.to_i / 1024 - elsif host =~ /linux/ - cpus = `nproc`.to_i - total_kB_ram = `grep MemTotal /proc/meminfo | awk '{print $2}'`.to_i - elsif host =~ /mingw/ - # powershell may not be available on Windows XP and Vista, so wrap this in a rescue block - begin - cpus = `powershell -Command "(Get-WmiObject Win32_Processor -Property NumberOfLogicalProcessors | Select-Object -Property NumberOfLogicalProcessors | Measure-Object NumberOfLogicalProcessors -Sum).Sum"`.to_i - total_kB_ram = `powershell -Command "Get-CimInstance -class cim_physicalmemory | % $_.Capacity}"`.to_i / 1024 - rescue - end - end - # Use the same number of CPUs within Vagrant as the system, with 1 - # as a default. - # - # Use at least 512MB of RAM, and if the system has more than 2GB of - # RAM, use 1/4 of the system RAM. This seems a reasonable compromise - # between having the Vagrant guest operating system not run out of - # RAM entirely (which it basically would if we went much lower than - # 512MB) and also allowing it to use up a healthily large amount of - # RAM so it can run faster on systems that can afford it. - if cpus.nil? or cpus.zero? - cpus = 1 - end - if total_kB_ram.nil? or total_kB_ram < 2048000 - assign_ram_mb = 512 - else - assign_ram_mb = (total_kB_ram / 1024 / 4) - end - # Actually apply these CPU/memory values to the providers. - config.vm.provider :virtualbox do |vb, override| - vb.cpus = cpus - vb.memory = assign_ram_mb - vb.name = VM_NAME - - override.vm.synced_folder "..", "/opt/app" - override.vm.synced_folder ENV["HOME"] + "/.sandstorm", "/host-dot-sandstorm" - override.vm.synced_folder "..", "/vagrant", disabled: true - end - config.vm.provider :libvirt do |libvirt, override| - libvirt.cpus = cpus - libvirt.memory = assign_ram_mb - libvirt.default_prefix = VM_NAME - - override.vm.synced_folder "..", "/opt/app", type: "9p", accessmode: "passthrough" - override.vm.synced_folder ENV["HOME"] + "/.sandstorm", "/host-dot-sandstorm", type: "9p", accessmode: "passthrough" - override.vm.synced_folder "..", "/vagrant", type: "9p", accessmode: "passthrough", disabled: true - end -end diff --git a/.sandstorm/build.sh b/.sandstorm/build.sh deleted file mode 100755 index c8a155a2a351..000000000000 --- a/.sandstorm/build.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash -set -x -set -euvo pipefail - -# Make meteor bundle -sudo chown vagrant:vagrant /home/vagrant -R -cd /opt/app -meteor npm install capnp -meteor npm install -meteor build --directory /home/vagrant/ - -export NODE_ENV=production -# Use npm and node from the Meteor dev bundle to install the bundle's dependencies. -TOOL_VERSION=$(meteor show --ejson $(<.meteor/release) | grep '^ *"tool":' | - sed -re 's/^.*"(meteor-tool@[^"]*)".*$/\1/g') -TOOLDIR=$(echo $TOOL_VERSION | tr @ /) -PATH=$HOME/.meteor/packages/$TOOLDIR/mt-os.linux.x86_64/dev_bundle/bin:$PATH -cd /home/vagrant/bundle/programs/server -npm install --production - -# Copy our launcher script into the bundle so the grain can start up. -mkdir -p /home/vagrant/bundle/opt/app/.sandstorm/ -cp /opt/app/.sandstorm/launcher.sh /home/vagrant/bundle/opt/app/.sandstorm/ diff --git a/.sandstorm/description.md b/.sandstorm/description.md deleted file mode 100644 index 7001f7c09a4a..000000000000 --- a/.sandstorm/description.md +++ /dev/null @@ -1 +0,0 @@ -The Complete Open Source Chat Solution. Rocket.Chat is a Web Chat Server, developed in JavaScript. It is a great solution for communities and companies wanting to privately host their own chat service or for developers looking forward to build and evolve their own chat platforms. diff --git a/.sandstorm/global-setup.sh b/.sandstorm/global-setup.sh deleted file mode 100755 index af9d391aaac9..000000000000 --- a/.sandstorm/global-setup.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash -set -x -set -euvo pipefail - -echo localhost > /etc/hostname -hostname localhost -# Install curl that is needed below. -apt-get update -apt-get install -y curl -curl https://install.sandstorm.io/ > /host-dot-sandstorm/caches/install.sh -SANDSTORM_CURRENT_VERSION=$(curl -fs "https://install.sandstorm.io/dev?from=0&type=install") -SANDSTORM_PACKAGE="sandstorm-$SANDSTORM_CURRENT_VERSION.tar.xz" -if [[ ! -f /host-dot-sandstorm/caches/$SANDSTORM_PACKAGE ]] ; then - curl --output "/host-dot-sandstorm/caches/$SANDSTORM_PACKAGE.partial" "https://dl.sandstorm.io/$SANDSTORM_PACKAGE" - mv "/host-dot-sandstorm/caches/$SANDSTORM_PACKAGE.partial" "/host-dot-sandstorm/caches/$SANDSTORM_PACKAGE" -fi -bash /host-dot-sandstorm/caches/install.sh -d -e "/host-dot-sandstorm/caches/$SANDSTORM_PACKAGE" -modprobe ip_tables -# Make the vagrant user part of the sandstorm group so that commands like -# `spk dev` work. -usermod -a -G 'sandstorm' 'vagrant' -# Bind to all addresses, so the vagrant port-forward works. -sudo sed --in-place='' --expression='s/^BIND_IP=.*/BIND_IP=0.0.0.0/' /opt/sandstorm/sandstorm.conf -# TODO: update sandstorm installer script to ask about dev accounts, and -# specify a value for this option in the default config? -if ! grep --quiet --no-messages ALLOW_DEV_ACCOUNTS=true /opt/sandstorm/sandstorm.conf ; then - echo "ALLOW_DEV_ACCOUNTS=true" | sudo tee -a /opt/sandstorm/sandstorm.conf - sudo service sandstorm restart -fi -# Enable apt-cacher-ng proxy to make things faster if one appears to be running on the gateway IP -GATEWAY_IP=$(ip route | grep ^default | cut -d ' ' -f 3) -if nc -z "$GATEWAY_IP" 3142 ; then - echo "Acquire::http::Proxy \"http://$GATEWAY_IP:3142\";" > /etc/apt/apt.conf.d/80httpproxy -fi diff --git a/.sandstorm/launcher.sh b/.sandstorm/launcher.sh deleted file mode 100755 index d63b973fdbd2..000000000000 --- a/.sandstorm/launcher.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -set -x -set -euvo pipefail - -export METEOR_SETTINGS='{"public": {"sandstorm": true}}' -export NODE_ENV=production -export SETTINGS_HIDDEN="Email,Email_Header,Email_Footer,SMTP_Host,SMTP_Port,SMTP_Username,SMTP_Password,From_Email,SMTP_Test_Button,Invitation_Customized,Invitation_Subject,Invitation_HTML,Accounts_Enrollment_Customized,Accounts_Enrollment_Email_Subject,Accounts_Enrollment_Email,Accounts_UserAddedEmail_Customized,Accounts_UserAddedEmailSubject,Accounts_UserAddedEmail,Forgot_Password_Customized,Forgot_Password_Email_Subject,Forgot_Password_Email,Verification_Customized,Verification_Email_Subject,Verification_Email" -exec node /start.js -p 8000 diff --git a/.sandstorm/pgp-keyring b/.sandstorm/pgp-keyring deleted file mode 100644 index ad9fabd67285..000000000000 Binary files a/.sandstorm/pgp-keyring and /dev/null differ diff --git a/.sandstorm/pgp-signature b/.sandstorm/pgp-signature deleted file mode 100644 index 8d8e2bbe62f4..000000000000 Binary files a/.sandstorm/pgp-signature and /dev/null differ diff --git a/.sandstorm/rocket.chat-128.svg b/.sandstorm/rocket.chat-128.svg deleted file mode 100644 index 06b1893ee1e7..000000000000 --- a/.sandstorm/rocket.chat-128.svg +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/.sandstorm/rocket.chat-150.svg b/.sandstorm/rocket.chat-150.svg deleted file mode 100644 index c1e19551c19a..000000000000 --- a/.sandstorm/rocket.chat-150.svg +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/.sandstorm/rocket.chat-24.svg b/.sandstorm/rocket.chat-24.svg deleted file mode 100644 index 31c373726528..000000000000 --- a/.sandstorm/rocket.chat-24.svg +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/.sandstorm/sandstorm-pkgdef.capnp b/.sandstorm/sandstorm-pkgdef.capnp deleted file mode 100644 index 3298b95b5021..000000000000 --- a/.sandstorm/sandstorm-pkgdef.capnp +++ /dev/null @@ -1,115 +0,0 @@ -@0xbbbe049af795122e; - -using Spk = import "/sandstorm/package.capnp"; -# This imports: -# $SANDSTORM_HOME/latest/usr/include/sandstorm/package.capnp -# Check out that file to see the full, documented package definition format. - -const pkgdef :Spk.PackageDefinition = ( - # The package definition. Note that the spk tool looks specifically for the - # "pkgdef" constant. - - id = "vfnwptfn02ty21w715snyyczw0nqxkv3jvawcah10c6z7hj1hnu0", - # Your app ID is actually its public key. The private key was placed in - # your keyring. All updates must be signed with the same key. - - manifest = ( - # This manifest is included in your app package to tell Sandstorm - # about your app. - - appTitle = (defaultText = "Rocket.Chat"), - - appVersion = 129, # Increment this for every release. - - appMarketingVersion = (defaultText = "0.74.3"), - # Human-readable representation of appVersion. Should match the way you - # identify versions of your app in documentation and marketing. - - actions = [ - # Define your "new document" handlers here. - ( title = (defaultText = "New Rocket.Chat"), - command = .myCommand - # The command to run when starting for the first time. (".myCommand" - # is just a constant defined at the bottom of the file.) - ) - ], - - continueCommand = .myCommand, - # This is the command called to start your app back up after it has been - # shut down for inactivity. Here we're using the same command as for - # starting a new instance, but you could use different commands for each - # case. - - metadata = ( - icons = ( - appGrid = (svg = embed "rocket.chat-128.svg"), - grain = (svg = embed "rocket.chat-24.svg"), - market = (svg = embed "rocket.chat-150.svg"), - ), - - website = "https://rocket.chat", - codeUrl = "https://github.com/RocketChat/Rocket.Chat", - license = (openSource = mit), - categories = [communications, productivity, office, social, developerTools], - - author = ( - contactEmail = "team@rocket.chat", - pgpSignature = embed "pgp-signature", - upstreamAuthor = "Rocket.Chat", - ), - pgpKeyring = embed "pgp-keyring", - - description = (defaultText = embed "description.md"), - shortDescription = (defaultText = "Chat app"), - - screenshots = [ - (width = 1024, height = 696, png = embed "screenshot1.png"), - (width = 1024, height = 696, png = embed "screenshot2.png"), - (width = 1024, height = 696, png = embed "screenshot3.png"), - (width = 1024, height = 696, png = embed "screenshot4.png") - ], - - changeLog = (defaultText = embed "CHANGELOG.md"), - ), - - ), - - sourceMap = ( - # The following directories will be copied into your package. - searchPath = [ - ( sourcePath = "/home/vagrant/bundle" ), - ( sourcePath = "/opt/meteor-spk/meteor-spk.deps" ) - ] - ), - - alwaysInclude = [ "." ], - # This says that we always want to include all files from the source map. - # (An alternative is to automatically detect dependencies by watching what - # the app opens while running in dev mode. To see what that looks like, - # run `spk init` without the -A option.) - - bridgeConfig = ( - viewInfo = ( - eventTypes = [ - (name = "message", verbPhrase = (defaultText = "sent message")), - (name = "privateMessage", verbPhrase = (defaultText = "sent private message"), requiredPermission = (explicitList = void)), - ] - ), - saveIdentityCaps = true, - ), -); - -const myCommand :Spk.Manifest.Command = ( - # Here we define the command used to start up your server. - argv = ["/sandstorm-http-bridge", "8000", "--", "/opt/app/.sandstorm/launcher.sh"], - environ = [ - # Note that this defines the *entire* environment seen by your app. - (key = "PATH", value = "/usr/local/bin:/usr/bin:/bin"), - (key = "SANDSTORM", value = "1"), - (key = "HOME", value = "/var"), - (key = "Statistics_reporting", value = "false"), - (key = "Accounts_AllowUserAvatarChange", value = "false"), - (key = "Accounts_AllowUserProfileChange", value = "false"), - (key = "BABEL_CACHE_DIR", value = "/var/babel_cache") - ] -); diff --git a/.sandstorm/screenshot1.png b/.sandstorm/screenshot1.png deleted file mode 100644 index ec123d99cd03..000000000000 Binary files a/.sandstorm/screenshot1.png and /dev/null differ diff --git a/.sandstorm/screenshot2.png b/.sandstorm/screenshot2.png deleted file mode 100644 index 30713297c2f2..000000000000 Binary files a/.sandstorm/screenshot2.png and /dev/null differ diff --git a/.sandstorm/screenshot3.png b/.sandstorm/screenshot3.png deleted file mode 100644 index d8e88683c4cf..000000000000 Binary files a/.sandstorm/screenshot3.png and /dev/null differ diff --git a/.sandstorm/screenshot4.png b/.sandstorm/screenshot4.png deleted file mode 100644 index 3955cc694a36..000000000000 Binary files a/.sandstorm/screenshot4.png and /dev/null differ diff --git a/.sandstorm/setup.sh b/.sandstorm/setup.sh deleted file mode 100755 index 6882afab185d..000000000000 --- a/.sandstorm/setup.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/bin/bash -set -x -set -euvo pipefail - -apt-get update -apt-get install build-essential git -y - -cd /opt/ - -NODE_ENV=production -PACKAGE=meteor-spk-0.4.1 -PACKAGE_FILENAME="$PACKAGE.tar.xz" -CACHE_TARGET="/host-dot-sandstorm/caches/${PACKAGE_FILENAME}" - -# Fetch meteor-spk tarball if not cached -if [ ! -f "$CACHE_TARGET" ] ; then - curl https://dl.sandstorm.io/${PACKAGE_FILENAME} > "$CACHE_TARGET" -fi - -# Extract to /opt -tar xf "$CACHE_TARGET" - -# Create symlink so we can rely on the path /opt/meteor-spk -ln -s "${PACKAGE}" meteor-spk - -#This will install capnp, the Cap’n Proto command-line tool. -#It will also install libcapnp, libcapnpc, and libkj in /usr/local/lib and headers in /usr/local/include/capnp and /usr/local/include/kj. -curl -O https://capnproto.org/capnproto-c++-0.6.1.tar.gz -tar zxf capnproto-c++-0.6.1.tar.gz -cd capnproto-c++-0.6.1 -./configure -make -j6 check -sudo make install -# inlcude libcapnp and libkj library to dependencies. -cp .libs/* /opt/meteor-spk/meteor-spk.deps/lib/x86_64-linux-gnu/ - -# Add bash, and its dependencies, so they get mapped into the image. -# Bash runs the launcher script. -cp -a /bin/bash /opt/meteor-spk/meteor-spk.deps/bin/ -cp -a /lib/x86_64-linux-gnu/libncurses.so.* /opt/meteor-spk/meteor-spk.deps/lib/x86_64-linux-gnu/ -cp -a /lib/x86_64-linux-gnu/libtinfo.so.* /opt/meteor-spk/meteor-spk.deps/lib/x86_64-linux-gnu/ -# for npm in package.json sharp. -cp -a /lib/x86_64-linux-gnu/libresolv* /opt/meteor-spk/meteor-spk.deps/lib/x86_64-linux-gnu/ - - -# Unfortunately, Meteor does not explicitly make it easy to cache packages, but -# we know experimentally that the package is mostly directly extractable to a -# user's $HOME/.meteor directory. -METEOR_RELEASE=1.6.1.1 -METEOR_PLATFORM=os.linux.x86_64 -METEOR_TARBALL_FILENAME="meteor-bootstrap-${METEOR_PLATFORM}.tar.gz" -METEOR_TARBALL_URL="https://d3sqy0vbqsdhku.cloudfront.net/packages-bootstrap/${METEOR_RELEASE}/${METEOR_TARBALL_FILENAME}" -METEOR_CACHE_TARGET="/host-dot-sandstorm/caches/${METEOR_TARBALL_FILENAME}" - -# Fetch meteor tarball if not cached -if [ ! -f "$METEOR_CACHE_TARGET" ] ; then - curl "$METEOR_TARBALL_URL" > "${METEOR_CACHE_TARGET}.partial" - mv "${METEOR_CACHE_TARGET}"{.partial,} -fi - -# Extract as unprivileged user, which is the usual meteor setup -cd /home/vagrant/ -su -c "tar xf '${METEOR_CACHE_TARGET}'" vagrant -# Link into global PATH -ln -s /home/vagrant/.meteor/meteor /usr/bin/meteor -chown vagrant:vagrant /home/vagrant -R diff --git a/.sandstorm/stack b/.sandstorm/stack deleted file mode 100644 index f148e1141a45..000000000000 --- a/.sandstorm/stack +++ /dev/null @@ -1 +0,0 @@ -meteor diff --git a/.scripts/npm-postinstall.js b/.scripts/npm-postinstall.js new file mode 100644 index 000000000000..b8362a2401de --- /dev/null +++ b/.scripts/npm-postinstall.js @@ -0,0 +1,11 @@ + +const { execSync } = require('child_process'); + +console.log('Running npm-postinstall.js'); + +execSync('cp node_modules/katex/dist/katex.min.css app/katex/'); + +execSync('mkdir -p public/fonts/'); +execSync('cp node_modules/katex/dist/fonts/* public/fonts/'); + +execSync('cp node_modules/pdfjs-dist/build/pdf.worker.min.js public/'); diff --git a/.scripts/set-version.js b/.scripts/set-version.js index 27852ab15050..abd5f8a2719e 100644 --- a/.scripts/set-version.js +++ b/.scripts/set-version.js @@ -20,7 +20,6 @@ try { const files = [ './package.json', - './.sandstorm/sandstorm-pkgdef.capnp', './.travis/snap.sh', './.circleci/snap.sh', './.circleci/update-releases.sh', diff --git a/.scripts/start.js b/.scripts/start.js index 3f08b5608276..8c1854d91802 100644 --- a/.scripts/start.js +++ b/.scripts/start.js @@ -3,22 +3,45 @@ const path = require('path'); const fs = require('fs'); const extend = require('util')._extend; -const { exec } = require('child_process'); +const { spawn } = require('child_process'); +const net = require('net'); const processes = []; +let exitCode; const baseDir = path.resolve(__dirname, '..'); const srcDir = path.resolve(baseDir); +const isPortTaken = (port) => new Promise((resolve, reject) => { + const tester = net.createServer() + .once('error', (err) => (err.code === 'EADDRINUSE' ? resolve(true) : reject(err))) + .once('listening', () => tester.once('close', () => resolve(false)).close()) + .listen(port); +}); + +const waitPortRelease = (port) => new Promise((resolve, reject) => { + isPortTaken(port).then((taken) => { + if (!taken) { + return resolve(); + } + setTimeout(() => { + waitPortRelease(port).then(resolve).catch(reject); + }, 1000); + }); +}); + const appOptions = { env: { PORT: 3000, ROOT_URL: 'http://localhost:3000', + // MONGO_URL: 'mongodb://localhost:27017/test', + // MONGO_OPLOG_URL: 'mongodb://localhost:27017/local', }, }; function startProcess(opts, callback) { - const proc = exec( + const proc = spawn( opts.command, + opts.params, opts.options ); @@ -43,12 +66,28 @@ function startProcess(opts, callback) { proc.stderr.pipe(logStream); } - proc.on('close', function(code) { - console.log(opts.name, `exited with code ${ code }`); - for (let i = 0; i < processes.length; i += 1) { - processes[i].kill(); + proc.on('exit', function(code, signal) { + if (code != null) { + exitCode = code; + console.log(opts.name, `exited with code ${ code }`); + } else { + console.log(opts.name, `exited with signal ${ signal }`); + } + + processes.splice(processes.indexOf(proc), 1); + + processes.forEach((p) => p.kill()); + + if (processes.length === 0) { + waitPortRelease(appOptions.env.PORT).then(() => { + console.log(`Port ${ appOptions.env.PORT } was released, exiting with code ${ exitCode }`); + process.exit(exitCode); + }).catch((error) => { + console.error(`Error waiting port ${ appOptions.env.PORT } to be released, exiting with code ${ exitCode }`); + console.error(error); + process.exit(exitCode); + }); } - process.exit(code); }); processes.push(proc); } @@ -56,7 +95,10 @@ function startProcess(opts, callback) { function startApp(callback) { startProcess({ name: 'Meteor App', - command: 'node /tmp/build-test/bundle/main.js', + command: 'node', + params: ['/tmp/build-test/bundle/main.js'], + // command: 'node', + // params: ['.meteor/local/build/main.js'], waitForMessage: appOptions.waitForMessage, options: { cwd: srcDir, @@ -68,7 +110,10 @@ function startApp(callback) { function startChimp() { startProcess({ name: 'Chimp', - command: 'npm run chimp-test', + command: 'npm', + params: ['run', 'chimp-test'], + // command: 'exit', + // params: ['2'], options: { env: Object.assign({}, process.env, { NODE_PATH: `${ process.env.NODE_PATH + diff --git a/.snapcraft/snap/hooks/post-refresh b/.snapcraft/snap/hooks/post-refresh index 9a78145595ec..f4172f2dd064 100755 --- a/.snapcraft/snap/hooks/post-refresh +++ b/.snapcraft/snap/hooks/post-refresh @@ -1,16 +1,32 @@ #!/bin/bash # Initialize the CADDY_URL to a default -snapctl set caddy=disable +caddy="$(snapctl get caddy)" +if [ -z "$caddy" ]; then + snapctl set caddy=disable +fi # Initialize the PORT to a default -snapctl set port=3000 +port="$(snapctl get port)" +if [ -z "$port" ]; then + snapctl set port=3000 +fi # Initialize the MONGO_URL to a default -snapctl set mongo-url=mongodb://localhost:27017/parties +mongourl="$(snapctl get mongo-url)" +if [ -z "$mongourl" ]; then + snapctl set mongo-url=mongodb://localhost:27017/parties +fi # Initialize the MONGO_OPLOG_URL to a default -snapctl set mongo-oplog-url=mongodb://localhost:27017/local +mongooplogurl="$(snapctl get mongo-oplog-url)" +if [ -z "$mongooplogurl" ]; then + snapctl set mongo-oplog-url=mongodb://localhost:27017/local +fi # Initialize the protocol to a default -snapctl set https=disable +https="$(snapctl get https)" +if [ -z "$https" ]; then + snapctl set https=disable +fi + diff --git a/.stylelintignore b/.stylelintignore index 88092312fb93..4f8093de49f0 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -1,2 +1,3 @@ -packages/rocketchat_theme/client/vendor/fontello/css/fontello.css +app/theme/client/vendor/fontello/css/fontello.css packages/meteor-autocomplete/client/autocomplete.css +app/katex/katex.min.css diff --git a/.travis.yml b/.travis.yml index b602c51213ae..31f618fadec9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -71,7 +71,6 @@ before_deploy: - source ".travis/setdeploydir.sh" - ".travis/setupsig.sh" - ".travis/namefiles.sh" -- echo ".travis/sandstorm.sh" deploy: - provider: s3 access_key_id: AKIAIKIA7H7D47KUHYCA diff --git a/.travis/sandstorm.sh b/.travis/sandstorm.sh deleted file mode 100755 index 72095e70e1e8..000000000000 --- a/.travis/sandstorm.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash -set -x -set -euvo pipefail -IFS=$'\n\t' - -export SANDSTORM_VERSION=$(curl -f "https://install.sandstorm.io/dev?from=0&type=install") -export PATH=$PATH:/tmp/sandstorm-$SANDSTORM_VERSION/bin - -cd /tmp -curl https://dl.sandstorm.io/sandstorm-$SANDSTORM_VERSION.tar.xz | tar -xJf - - -mkdir -p ~/opt -cd ~/opt -curl https://dl.sandstorm.io/meteor-spk-0.1.8.tar.xz | tar -xJf - -ln -s meteor-spk-0.1.8 meteor-spk -cp -a /bin/bash ~/opt/meteor-spk/meteor-spk.deps/bin/ -cp -a /lib/x86_64-linux-gnu/libncurses.so.* ~/opt/meteor-spk/meteor-spk.deps/lib/x86_64-linux-gnu/ -cp -a /lib/x86_64-linux-gnu/libtinfo.so.* ~/opt/meteor-spk/meteor-spk.deps/lib/x86_64-linux-gnu/ -ln -s $TRAVIS_BUILD_DIR ~/opt/app - -cd /tmp -spk init -p3000 -- nothing -export SANDSTORM_ID="$(grep '\sid =' sandstorm-pkgdef.capnp)" - -cd $TRAVIS_BUILD_DIR -export METEOR_WAREHOUSE_DIR="${METEOR_WAREHOUSE_DIR:-$HOME/.meteor}" -export METEOR_DEV_BUNDLE=$(dirname $(readlink -f "$METEOR_WAREHOUSE_DIR/meteor"))/dev_bundle - -mkdir -p ~/vagrant -tar -zxf /tmp/build/Rocket.Chat.tar.gz --directory ~/vagrant/ -cd ~/vagrant/bundle/programs/server && "$METEOR_DEV_BUNDLE/bin/npm" install -cd $TRAVIS_BUILD_DIR/.sandstorm -sed -i "s/\sid = .*/$SANDSTORM_ID/" sandstorm-pkgdef.capnp -mkdir -p ~/vagrant/bundle/opt/app/.sandstorm/ -cp ~/opt/app/.sandstorm/launcher.sh ~/vagrant/bundle/opt/app/.sandstorm/ -sed -i "s/\spgp/#pgp/g" sandstorm-pkgdef.capnp -spk pack $ROCKET_DEPLOY_DIR/rocket.chat-$ARTIFACT_NAME.spk diff --git a/.travis/snap.sh b/.travis/snap.sh index ee4a78c1a985..81468e4c6552 100755 --- a/.travis/snap.sh +++ b/.travis/snap.sh @@ -17,7 +17,7 @@ elif [[ $TRAVIS_TAG ]]; then RC_VERSION=$TRAVIS_TAG else CHANNEL=edge - RC_VERSION=0.74.3 + RC_VERSION=1.0.0 fi echo "Preparing to trigger a snap release for $CHANNEL channel" diff --git a/.vscode/launch.json b/.vscode/launch.json index 465d780a4a59..58b8073d89b2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,7 @@ { "version": "0.2.0", "configurations": [ + { "name": "Attach to meteor debug", "type": "node", @@ -13,6 +14,7 @@ "meteor://💻app/*": "${workspaceFolder}/*", "meteor://💻app/packages/rocketchat:*": "${workspaceFolder}/packages/rocketchat-*", "meteor://💻app/packages/chatpal:*": "${workspaceFolder}/packages/chatpal-*", + "meteor://💻app/packages/assistify:*": "${workspaceFolder}/packages/assistify-*" }, "protocol": "inspector" }, @@ -26,6 +28,7 @@ "meteor://💻app/*": "${workspaceFolder}/*", "meteor://💻app/packages/rocketchat:*": "${workspaceFolder}/packages/rocketchat-*", "meteor://💻app/packages/chatpal:*": "${workspaceFolder}/packages/chatpal-*", + "meteor://💻app/packages/assistify:*": "${workspaceFolder}/packages/assistify-*" } }, { @@ -43,6 +46,7 @@ "meteor://💻app/*": "${workspaceFolder}/*", "meteor://💻app/packages/rocketchat:*": "${workspaceFolder}/packages/rocketchat-*", "meteor://💻app/packages/chatpal:*": "${workspaceFolder}/packages/chatpal-*", + "meteor://💻app/packages/assistify:*": "${workspaceFolder}/packages/assistify-*" }, "protocol": "inspector" }, @@ -61,6 +65,7 @@ "meteor://💻app/*": "${workspaceFolder}/*", "meteor://💻app/packages/rocketchat:*": "${workspaceFolder}/packages/rocketchat-*", "meteor://💻app/packages/chatpal:*": "${workspaceFolder}/packages/chatpal-*", + "meteor://💻app/packages/assistify:*": "${workspaceFolder}/packages/assistify-*" }, "protocol": "inspector" }, @@ -79,6 +84,7 @@ "meteor://💻app/*": "${workspaceFolder}/*", "meteor://💻app/packages/rocketchat:*": "${workspaceFolder}/packages/rocketchat-*", "meteor://💻app/packages/chatpal:*": "${workspaceFolder}/packages/chatpal-*", + "meteor://💻app/packages/assistify:*": "${workspaceFolder}/packages/assistify-*" }, "env": { "TEST_MODE": "true" diff --git a/HISTORY.md b/HISTORY.md index e325e1d9dc0b..f47c154cddf0 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,434 @@ +# 1.0.0 +`2019-04-28 · 4 ️️️⚠️ · 30 🎉 · 32 🚀 · 97 🐛 · 173 🔍 · 60 👩‍💻👨‍💻` + +### Engine versions +- Node: `8.11.4` +- NPM: `6.4.1` +- MongoDB: `3.2, 3.4, 3.6, 4.0` + +### ⚠️ BREAKING CHANGES + +- Remove deprecated file upload engine Slingshot ([#13724](https://github.com/RocketChat/Rocket.Chat/pull/13724)) +- Remove internal hubot package ([#13522](https://github.com/RocketChat/Rocket.Chat/pull/13522)) +- Prevent start if incompatible mongo version ([#13927](https://github.com/RocketChat/Rocket.Chat/pull/13927)) +- Require OPLOG/REPLICASET to run Rocket.Chat ([#14227](https://github.com/RocketChat/Rocket.Chat/pull/14227)) + +### 🎉 New features + +- Marketplace integration with Rocket.Chat Cloud ([#13809](https://github.com/RocketChat/Rocket.Chat/pull/13809)) +- Add message action to copy message to input as reply ([#12626](https://github.com/RocketChat/Rocket.Chat/pull/12626)) +- Allow sending long messages as attachments ([#13819](https://github.com/RocketChat/Rocket.Chat/pull/13819)) +- Add e-mail field on Livechat Departments ([#13775](https://github.com/RocketChat/Rocket.Chat/pull/13775)) +- Provide new Livechat client as community feature ([#13723](https://github.com/RocketChat/Rocket.Chat/pull/13723)) +- Discussions ([#13541](https://github.com/RocketChat/Rocket.Chat/pull/13541) by [@vickyokrm](https://github.com/vickyokrm)) +- Bosnian lang (BS) ([#13635](https://github.com/RocketChat/Rocket.Chat/pull/13635) by [@fliptrail](https://github.com/fliptrail)) +- Federation ([#12370](https://github.com/RocketChat/Rocket.Chat/pull/12370)) +- Show department field on Livechat visitor panel ([#13530](https://github.com/RocketChat/Rocket.Chat/pull/13530)) +- Add offset parameter to channels.history, groups.history, dm.history ([#13310](https://github.com/RocketChat/Rocket.Chat/pull/13310) by [@xbolshe](https://github.com/xbolshe)) +- Permission to assign roles ([#13597](https://github.com/RocketChat/Rocket.Chat/pull/13597)) +- reply with a file ([#12095](https://github.com/RocketChat/Rocket.Chat/pull/12095) by [@rssilva](https://github.com/rssilva)) +- legal notice page ([#12472](https://github.com/RocketChat/Rocket.Chat/pull/12472) by [@localguru](https://github.com/localguru)) +- Add missing remove add leader channel ([#13315](https://github.com/RocketChat/Rocket.Chat/pull/13315) by [@Montel](https://github.com/Montel)) +- users.setActiveStatus endpoint in rest api ([#13443](https://github.com/RocketChat/Rocket.Chat/pull/13443) by [@thayannevls](https://github.com/thayannevls)) +- User avatars from external source ([#7929](https://github.com/RocketChat/Rocket.Chat/pull/7929) by [@mjovanovic0](https://github.com/mjovanovic0)) +- Add an option to delete file in files list ([#13815](https://github.com/RocketChat/Rocket.Chat/pull/13815)) +- Threads V 1.0 ([#13996](https://github.com/RocketChat/Rocket.Chat/pull/13996)) +- Add support to updatedSince parameter in emoji-custom.list and deprecated old endpoint ([#13510](https://github.com/RocketChat/Rocket.Chat/pull/13510)) +- Chatpal: Enable custom search parameters ([#13829](https://github.com/RocketChat/Rocket.Chat/pull/13829) by [@Peym4n](https://github.com/Peym4n)) +- - Add setting to request a comment when closing Livechat room ([#13983](https://github.com/RocketChat/Rocket.Chat/pull/13983) by [@knrt10](https://github.com/knrt10)) +- Rest threads ([#14045](https://github.com/RocketChat/Rocket.Chat/pull/14045)) +- Add GET method to fetch Livechat message through REST API ([#14147](https://github.com/RocketChat/Rocket.Chat/pull/14147)) +- Add Voxtelesys to list of SMS providers ([#13697](https://github.com/RocketChat/Rocket.Chat/pull/13697) by [@jhnburke8](https://github.com/jhnburke8) & [@john08burke](https://github.com/john08burke)) +- Rest endpoints of discussions ([#13987](https://github.com/RocketChat/Rocket.Chat/pull/13987)) +- Multiple slackbridges ([#11346](https://github.com/RocketChat/Rocket.Chat/pull/11346) by [@kable-wilmoth](https://github.com/kable-wilmoth)) +- option to not use nrr (experimental) ([#14224](https://github.com/RocketChat/Rocket.Chat/pull/14224)) +- Set up livechat connections created from new client ([#14236](https://github.com/RocketChat/Rocket.Chat/pull/14236)) +- allow drop files on thread ([#14214](https://github.com/RocketChat/Rocket.Chat/pull/14214)) +- Update message actions ([#14268](https://github.com/RocketChat/Rocket.Chat/pull/14268)) + +### 🚀 Improvements + +- UI of page not found ([#13757](https://github.com/RocketChat/Rocket.Chat/pull/13757) by [@fliptrail](https://github.com/fliptrail)) +- Show rooms with mentions on unread category even with hide counter ([#13948](https://github.com/RocketChat/Rocket.Chat/pull/13948)) +- Join channels by sending a message or join button (#13752) ([#13752](https://github.com/RocketChat/Rocket.Chat/pull/13752) by [@bhardwajaditya](https://github.com/bhardwajaditya)) +- Filter agents with autocomplete input instead of select element ([#13730](https://github.com/RocketChat/Rocket.Chat/pull/13730)) +- Ignore agent status when queuing incoming livechats via Guest Pool ([#13818](https://github.com/RocketChat/Rocket.Chat/pull/13818)) +- Replaces color #13679A to #1d74f5 ([#13796](https://github.com/RocketChat/Rocket.Chat/pull/13796) by [@fliptrail](https://github.com/fliptrail)) +- Remove unnecessary "File Upload". ([#13743](https://github.com/RocketChat/Rocket.Chat/pull/13743) by [@knrt10](https://github.com/knrt10)) +- Add index for room's ts ([#13726](https://github.com/RocketChat/Rocket.Chat/pull/13726)) +- Add decoding for commonName (cn) and displayName attributes for SAML ([#12347](https://github.com/RocketChat/Rocket.Chat/pull/12347) by [@pkolmann](https://github.com/pkolmann)) +- Deprecate fixCordova helper ([#13598](https://github.com/RocketChat/Rocket.Chat/pull/13598)) +- Remove dangling side-nav styles ([#13584](https://github.com/RocketChat/Rocket.Chat/pull/13584)) +- Disable X-Powered-By header in all known express middlewares ([#13388](https://github.com/RocketChat/Rocket.Chat/pull/13388)) +- Allow custom rocketchat username for crowd users and enable login via email/crowd_username ([#12981](https://github.com/RocketChat/Rocket.Chat/pull/12981) by [@steerben](https://github.com/steerben)) +- Add department field on find guest method ([#13491](https://github.com/RocketChat/Rocket.Chat/pull/13491)) +- KaTeX and Autolinker message rendering ([#11698](https://github.com/RocketChat/Rocket.Chat/pull/11698)) +- Update to MongoDB 4.0 in docker-compose file ([#13396](https://github.com/RocketChat/Rocket.Chat/pull/13396) by [@ngulden](https://github.com/ngulden)) +- Admin ui ([#13393](https://github.com/RocketChat/Rocket.Chat/pull/13393)) +- End to end tests ([#13401](https://github.com/RocketChat/Rocket.Chat/pull/13401)) +- Update deleteUser errors to be more semantic ([#12380](https://github.com/RocketChat/Rocket.Chat/pull/12380)) +- Line height on static content pages ([#11673](https://github.com/RocketChat/Rocket.Chat/pull/11673)) +- new icons ([#13289](https://github.com/RocketChat/Rocket.Chat/pull/13289)) +- Add permission to change other user profile avatar ([#13884](https://github.com/RocketChat/Rocket.Chat/pull/13884) by [@knrt10](https://github.com/knrt10)) +- UI of Permissions page ([#13732](https://github.com/RocketChat/Rocket.Chat/pull/13732) by [@fliptrail](https://github.com/fliptrail)) +- Use SessionId for credential token in SAML request ([#13791](https://github.com/RocketChat/Rocket.Chat/pull/13791) by [@MohammedEssehemy](https://github.com/MohammedEssehemy)) +- Include more information to help with bug reports and debugging ([#14047](https://github.com/RocketChat/Rocket.Chat/pull/14047)) +- New sidebar item badges, mention links, and ticks ([#14030](https://github.com/RocketChat/Rocket.Chat/pull/14030)) +- Remove setting to show a livechat is waiting ([#13992](https://github.com/RocketChat/Rocket.Chat/pull/13992)) +- Attachment download caching ([#14137](https://github.com/RocketChat/Rocket.Chat/pull/14137) by [@wreiske](https://github.com/wreiske)) +- Get avatar from oauth ([#14131](https://github.com/RocketChat/Rocket.Chat/pull/14131)) +- OAuth Role Sync ([#13761](https://github.com/RocketChat/Rocket.Chat/pull/13761) by [@hypery2k](https://github.com/hypery2k)) +- Update the Apps Engine version to v1.4.1 ([#14072](https://github.com/RocketChat/Rocket.Chat/pull/14072)) +- Replace livechat inquiry dialog with preview room ([#13986](https://github.com/RocketChat/Rocket.Chat/pull/13986)) + +### 🐛 Bug fixes + +- Opening a Livechat room from another agent ([#13951](https://github.com/RocketChat/Rocket.Chat/pull/13951)) +- Directory and Apps logs page ([#13938](https://github.com/RocketChat/Rocket.Chat/pull/13938)) +- Minor issues detected after testing the new Livechat client ([#13521](https://github.com/RocketChat/Rocket.Chat/pull/13521)) +- Display first message when taking Livechat inquiry ([#13896](https://github.com/RocketChat/Rocket.Chat/pull/13896)) +- Loading theme CSS on first server startup ([#13953](https://github.com/RocketChat/Rocket.Chat/pull/13953)) +- OTR dialog issue ([#13755](https://github.com/RocketChat/Rocket.Chat/pull/13755) by [@knrt10](https://github.com/knrt10)) +- Limit App’s HTTP calls to 500ms ([#13949](https://github.com/RocketChat/Rocket.Chat/pull/13949)) +- Read Receipt for Livechat Messages fixed ([#13832](https://github.com/RocketChat/Rocket.Chat/pull/13832) by [@knrt10](https://github.com/knrt10)) +- Avatar image being shrinked on autocomplete ([#13914](https://github.com/RocketChat/Rocket.Chat/pull/13914)) +- VIDEO/JITSI multiple calls before video call ([#13855](https://github.com/RocketChat/Rocket.Chat/pull/13855)) +- Some Safari bugs ([#13895](https://github.com/RocketChat/Rocket.Chat/pull/13895)) +- wrong width/height for tile_70 (mstile 70x70 (png)) ([#13851](https://github.com/RocketChat/Rocket.Chat/pull/13851) by [@ulf-f](https://github.com/ulf-f)) +- wrong importing of e2e ([#13863](https://github.com/RocketChat/Rocket.Chat/pull/13863)) +- Forwarded Livechat visitor name is not getting updated on the sidebar ([#13783](https://github.com/RocketChat/Rocket.Chat/pull/13783) by [@zolbayars](https://github.com/zolbayars)) +- Remove spaces in some i18n files ([#13801](https://github.com/RocketChat/Rocket.Chat/pull/13801)) +- Translation interpolations for many languages ([#13751](https://github.com/RocketChat/Rocket.Chat/pull/13751) by [@fliptrail](https://github.com/fliptrail)) +- Fixed grammatical error. ([#13559](https://github.com/RocketChat/Rocket.Chat/pull/13559) by [@gsunit](https://github.com/gsunit)) +- In home screen Rocket.Chat+ is dispalyed as Rocket.Chat ([#13784](https://github.com/RocketChat/Rocket.Chat/pull/13784) by [@ashwaniYDV](https://github.com/ashwaniYDV)) +- No new room created when conversation is closed ([#13753](https://github.com/RocketChat/Rocket.Chat/pull/13753) by [@knrt10](https://github.com/knrt10)) +- Loading user list from room messages ([#13769](https://github.com/RocketChat/Rocket.Chat/pull/13769)) +- User is unable to enter multiple emojis by clicking on the emoji icon ([#13744](https://github.com/RocketChat/Rocket.Chat/pull/13744) by [@Kailash0311](https://github.com/Kailash0311)) +- Audio message recording ([#13727](https://github.com/RocketChat/Rocket.Chat/pull/13727)) +- Remove Room info for Direct Messages (#9383) ([#12429](https://github.com/RocketChat/Rocket.Chat/pull/12429) by [@vinade](https://github.com/vinade)) +- WebRTC wasn't working duo to design and browser's APIs changes ([#13675](https://github.com/RocketChat/Rocket.Chat/pull/13675)) +- Adds Proper Language display name for many languages ([#13714](https://github.com/RocketChat/Rocket.Chat/pull/13714) by [@fliptrail](https://github.com/fliptrail)) +- Update bad-words to 3.0.2 ([#13705](https://github.com/RocketChat/Rocket.Chat/pull/13705) by [@trivoallan](https://github.com/trivoallan)) +- Changing Room name updates the webhook ([#13672](https://github.com/RocketChat/Rocket.Chat/pull/13672) by [@knrt10](https://github.com/knrt10)) +- Fix snap refresh hook ([#13702](https://github.com/RocketChat/Rocket.Chat/pull/13702)) +- Audio message recording issues ([#13486](https://github.com/RocketChat/Rocket.Chat/pull/13486)) +- Legal pages' style ([#13677](https://github.com/RocketChat/Rocket.Chat/pull/13677)) +- Stop livestream ([#13676](https://github.com/RocketChat/Rocket.Chat/pull/13676)) +- Avatar fonts for PNG and JPG ([#13681](https://github.com/RocketChat/Rocket.Chat/pull/13681)) +- Block User Icon ([#13630](https://github.com/RocketChat/Rocket.Chat/pull/13630) by [@knrt10](https://github.com/knrt10)) +- Corrects UI background of forced F2A Authentication ([#13670](https://github.com/RocketChat/Rocket.Chat/pull/13670) by [@fliptrail](https://github.com/fliptrail)) +- Race condition on the loading of Apps on the admin page ([#13587](https://github.com/RocketChat/Rocket.Chat/pull/13587)) +- Do not allow change avatars of another users without permission ([#13629](https://github.com/RocketChat/Rocket.Chat/pull/13629)) +- link of k8s deploy ([#13612](https://github.com/RocketChat/Rocket.Chat/pull/13612) by [@Mr-Linus](https://github.com/Mr-Linus)) +- Bugfix markdown Marked link new tab ([#13245](https://github.com/RocketChat/Rocket.Chat/pull/13245) by [@DeviaVir](https://github.com/DeviaVir)) +- Partially messaging formatting for bold letters ([#13599](https://github.com/RocketChat/Rocket.Chat/pull/13599) by [@knrt10](https://github.com/knrt10)) +- Change userId of rate limiter, change to logged user ([#13442](https://github.com/RocketChat/Rocket.Chat/pull/13442)) +- Add retries to docker-compose.yml, to wait for MongoDB to be ready ([#13199](https://github.com/RocketChat/Rocket.Chat/pull/13199) by [@tiangolo](https://github.com/tiangolo)) +- Non-latin room names and other slugifications ([#13467](https://github.com/RocketChat/Rocket.Chat/pull/13467)) +- Fixed rocketchat-oembed meta fragment pulling ([#13056](https://github.com/RocketChat/Rocket.Chat/pull/13056) by [@wreiske](https://github.com/wreiske)) +- Attachments without dates were showing December 31, 1970 ([#13428](https://github.com/RocketChat/Rocket.Chat/pull/13428) by [@wreiske](https://github.com/wreiske)) +- Restart required to apply changes in API Rate Limiter settings ([#13451](https://github.com/RocketChat/Rocket.Chat/pull/13451)) +- Ability to activate an app installed by zip even offline ([#13563](https://github.com/RocketChat/Rocket.Chat/pull/13563)) +- .bin extension added to attached file names ([#13468](https://github.com/RocketChat/Rocket.Chat/pull/13468)) +- Right arrows in default HTML content ([#13502](https://github.com/RocketChat/Rocket.Chat/pull/13502)) +- Typo in a referrer header in inject.js file ([#13469](https://github.com/RocketChat/Rocket.Chat/pull/13469) by [@algomaster99](https://github.com/algomaster99)) +- Fix issue cannot filter channels by name ([#12952](https://github.com/RocketChat/Rocket.Chat/pull/12952) by [@huydang284](https://github.com/huydang284)) +- mention-links not being always resolved ([#11745](https://github.com/RocketChat/Rocket.Chat/pull/11745)) +- allow user to logout before set username ([#13439](https://github.com/RocketChat/Rocket.Chat/pull/13439)) +- Error when recording data into the connection object ([#13553](https://github.com/RocketChat/Rocket.Chat/pull/13553)) +- Handle showing/hiding input in messageBox ([#13564](https://github.com/RocketChat/Rocket.Chat/pull/13564)) +- Fix wrong this scope in Notifications ([#13515](https://github.com/RocketChat/Rocket.Chat/pull/13515)) +- Get next Livechat agent endpoint ([#13485](https://github.com/RocketChat/Rocket.Chat/pull/13485)) +- Sidenav mouse hover was slow ([#13482](https://github.com/RocketChat/Rocket.Chat/pull/13482)) +- Emoji detection at line breaks ([#13447](https://github.com/RocketChat/Rocket.Chat/pull/13447) by [@savish28](https://github.com/savish28)) +- Small improvements on message box ([#13444](https://github.com/RocketChat/Rocket.Chat/pull/13444)) +- Fixing rooms find by type and name ([#11451](https://github.com/RocketChat/Rocket.Chat/pull/11451) by [@hmagarotto](https://github.com/hmagarotto)) +- linear-gradient background on safari ([#13363](https://github.com/RocketChat/Rocket.Chat/pull/13363)) +- Fixed text for "bulk-register-user" ([#11558](https://github.com/RocketChat/Rocket.Chat/pull/11558) by [@the4ndy](https://github.com/the4ndy)) +- Closing sidebar when room menu is clicked. ([#13842](https://github.com/RocketChat/Rocket.Chat/pull/13842) by [@Kailash0311](https://github.com/Kailash0311)) +- Check settings for name requirement before validating ([#14021](https://github.com/RocketChat/Rocket.Chat/pull/14021)) +- Links and upload paths when running in a subdir ([#13982](https://github.com/RocketChat/Rocket.Chat/pull/13982)) +- users.getPreferences when the user doesn't have any preferences ([#13532](https://github.com/RocketChat/Rocket.Chat/pull/13532) by [@thayannevls](https://github.com/thayannevls)) +- Real names were not displayed in the reactions (API/UI) ([#13495](https://github.com/RocketChat/Rocket.Chat/pull/13495)) +- Theme CSS loading in subdir env ([#14015](https://github.com/RocketChat/Rocket.Chat/pull/14015)) +- Fix rendering of links in the announcement modal ([#13250](https://github.com/RocketChat/Rocket.Chat/pull/13250) by [@supra08](https://github.com/supra08)) +- Add custom MIME types for *.ico extension ([#13969](https://github.com/RocketChat/Rocket.Chat/pull/13969)) +- Groups endpoints permission validations ([#13994](https://github.com/RocketChat/Rocket.Chat/pull/13994)) +- Focus on input when emoji picker box is open was not working ([#13981](https://github.com/RocketChat/Rocket.Chat/pull/13981)) +- Auto hide Livechat room from sidebar on close ([#13824](https://github.com/RocketChat/Rocket.Chat/pull/13824) by [@knrt10](https://github.com/knrt10)) +- Improve cloud section ([#13820](https://github.com/RocketChat/Rocket.Chat/pull/13820)) +- Wrong permalink when running in subdir ([#13746](https://github.com/RocketChat/Rocket.Chat/pull/13746) by [@ura14h](https://github.com/ura14h)) +- Change localStorage keys to work when server is running in a subdir ([#13968](https://github.com/RocketChat/Rocket.Chat/pull/13968)) +- SAML certificate settings don't follow a pattern ([#14179](https://github.com/RocketChat/Rocket.Chat/pull/14179)) +- Custom Oauth store refresh and id tokens with expiresIn ([#14121](https://github.com/RocketChat/Rocket.Chat/pull/14121) by [@ralfbecker](https://github.com/ralfbecker)) +- Apps converters delete fields on message attachments ([#14028](https://github.com/RocketChat/Rocket.Chat/pull/14028)) +- Custom Oauth login not working with accessToken ([#14113](https://github.com/RocketChat/Rocket.Chat/pull/14113) by [@knrt10](https://github.com/knrt10)) +- renderField template to correct short property usage ([#14148](https://github.com/RocketChat/Rocket.Chat/pull/14148)) +- Updating a message from apps if keep history is on ([#14129](https://github.com/RocketChat/Rocket.Chat/pull/14129)) +- Missing connection headers on Livechat REST API ([#14130](https://github.com/RocketChat/Rocket.Chat/pull/14130)) +- Receiving agent for new livechats from REST API ([#14103](https://github.com/RocketChat/Rocket.Chat/pull/14103)) +- Livechat user registration in another department ([#10695](https://github.com/RocketChat/Rocket.Chat/pull/10695)) +- Support for handling SAML LogoutRequest SLO ([#14074](https://github.com/RocketChat/Rocket.Chat/pull/14074)) +- Livechat office hours ([#14031](https://github.com/RocketChat/Rocket.Chat/pull/14031)) +- Auto-translate toggle not updating rendered messages ([#14262](https://github.com/RocketChat/Rocket.Chat/pull/14262)) +- Align burger menu in header with content matching room header ([#14265](https://github.com/RocketChat/Rocket.Chat/pull/14265)) +- Normalize TAPi18n language string on Livechat widget ([#14012](https://github.com/RocketChat/Rocket.Chat/pull/14012)) +- Autogrow not working properly for many message boxes ([#14163](https://github.com/RocketChat/Rocket.Chat/pull/14163)) +- Image attachment re-renders on message update ([#14207](https://github.com/RocketChat/Rocket.Chat/pull/14207) by [@Kailash0311](https://github.com/Kailash0311)) +- Sidenav does not open on some admin pages ([#14010](https://github.com/RocketChat/Rocket.Chat/pull/14010)) +- Empty result when getting badge count notification ([#14244](https://github.com/RocketChat/Rocket.Chat/pull/14244)) +- Obey audio notification preferences ([#14188](https://github.com/RocketChat/Rocket.Chat/pull/14188)) +- Slackbridge private channels ([#14273](https://github.com/RocketChat/Rocket.Chat/pull/14273) by [@nylen](https://github.com/nylen)) +- View All members button now not in direct room ([#14081](https://github.com/RocketChat/Rocket.Chat/pull/14081) by [@knrt10](https://github.com/knrt10)) + +
+🔍 Minor changes + +- Update eslint config ([#13966](https://github.com/RocketChat/Rocket.Chat/pull/13966)) +- Remove some bad references to messageBox ([#13954](https://github.com/RocketChat/Rocket.Chat/pull/13954)) +- LingoHub based on develop ([#13964](https://github.com/RocketChat/Rocket.Chat/pull/13964)) +- Update preview Dockerfile to use Stretch dependencies ([#13947](https://github.com/RocketChat/Rocket.Chat/pull/13947)) +- Small improvements to federation callbacks/hooks ([#13946](https://github.com/RocketChat/Rocket.Chat/pull/13946)) +- Improve: Support search and adding federated users through regular endpoints ([#13936](https://github.com/RocketChat/Rocket.Chat/pull/13936)) +- Remove bitcoin link in Readme.md since the link is broken ([#13935](https://github.com/RocketChat/Rocket.Chat/pull/13935) by [@ashwaniYDV](https://github.com/ashwaniYDV)) +- Fix missing dependencies on stretch CI image ([#13910](https://github.com/RocketChat/Rocket.Chat/pull/13910)) +- Remove some index.js files routing for server/client files ([#13772](https://github.com/RocketChat/Rocket.Chat/pull/13772)) +- Use CircleCI Debian Stretch images ([#13906](https://github.com/RocketChat/Rocket.Chat/pull/13906)) +- LingoHub based on develop ([#13891](https://github.com/RocketChat/Rocket.Chat/pull/13891)) +- User remove role dialog fixed ([#13874](https://github.com/RocketChat/Rocket.Chat/pull/13874) by [@bhardwajaditya](https://github.com/bhardwajaditya)) +- Rename Threads to Discussion ([#13782](https://github.com/RocketChat/Rocket.Chat/pull/13782)) +- [BUG] Icon Fixed for Knowledge base on Livechat ([#13806](https://github.com/RocketChat/Rocket.Chat/pull/13806) by [@knrt10](https://github.com/knrt10)) +- Add support to search for all users in directory ([#13803](https://github.com/RocketChat/Rocket.Chat/pull/13803)) +- LingoHub based on develop ([#13839](https://github.com/RocketChat/Rocket.Chat/pull/13839)) +- Remove unused style ([#13834](https://github.com/RocketChat/Rocket.Chat/pull/13834)) +- Remove unused files ([#13833](https://github.com/RocketChat/Rocket.Chat/pull/13833)) +- Lingohub sync and additional fixes ([#13825](https://github.com/RocketChat/Rocket.Chat/pull/13825)) +- Fix: addRoomAccessValidator method created for Threads ([#13789](https://github.com/RocketChat/Rocket.Chat/pull/13789)) +- Adds French translation of Personal Access Token ([#13779](https://github.com/RocketChat/Rocket.Chat/pull/13779) by [@ashwaniYDV](https://github.com/ashwaniYDV)) +- Remove Sandstorm support ([#13773](https://github.com/RocketChat/Rocket.Chat/pull/13773)) +- Removing (almost) every dynamic imports ([#13767](https://github.com/RocketChat/Rocket.Chat/pull/13767)) +- Regression: Threads styles improvement ([#13741](https://github.com/RocketChat/Rocket.Chat/pull/13741)) +- Convert imports to relative paths ([#13740](https://github.com/RocketChat/Rocket.Chat/pull/13740)) +- Regression: removed backup files ([#13729](https://github.com/RocketChat/Rocket.Chat/pull/13729)) +- Remove unused files ([#13725](https://github.com/RocketChat/Rocket.Chat/pull/13725)) +- Add Houston config ([#13707](https://github.com/RocketChat/Rocket.Chat/pull/13707)) +- Change the way to resolve DNS for Federation ([#13695](https://github.com/RocketChat/Rocket.Chat/pull/13695)) +- Update husky config ([#13687](https://github.com/RocketChat/Rocket.Chat/pull/13687)) +- Regression: Prune Threads ([#13683](https://github.com/RocketChat/Rocket.Chat/pull/13683)) +- Regression: Fix icon for DMs ([#13679](https://github.com/RocketChat/Rocket.Chat/pull/13679)) +- Regression: Add missing translations used in Apps pages ([#13674](https://github.com/RocketChat/Rocket.Chat/pull/13674)) +- Regression: User Discussions join message ([#13656](https://github.com/RocketChat/Rocket.Chat/pull/13656) by [@bhardwajaditya](https://github.com/bhardwajaditya)) +- Regression: Sidebar create new channel hover text ([#13658](https://github.com/RocketChat/Rocket.Chat/pull/13658) by [@bhardwajaditya](https://github.com/bhardwajaditya)) +- Regression: Fix embedded layout ([#13574](https://github.com/RocketChat/Rocket.Chat/pull/13574)) +- Improve: Send cloud token to Federation Hub ([#13651](https://github.com/RocketChat/Rocket.Chat/pull/13651)) +- Regression: Discussions - Invite users and DM ([#13646](https://github.com/RocketChat/Rocket.Chat/pull/13646)) +- LingoHub based on develop ([#13623](https://github.com/RocketChat/Rocket.Chat/pull/13623)) +- Force some words to translate in other languages ([#13367](https://github.com/RocketChat/Rocket.Chat/pull/13367) by [@soltanabadiyan](https://github.com/soltanabadiyan)) +- Fix wrong imports ([#13601](https://github.com/RocketChat/Rocket.Chat/pull/13601)) +- Fix: Some german translations ([#13299](https://github.com/RocketChat/Rocket.Chat/pull/13299) by [@soenkef](https://github.com/soenkef)) +- Add better positioning for tooltips on edges ([#13472](https://github.com/RocketChat/Rocket.Chat/pull/13472)) +- Fix: Mongo.setConnectionOptions was not being set correctly ([#13586](https://github.com/RocketChat/Rocket.Chat/pull/13586)) +- Regression: Missing settings import at `packages/rocketchat-livechat/server/methods/saveAppearance.js` ([#13573](https://github.com/RocketChat/Rocket.Chat/pull/13573)) +- Depack: Use mainModule for root files ([#13508](https://github.com/RocketChat/Rocket.Chat/pull/13508)) +- Regression: fix app pages styles ([#13567](https://github.com/RocketChat/Rocket.Chat/pull/13567)) +- Move mongo config away from cors package ([#13531](https://github.com/RocketChat/Rocket.Chat/pull/13531)) +- Regression: Add debounce on admin users search to avoid blocking by DDP Rate Limiter ([#13529](https://github.com/RocketChat/Rocket.Chat/pull/13529)) +- Remove Package references ([#13523](https://github.com/RocketChat/Rocket.Chat/pull/13523)) +- Remove Npm.depends and Npm.require except those that are inside package.js ([#13518](https://github.com/RocketChat/Rocket.Chat/pull/13518)) +- Update Meteor 1.8.0.2 ([#13519](https://github.com/RocketChat/Rocket.Chat/pull/13519)) +- Convert rc-nrr and slashcommands open to main module structure ([#13520](https://github.com/RocketChat/Rocket.Chat/pull/13520)) +- Regression: Fix wrong imports in rc-models ([#13516](https://github.com/RocketChat/Rocket.Chat/pull/13516)) +- Regression: Fix autolinker that was not parsing urls correctly ([#13497](https://github.com/RocketChat/Rocket.Chat/pull/13497)) +- Regression: Not updating subscriptions and not showing desktop notifcations ([#13509](https://github.com/RocketChat/Rocket.Chat/pull/13509)) +- Fix some imports from wrong packages, remove exports and files unused in rc-ui ([#13422](https://github.com/RocketChat/Rocket.Chat/pull/13422)) +- Remove functions from globals ([#13421](https://github.com/RocketChat/Rocket.Chat/pull/13421)) +- Remove unused files and code in rc-lib - step 3 ([#13420](https://github.com/RocketChat/Rocket.Chat/pull/13420)) +- Remove unused files in rc-lib - step 2 ([#13419](https://github.com/RocketChat/Rocket.Chat/pull/13419)) +- Remove unused files and code in rc-lib - step 1 ([#13416](https://github.com/RocketChat/Rocket.Chat/pull/13416)) +- Convert rocketchat-lib to main module structure ([#13415](https://github.com/RocketChat/Rocket.Chat/pull/13415)) +- Regression: Message box geolocation was throwing error ([#13496](https://github.com/RocketChat/Rocket.Chat/pull/13496)) +- Import missed functions to remove dependency of RC namespace ([#13414](https://github.com/RocketChat/Rocket.Chat/pull/13414)) +- Convert rocketchat-apps to main module structure ([#13409](https://github.com/RocketChat/Rocket.Chat/pull/13409)) +- Remove dependency of RC namespace in root server folder - step 6 ([#13405](https://github.com/RocketChat/Rocket.Chat/pull/13405)) +- Remove dependency of RC namespace in root server folder - step 5 ([#13402](https://github.com/RocketChat/Rocket.Chat/pull/13402)) +- Remove dependency of RC namespace in root server folder - step 4 ([#13400](https://github.com/RocketChat/Rocket.Chat/pull/13400)) +- Remove dependency of RC namespace in root server folder - step 3 ([#13398](https://github.com/RocketChat/Rocket.Chat/pull/13398)) +- Remove dependency of RC namespace in root server folder - step 2 ([#13397](https://github.com/RocketChat/Rocket.Chat/pull/13397)) +- Remove dependency of RC namespace in root server folder - step 1 ([#13390](https://github.com/RocketChat/Rocket.Chat/pull/13390)) +- Remove dependency of RC namespace in root client folder, imports/message-read-receipt and imports/personal-access-tokens ([#13389](https://github.com/RocketChat/Rocket.Chat/pull/13389)) +- Remove dependency of RC namespace in rc-integrations and importer-hipchat-enterprise ([#13386](https://github.com/RocketChat/Rocket.Chat/pull/13386)) +- Move rc-livechat server models to rc-models ([#13384](https://github.com/RocketChat/Rocket.Chat/pull/13384)) +- Remove dependency of RC namespace in rc-livechat/server/publications ([#13383](https://github.com/RocketChat/Rocket.Chat/pull/13383)) +- Remove dependency of RC namespace in rc-livechat/server/methods ([#13382](https://github.com/RocketChat/Rocket.Chat/pull/13382)) +- Remove dependency of RC namespace in rc-livechat/imports, lib, server/api, server/hooks and server/lib ([#13379](https://github.com/RocketChat/Rocket.Chat/pull/13379)) +- Remove LIvechat global variable from RC namespace ([#13378](https://github.com/RocketChat/Rocket.Chat/pull/13378)) +- Remove dependency of RC namespace in rc-livechat/server/models ([#13377](https://github.com/RocketChat/Rocket.Chat/pull/13377)) +- Remove dependency of RC namespace in livechat/client ([#13370](https://github.com/RocketChat/Rocket.Chat/pull/13370)) +- Remove dependency of RC namespace in rc-wordpress, chatpal-search and irc ([#13492](https://github.com/RocketChat/Rocket.Chat/pull/13492)) +- Remove dependency of RC namespace in rc-videobridge and webdav ([#13366](https://github.com/RocketChat/Rocket.Chat/pull/13366)) +- Remove dependency of RC namespace in rc-ui-master, ui-message- user-data-download and version-check ([#13365](https://github.com/RocketChat/Rocket.Chat/pull/13365)) +- Remove dependency of RC namespace in rc-ui-clean-history, ui-admin and ui-login ([#13362](https://github.com/RocketChat/Rocket.Chat/pull/13362)) +- Remove dependency of RC namespace in rc-ui, ui-account and ui-admin ([#13361](https://github.com/RocketChat/Rocket.Chat/pull/13361)) +- Remove dependency of RC namespace in rc-statistics and tokenpass ([#13359](https://github.com/RocketChat/Rocket.Chat/pull/13359)) +- Remove dependency of RC namespace in rc-smarsh-connector, sms and spotify ([#13358](https://github.com/RocketChat/Rocket.Chat/pull/13358)) +- Remove dependency of RC namespace in rc-slash-kick, leave, me, msg, mute, open, topic and unarchiveroom ([#13357](https://github.com/RocketChat/Rocket.Chat/pull/13357)) +- Remove dependency of RC namespace in rc-slash-archiveroom, create, help, hide, invite, inviteall and join ([#13356](https://github.com/RocketChat/Rocket.Chat/pull/13356)) +- Remove dependency of RC namespace in rc-setup-wizard, slackbridge and asciiarts ([#13348](https://github.com/RocketChat/Rocket.Chat/pull/13348)) +- Remove dependency of RC namespace in rc-reactions, retention-policy and search ([#13347](https://github.com/RocketChat/Rocket.Chat/pull/13347)) +- Remove dependency of RC namespace in rc-oembed and rc-otr ([#13345](https://github.com/RocketChat/Rocket.Chat/pull/13345)) +- Remove dependency of RC namespace in rc-oauth2-server and message-star ([#13344](https://github.com/RocketChat/Rocket.Chat/pull/13344)) +- Remove dependency of RC namespace in rc-message-pin and message-snippet ([#13343](https://github.com/RocketChat/Rocket.Chat/pull/13343)) +- Depackaging ([#13483](https://github.com/RocketChat/Rocket.Chat/pull/13483)) +- Merge master into develop & Set version to 1.0.0-develop ([#13435](https://github.com/RocketChat/Rocket.Chat/pull/13435) by [@TkTech](https://github.com/TkTech) & [@theundefined](https://github.com/theundefined)) +- Regression: Table admin pages ([#13411](https://github.com/RocketChat/Rocket.Chat/pull/13411)) +- Regression: Template error ([#13410](https://github.com/RocketChat/Rocket.Chat/pull/13410)) +- Removed old templates ([#13406](https://github.com/RocketChat/Rocket.Chat/pull/13406)) +- Add pagination to getUsersOfRoom ([#12834](https://github.com/RocketChat/Rocket.Chat/pull/12834)) +- OpenShift custom OAuth support ([#13925](https://github.com/RocketChat/Rocket.Chat/pull/13925) by [@bsharrow](https://github.com/bsharrow)) +- Settings: disable reset button ([#14026](https://github.com/RocketChat/Rocket.Chat/pull/14026)) +- Settings: hiding reset button for readonly fields ([#14025](https://github.com/RocketChat/Rocket.Chat/pull/14025)) +- Fix debug logging not being enabled by the setting ([#13979](https://github.com/RocketChat/Rocket.Chat/pull/13979)) +- Deprecate /api/v1/info in favor of /api/info ([#13798](https://github.com/RocketChat/Rocket.Chat/pull/13798)) +- Change dynamic dependency of FileUpload in Messages models ([#13776](https://github.com/RocketChat/Rocket.Chat/pull/13776)) +- Allow set env var METEOR_OPLOG_TOO_FAR_BEHIND ([#14017](https://github.com/RocketChat/Rocket.Chat/pull/14017)) +- Improve: Decrease padding for app buy modal ([#13984](https://github.com/RocketChat/Rocket.Chat/pull/13984)) +- Prioritize user-mentions badge ([#14057](https://github.com/RocketChat/Rocket.Chat/pull/14057)) +- Proper thread quote, clear message box on send, and other nice things to have ([#14049](https://github.com/RocketChat/Rocket.Chat/pull/14049)) +- Fix: Tests were not exiting RC instances ([#14054](https://github.com/RocketChat/Rocket.Chat/pull/14054)) +- Fix shield indentation ([#14048](https://github.com/RocketChat/Rocket.Chat/pull/14048)) +- Fix modal scroll ([#14052](https://github.com/RocketChat/Rocket.Chat/pull/14052)) +- Fix race condition of lastMessage set ([#14041](https://github.com/RocketChat/Rocket.Chat/pull/14041)) +- Fix room re-rendering ([#14044](https://github.com/RocketChat/Rocket.Chat/pull/14044)) +- Fix sending notifications to mentions on threads and discussion email sender ([#14043](https://github.com/RocketChat/Rocket.Chat/pull/14043)) +- Fix discussions issues after room deletion and translation actions not being shown ([#14018](https://github.com/RocketChat/Rocket.Chat/pull/14018)) +- Show discussion avatar ([#14053](https://github.com/RocketChat/Rocket.Chat/pull/14053)) +- Fix threads tests ([#14180](https://github.com/RocketChat/Rocket.Chat/pull/14180)) +- Prevent error for ldap login with invalid characters ([#14160](https://github.com/RocketChat/Rocket.Chat/pull/14160)) +- [REGRESSION] Messages sent by livechat's guests are losing sender info ([#14174](https://github.com/RocketChat/Rocket.Chat/pull/14174)) +- Faster CI build for PR ([#14171](https://github.com/RocketChat/Rocket.Chat/pull/14171)) +- Regression: Message box does not go back to initial state after sending a message ([#14161](https://github.com/RocketChat/Rocket.Chat/pull/14161)) +- Prevent error on normalize thread message for preview ([#14170](https://github.com/RocketChat/Rocket.Chat/pull/14170)) +- Update badges and mention links colors ([#14071](https://github.com/RocketChat/Rocket.Chat/pull/14071)) +- Smaller thread replies and system messages ([#14099](https://github.com/RocketChat/Rocket.Chat/pull/14099)) +- Regression: User autocomplete was not listing users from correct room ([#14125](https://github.com/RocketChat/Rocket.Chat/pull/14125)) +- Regression: Role creation and deletion error fixed ([#14097](https://github.com/RocketChat/Rocket.Chat/pull/14097) by [@knrt10](https://github.com/knrt10)) +- [Regression] Fix integrations message example ([#14111](https://github.com/RocketChat/Rocket.Chat/pull/14111)) +- Fix update apps capability of updating messages ([#14118](https://github.com/RocketChat/Rocket.Chat/pull/14118)) +- Fix: Skip thread notifications on message edit ([#14100](https://github.com/RocketChat/Rocket.Chat/pull/14100)) +- Fix: Remove message class `sequential` if `new-day` is present ([#14116](https://github.com/RocketChat/Rocket.Chat/pull/14116)) +- Fix top bar unread message counter ([#14102](https://github.com/RocketChat/Rocket.Chat/pull/14102)) +- LingoHub based on develop ([#14046](https://github.com/RocketChat/Rocket.Chat/pull/14046)) +- Fix sending message from action buttons in messages ([#14101](https://github.com/RocketChat/Rocket.Chat/pull/14101)) +- Fix: Error when version check endpoint was returning invalid data ([#14089](https://github.com/RocketChat/Rocket.Chat/pull/14089)) +- Wait port release to finish tests ([#14066](https://github.com/RocketChat/Rocket.Chat/pull/14066)) +- Fix threads rendering performance ([#14059](https://github.com/RocketChat/Rocket.Chat/pull/14059)) +- Unstuck observers every minute ([#14076](https://github.com/RocketChat/Rocket.Chat/pull/14076)) +- Fix messages losing thread titles on editing or reaction and improve message actions ([#14051](https://github.com/RocketChat/Rocket.Chat/pull/14051)) +- Improve message validation ([#14266](https://github.com/RocketChat/Rocket.Chat/pull/14266)) +- Added federation ping, loopback and dashboard ([#14007](https://github.com/RocketChat/Rocket.Chat/pull/14007)) +- Regression: Exception on notification when adding someone in room via mention ([#14251](https://github.com/RocketChat/Rocket.Chat/pull/14251)) +- Regression: fix grouping for reactive message ([#14246](https://github.com/RocketChat/Rocket.Chat/pull/14246)) +- Regression: Cursor position set to beginning when editing a message ([#14245](https://github.com/RocketChat/Rocket.Chat/pull/14245)) +- Regression: grouping messages on threads ([#14238](https://github.com/RocketChat/Rocket.Chat/pull/14238)) +- Regression: Remove border from unstyled message body ([#14235](https://github.com/RocketChat/Rocket.Chat/pull/14235)) +- Move LDAP Escape to login handler ([#14234](https://github.com/RocketChat/Rocket.Chat/pull/14234)) +- [Regression] Personal Access Token list fixed ([#14216](https://github.com/RocketChat/Rocket.Chat/pull/14216) by [@knrt10](https://github.com/knrt10)) +- ESLint: Add more import rules ([#14226](https://github.com/RocketChat/Rocket.Chat/pull/14226)) +- Regression: fix drop file ([#14225](https://github.com/RocketChat/Rocket.Chat/pull/14225)) +- Broken styles in Administration's contextual bar ([#14222](https://github.com/RocketChat/Rocket.Chat/pull/14222)) +- Regression: Broken UI for messages ([#14223](https://github.com/RocketChat/Rocket.Chat/pull/14223)) +- Exit process on unhandled rejection ([#14220](https://github.com/RocketChat/Rocket.Chat/pull/14220)) +- Unify mime-type package configuration ([#14217](https://github.com/RocketChat/Rocket.Chat/pull/14217)) +- Regression: Prevent startup errors for mentions parsing ([#14219](https://github.com/RocketChat/Rocket.Chat/pull/14219)) +- Regression: System messages styling ([#14189](https://github.com/RocketChat/Rocket.Chat/pull/14189)) +- Prevent click on reply thread to trigger flex tab closing ([#14215](https://github.com/RocketChat/Rocket.Chat/pull/14215)) +- created function to allow change default values, fix loading search users ([#14177](https://github.com/RocketChat/Rocket.Chat/pull/14177)) +- Use main message as thread tab title ([#14213](https://github.com/RocketChat/Rocket.Chat/pull/14213)) +- Use own logic to get thread infos via REST ([#14210](https://github.com/RocketChat/Rocket.Chat/pull/14210)) +- Regression: wrong expression at messageBox.actions.remove() ([#14192](https://github.com/RocketChat/Rocket.Chat/pull/14192)) +- Increment user counter on DMs ([#14185](https://github.com/RocketChat/Rocket.Chat/pull/14185)) +- [REGRESSION] Fix variable name references in message template ([#14184](https://github.com/RocketChat/Rocket.Chat/pull/14184)) +- Regression: Active room was not being marked ([#14276](https://github.com/RocketChat/Rocket.Chat/pull/14276)) +- Rename Cloud to Connectivity Services & split Apps in Apps and Marketplace ([#14211](https://github.com/RocketChat/Rocket.Chat/pull/14211)) +- LingoHub based on develop ([#14178](https://github.com/RocketChat/Rocket.Chat/pull/14178)) +- Regression: Discussions were not showing on Tab Bar ([#14050](https://github.com/RocketChat/Rocket.Chat/pull/14050) by [@knrt10](https://github.com/knrt10)) +- Force unstyling of blockquote under .message-body--unstyled ([#14274](https://github.com/RocketChat/Rocket.Chat/pull/14274)) +- Regression: Admin embedded layout ([#14229](https://github.com/RocketChat/Rocket.Chat/pull/14229)) +- New threads layout ([#14269](https://github.com/RocketChat/Rocket.Chat/pull/14269)) +- Improve: Marketplace auth inside Rocket.Chat instead of inside the iframe. ([#14258](https://github.com/RocketChat/Rocket.Chat/pull/14258)) +- [New] Reply privately to group messages ([#14150](https://github.com/RocketChat/Rocket.Chat/pull/14150) by [@bhardwajaditya](https://github.com/bhardwajaditya)) + +
+ +### 👩‍💻👨‍💻 Contributors 😍 + +- [@DeviaVir](https://github.com/DeviaVir) +- [@Kailash0311](https://github.com/Kailash0311) +- [@MohammedEssehemy](https://github.com/MohammedEssehemy) +- [@Montel](https://github.com/Montel) +- [@Mr-Linus](https://github.com/Mr-Linus) +- [@Peym4n](https://github.com/Peym4n) +- [@TkTech](https://github.com/TkTech) +- [@algomaster99](https://github.com/algomaster99) +- [@ashwaniYDV](https://github.com/ashwaniYDV) +- [@bhardwajaditya](https://github.com/bhardwajaditya) +- [@bsharrow](https://github.com/bsharrow) +- [@fliptrail](https://github.com/fliptrail) +- [@gsunit](https://github.com/gsunit) +- [@hmagarotto](https://github.com/hmagarotto) +- [@huydang284](https://github.com/huydang284) +- [@hypery2k](https://github.com/hypery2k) +- [@jhnburke8](https://github.com/jhnburke8) +- [@john08burke](https://github.com/john08burke) +- [@kable-wilmoth](https://github.com/kable-wilmoth) +- [@knrt10](https://github.com/knrt10) +- [@localguru](https://github.com/localguru) +- [@mjovanovic0](https://github.com/mjovanovic0) +- [@ngulden](https://github.com/ngulden) +- [@nylen](https://github.com/nylen) +- [@pkolmann](https://github.com/pkolmann) +- [@ralfbecker](https://github.com/ralfbecker) +- [@rssilva](https://github.com/rssilva) +- [@savish28](https://github.com/savish28) +- [@soenkef](https://github.com/soenkef) +- [@soltanabadiyan](https://github.com/soltanabadiyan) +- [@steerben](https://github.com/steerben) +- [@supra08](https://github.com/supra08) +- [@thayannevls](https://github.com/thayannevls) +- [@the4ndy](https://github.com/the4ndy) +- [@theundefined](https://github.com/theundefined) +- [@tiangolo](https://github.com/tiangolo) +- [@trivoallan](https://github.com/trivoallan) +- [@ulf-f](https://github.com/ulf-f) +- [@ura14h](https://github.com/ura14h) +- [@vickyokrm](https://github.com/vickyokrm) +- [@vinade](https://github.com/vinade) +- [@wreiske](https://github.com/wreiske) +- [@xbolshe](https://github.com/xbolshe) +- [@zolbayars](https://github.com/zolbayars) + +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@Hudell](https://github.com/Hudell) +- [@LuluGO](https://github.com/LuluGO) +- [@MarcosSpessatto](https://github.com/MarcosSpessatto) +- [@alansikora](https://github.com/alansikora) +- [@d-gubert](https://github.com/d-gubert) +- [@engelgabriel](https://github.com/engelgabriel) +- [@geekgonecrazy](https://github.com/geekgonecrazy) +- [@ggazzo](https://github.com/ggazzo) +- [@graywolf336](https://github.com/graywolf336) +- [@marceloschmidt](https://github.com/marceloschmidt) +- [@mrsimpson](https://github.com/mrsimpson) +- [@renatobecker](https://github.com/renatobecker) +- [@rodrigok](https://github.com/rodrigok) +- [@sampaiodiego](https://github.com/sampaiodiego) +- [@tassoevan](https://github.com/tassoevan) +- [@timkinnane](https://github.com/timkinnane) + # 0.74.3 -`2019-02-13 · 3 🚀 · 11 🐛 · 2 🔍 · 9 👩‍💻👨‍💻` +`2019-02-13 · 3 🚀 · 11 🐛 · 3 🔍 · 9 👩‍💻👨‍💻` ### Engine versions - Node: `8.11.4` @@ -30,6 +458,7 @@
🔍 Minor changes +- Release 0.74.3 ([#13474](https://github.com/RocketChat/Rocket.Chat/pull/13474) by [@BehindLoader](https://github.com/BehindLoader) & [@leonboot](https://github.com/leonboot)) - Room loading improvements ([#13471](https://github.com/RocketChat/Rocket.Chat/pull/13471)) - Regression: Remove console.log on email translations ([#13456](https://github.com/RocketChat/Rocket.Chat/pull/13456)) @@ -316,7 +745,7 @@ - Syncloud deploy option ([#12867](https://github.com/RocketChat/Rocket.Chat/pull/12867) by [@cyberb](https://github.com/cyberb)) - Config hooks for snap ([#12351](https://github.com/RocketChat/Rocket.Chat/pull/12351)) - Livechat registration form message ([#12597](https://github.com/RocketChat/Rocket.Chat/pull/12597)) -- Include message type & id in push notification payload ([#12771](https://github.com/RocketChat/Rocket.Chat/pull/12771)) +- Include message type & id in push notification payload ([#12771](https://github.com/RocketChat/Rocket.Chat/pull/12771) by [@cardoso](https://github.com/cardoso)) ### 🚀 Improvements @@ -460,6 +889,7 @@ ### 👩‍💻👨‍💻 Contributors 😍 - [@alexbartsch](https://github.com/alexbartsch) +- [@cardoso](https://github.com/cardoso) - [@cyberb](https://github.com/cyberb) - [@hypery2k](https://github.com/hypery2k) - [@karakayasemi](https://github.com/karakayasemi) @@ -478,7 +908,6 @@ - [@Hudell](https://github.com/Hudell) - [@LuluGO](https://github.com/LuluGO) - [@MarcosSpessatto](https://github.com/MarcosSpessatto) -- [@cardoso](https://github.com/cardoso) - [@d-gubert](https://github.com/d-gubert) - [@engelgabriel](https://github.com/engelgabriel) - [@geekgonecrazy](https://github.com/geekgonecrazy) @@ -574,7 +1003,7 @@ - Add permission to enable personal access token to specific roles ([#12309](https://github.com/RocketChat/Rocket.Chat/pull/12309)) - Option to reset e2e key ([#12483](https://github.com/RocketChat/Rocket.Chat/pull/12483)) -- /api/v1/spotlight: return joinCodeRequired field for rooms ([#12651](https://github.com/RocketChat/Rocket.Chat/pull/12651)) +- /api/v1/spotlight: return joinCodeRequired field for rooms ([#12651](https://github.com/RocketChat/Rocket.Chat/pull/12651) by [@cardoso](https://github.com/cardoso)) - New API Endpoints for the new version of JS SDK ([#12623](https://github.com/RocketChat/Rocket.Chat/pull/12623)) - Setting to configure robots.txt content ([#12547](https://github.com/RocketChat/Rocket.Chat/pull/12547)) - Make Livechat's widget draggable ([#12378](https://github.com/RocketChat/Rocket.Chat/pull/12378)) @@ -712,6 +1141,7 @@ - [@AndreamApp](https://github.com/AndreamApp) - [@Ismaw34](https://github.com/Ismaw34) +- [@cardoso](https://github.com/cardoso) - [@imronras](https://github.com/imronras) - [@karlprieb](https://github.com/karlprieb) - [@mbrodala](https://github.com/mbrodala) @@ -728,7 +1158,6 @@ - [@Hudell](https://github.com/Hudell) - [@MarcosSpessatto](https://github.com/MarcosSpessatto) -- [@cardoso](https://github.com/cardoso) - [@engelgabriel](https://github.com/engelgabriel) - [@ggazzo](https://github.com/ggazzo) - [@marceloschmidt](https://github.com/marceloschmidt) @@ -786,7 +1215,7 @@ - sidenav size on large screens ([#12372](https://github.com/RocketChat/Rocket.Chat/pull/12372)) - Ability to disable user presence monitor ([#12353](https://github.com/RocketChat/Rocket.Chat/pull/12353)) - PDF message attachment preview (client side rendering) ([#10519](https://github.com/RocketChat/Rocket.Chat/pull/10519) by [@kb0304](https://github.com/kb0304)) -- Add "help wanted" section to Readme ([#12432](https://github.com/RocketChat/Rocket.Chat/pull/12432)) +- Add "help wanted" section to Readme ([#12432](https://github.com/RocketChat/Rocket.Chat/pull/12432) by [@isabellarussell](https://github.com/isabellarussell)) ### 🚀 Improvements @@ -838,6 +1267,7 @@ - [@MarcosEllys](https://github.com/MarcosEllys) - [@crazy-max](https://github.com/crazy-max) +- [@isabellarussell](https://github.com/isabellarussell) - [@kb0304](https://github.com/kb0304) - [@madguy02](https://github.com/madguy02) - [@nikeee](https://github.com/nikeee) @@ -854,7 +1284,6 @@ - [@geekgonecrazy](https://github.com/geekgonecrazy) - [@ggazzo](https://github.com/ggazzo) - [@graywolf336](https://github.com/graywolf336) -- [@isabellarussell](https://github.com/isabellarussell) - [@renatobecker](https://github.com/renatobecker) - [@rodrigok](https://github.com/rodrigok) - [@sampaiodiego](https://github.com/sampaiodiego) @@ -944,7 +1373,7 @@ 🔍 Minor changes - Release 0.70.1 ([#12270](https://github.com/RocketChat/Rocket.Chat/pull/12270) by [@edzluhan](https://github.com/edzluhan)) -- Merge master into develop & Set version to 0.71.0-develop ([#12264](https://github.com/RocketChat/Rocket.Chat/pull/12264) by [@kaiiiiiiiii](https://github.com/kaiiiiiiiii)) +- Merge master into develop & Set version to 0.71.0-develop ([#12264](https://github.com/RocketChat/Rocket.Chat/pull/12264) by [@cardoso](https://github.com/cardoso) & [@kaiiiiiiiii](https://github.com/kaiiiiiiiii)) - Regression: fix modal submit ([#12233](https://github.com/RocketChat/Rocket.Chat/pull/12233)) - Add reetp to the issues' bot whitelist ([#12227](https://github.com/RocketChat/Rocket.Chat/pull/12227)) - Fix: Remove semver satisfies from Apps details that is already done my marketplace ([#12268](https://github.com/RocketChat/Rocket.Chat/pull/12268)) @@ -953,13 +1382,13 @@ ### 👩‍💻👨‍💻 Contributors 😍 +- [@cardoso](https://github.com/cardoso) - [@edzluhan](https://github.com/edzluhan) - [@kaiiiiiiiii](https://github.com/kaiiiiiiiii) ### 👩‍💻👨‍💻 Core Team 🤓 - [@Hudell](https://github.com/Hudell) -- [@cardoso](https://github.com/cardoso) - [@ggazzo](https://github.com/ggazzo) - [@renatobecker](https://github.com/renatobecker) - [@rodrigok](https://github.com/rodrigok) @@ -983,9 +1412,9 @@ ### 🎉 New features - Allow multiple subcommands in MIGRATION_VERSION env variable ([#11184](https://github.com/RocketChat/Rocket.Chat/pull/11184) by [@arch119](https://github.com/arch119)) -- Support for end to end encryption ([#10094](https://github.com/RocketChat/Rocket.Chat/pull/10094)) +- Support for end to end encryption ([#10094](https://github.com/RocketChat/Rocket.Chat/pull/10094) by [@mrinaldhar](https://github.com/mrinaldhar)) - Livechat Analytics and Reports ([#11238](https://github.com/RocketChat/Rocket.Chat/pull/11238) by [@pkgodara](https://github.com/pkgodara)) -- Apps: Add handlers for message updates ([#11993](https://github.com/RocketChat/Rocket.Chat/pull/11993)) +- Apps: Add handlers for message updates ([#11993](https://github.com/RocketChat/Rocket.Chat/pull/11993) by [@cardoso](https://github.com/cardoso)) - Livechat notifications on new incoming inquiries for guest-pool ([#10588](https://github.com/RocketChat/Rocket.Chat/pull/10588)) - Customizable default directory view ([#11965](https://github.com/RocketChat/Rocket.Chat/pull/11965) by [@ohmonster](https://github.com/ohmonster)) - Blockstack as decentralized auth provider ([#12047](https://github.com/RocketChat/Rocket.Chat/pull/12047)) @@ -1044,13 +1473,13 @@
🔍 Minor changes -- Release 0.69.2 ([#12026](https://github.com/RocketChat/Rocket.Chat/pull/12026) by [@kaiiiiiiiii](https://github.com/kaiiiiiiiii)) +- Release 0.69.2 ([#12026](https://github.com/RocketChat/Rocket.Chat/pull/12026) by [@cardoso](https://github.com/cardoso) & [@kaiiiiiiiii](https://github.com/kaiiiiiiiii)) - LingoHub based on develop ([#11936](https://github.com/RocketChat/Rocket.Chat/pull/11936)) - Better organize package.json ([#12115](https://github.com/RocketChat/Rocket.Chat/pull/12115)) - Fix using wrong variable ([#12114](https://github.com/RocketChat/Rocket.Chat/pull/12114)) - Fix the style lint ([#11991](https://github.com/RocketChat/Rocket.Chat/pull/11991)) - Merge master into develop & Set version to 0.70.0-develop ([#11921](https://github.com/RocketChat/Rocket.Chat/pull/11921) by [@c0dzilla](https://github.com/c0dzilla) & [@rndmh3ro](https://github.com/rndmh3ro) & [@ubarsaiyan](https://github.com/ubarsaiyan) & [@vynmera](https://github.com/vynmera)) -- Release 0.69.2 ([#12026](https://github.com/RocketChat/Rocket.Chat/pull/12026) by [@kaiiiiiiiii](https://github.com/kaiiiiiiiii)) +- Release 0.69.2 ([#12026](https://github.com/RocketChat/Rocket.Chat/pull/12026) by [@cardoso](https://github.com/cardoso) & [@kaiiiiiiiii](https://github.com/kaiiiiiiiii)) - Regression: fix message box autogrow ([#12138](https://github.com/RocketChat/Rocket.Chat/pull/12138)) - Regression: Modal height ([#12122](https://github.com/RocketChat/Rocket.Chat/pull/12122)) - Fix: Change wording on e2e to make a little more clear ([#12124](https://github.com/RocketChat/Rocket.Chat/pull/12124)) @@ -1075,11 +1504,13 @@ - [@aferreira44](https://github.com/aferreira44) - [@arch119](https://github.com/arch119) - [@c0dzilla](https://github.com/c0dzilla) +- [@cardoso](https://github.com/cardoso) - [@crazy-max](https://github.com/crazy-max) - [@edzluhan](https://github.com/edzluhan) - [@flaviogrossi](https://github.com/flaviogrossi) - [@kaiiiiiiiii](https://github.com/kaiiiiiiiii) - [@karakayasemi](https://github.com/karakayasemi) +- [@mrinaldhar](https://github.com/mrinaldhar) - [@ohmonster](https://github.com/ohmonster) - [@pkgodara](https://github.com/pkgodara) - [@rndmh3ro](https://github.com/rndmh3ro) @@ -1094,12 +1525,10 @@ - [@Hudell](https://github.com/Hudell) - [@MarcosSpessatto](https://github.com/MarcosSpessatto) - [@MartinSchoeler](https://github.com/MartinSchoeler) -- [@cardoso](https://github.com/cardoso) - [@engelgabriel](https://github.com/engelgabriel) - [@geekgonecrazy](https://github.com/geekgonecrazy) - [@ggazzo](https://github.com/ggazzo) - [@graywolf336](https://github.com/graywolf336) -- [@mrinaldhar](https://github.com/mrinaldhar) - [@mrsimpson](https://github.com/mrsimpson) - [@renatobecker](https://github.com/renatobecker) - [@rodrigok](https://github.com/rodrigok) @@ -1121,17 +1550,17 @@ ### 🐛 Bug fixes - Reset password link error if already logged in ([#12022](https://github.com/RocketChat/Rocket.Chat/pull/12022)) -- Apps: setting with 'code' type only saving last line ([#11992](https://github.com/RocketChat/Rocket.Chat/pull/11992)) +- Apps: setting with 'code' type only saving last line ([#11992](https://github.com/RocketChat/Rocket.Chat/pull/11992) by [@cardoso](https://github.com/cardoso)) - Update user information not possible by admin if disabled to users ([#11955](https://github.com/RocketChat/Rocket.Chat/pull/11955) by [@kaiiiiiiiii](https://github.com/kaiiiiiiiii)) - Hidden admin sidenav on embedded layout ([#12025](https://github.com/RocketChat/Rocket.Chat/pull/12025)) ### 👩‍💻👨‍💻 Contributors 😍 +- [@cardoso](https://github.com/cardoso) - [@kaiiiiiiiii](https://github.com/kaiiiiiiiii) ### 👩‍💻👨‍💻 Core Team 🤓 -- [@cardoso](https://github.com/cardoso) - [@ggazzo](https://github.com/ggazzo) - [@rodrigok](https://github.com/rodrigok) - [@sampaiodiego](https://github.com/sampaiodiego) @@ -1429,7 +1858,7 @@ ### 🚀 Improvements -- Set default max upload size to 100mb ([#11327](https://github.com/RocketChat/Rocket.Chat/pull/11327)) +- Set default max upload size to 100mb ([#11327](https://github.com/RocketChat/Rocket.Chat/pull/11327) by [@cardoso](https://github.com/cardoso)) - Typing indicators now use Real Names ([#11164](https://github.com/RocketChat/Rocket.Chat/pull/11164) by [@vynmera](https://github.com/vynmera)) - Allow markdown in room topic, announcement, and description including single quotes ([#11408](https://github.com/RocketChat/Rocket.Chat/pull/11408)) @@ -1482,6 +1911,7 @@ - [@PhpXp](https://github.com/PhpXp) - [@arminfelder](https://github.com/arminfelder) - [@arungalva](https://github.com/arungalva) +- [@cardoso](https://github.com/cardoso) - [@karlprieb](https://github.com/karlprieb) - [@soundstorm](https://github.com/soundstorm) - [@tpDBL](https://github.com/tpDBL) @@ -1493,7 +1923,6 @@ - [@MarcosSpessatto](https://github.com/MarcosSpessatto) - [@MartinSchoeler](https://github.com/MartinSchoeler) - [@brunosquadros](https://github.com/brunosquadros) -- [@cardoso](https://github.com/cardoso) - [@engelgabriel](https://github.com/engelgabriel) - [@geekgonecrazy](https://github.com/geekgonecrazy) - [@ggazzo](https://github.com/ggazzo) @@ -1723,7 +2152,7 @@ - Allow inviting livechat managers to the same LiveChat room ([#10956](https://github.com/RocketChat/Rocket.Chat/pull/10956)) - Cannot read property 'debug' of undefined when trying to use REST API ([#10805](https://github.com/RocketChat/Rocket.Chat/pull/10805) by [@haffla](https://github.com/haffla)) - Icons svg xml structure ([#10771](https://github.com/RocketChat/Rocket.Chat/pull/10771)) -- Remove outdated 2FA warning for mobile clients ([#10916](https://github.com/RocketChat/Rocket.Chat/pull/10916)) +- Remove outdated 2FA warning for mobile clients ([#10916](https://github.com/RocketChat/Rocket.Chat/pull/10916) by [@cardoso](https://github.com/cardoso)) - Update Sandstorm build config ([#10867](https://github.com/RocketChat/Rocket.Chat/pull/10867) by [@ocdtrekkie](https://github.com/ocdtrekkie)) - "blank messages" on iOS < 11 ([#11221](https://github.com/RocketChat/Rocket.Chat/pull/11221)) - "blank" screen on iOS < 11 ([#11199](https://github.com/RocketChat/Rocket.Chat/pull/11199)) @@ -1766,7 +2195,7 @@ - NPM Dependencies Update ([#10913](https://github.com/RocketChat/Rocket.Chat/pull/10913)) - update meteor to 1.6.1 for sandstorm build ([#10131](https://github.com/RocketChat/Rocket.Chat/pull/10131) by [@peterlee0127](https://github.com/peterlee0127)) - Renaming username.username to username.value for clarity ([#10986](https://github.com/RocketChat/Rocket.Chat/pull/10986)) -- Fix readme typo ([#5](https://github.com/RocketChat/Rocket.Chat/pull/5)) +- Fix readme typo ([#5](https://github.com/RocketChat/Rocket.Chat/pull/5) by [@filipealva](https://github.com/filipealva)) - Remove wrong and not needed time unit ([#10807](https://github.com/RocketChat/Rocket.Chat/pull/10807) by [@cliffparnitzky](https://github.com/cliffparnitzky)) - Develop sync commits ([#10909](https://github.com/RocketChat/Rocket.Chat/pull/10909) by [@nsuchy](https://github.com/nsuchy)) - Develop sync2 ([#10908](https://github.com/RocketChat/Rocket.Chat/pull/10908) by [@nsuchy](https://github.com/nsuchy)) @@ -1792,8 +2221,10 @@ - [@JoseRenan](https://github.com/JoseRenan) - [@brylie](https://github.com/brylie) - [@c0dzilla](https://github.com/c0dzilla) +- [@cardoso](https://github.com/cardoso) - [@cliffparnitzky](https://github.com/cliffparnitzky) - [@cpitman](https://github.com/cpitman) +- [@filipealva](https://github.com/filipealva) - [@gdelavald](https://github.com/gdelavald) - [@haffla](https://github.com/haffla) - [@jonnilundy](https://github.com/jonnilundy) @@ -1825,9 +2256,7 @@ - [@Hudell](https://github.com/Hudell) - [@MarcosSpessatto](https://github.com/MarcosSpessatto) - [@alansikora](https://github.com/alansikora) -- [@cardoso](https://github.com/cardoso) - [@engelgabriel](https://github.com/engelgabriel) -- [@filipealva](https://github.com/filipealva) - [@geekgonecrazy](https://github.com/geekgonecrazy) - [@ggazzo](https://github.com/ggazzo) - [@graywolf336](https://github.com/graywolf336) @@ -1908,7 +2337,7 @@ - Now is possible to access files using header authorization (`x-user-id` and `x-auth-token`) ([#10741](https://github.com/RocketChat/Rocket.Chat/pull/10741)) - Add REST API endpoints `channels.counters`, `groups.counters and `im.counters` ([#9679](https://github.com/RocketChat/Rocket.Chat/pull/9679) by [@xbolshe](https://github.com/xbolshe)) - Add REST API endpoints `channels.setCustomFields` and `groups.setCustomFields` ([#9733](https://github.com/RocketChat/Rocket.Chat/pull/9733) by [@xbolshe](https://github.com/xbolshe)) -- Add permission `view-broadcast-member-list` ([#10753](https://github.com/RocketChat/Rocket.Chat/pull/10753)) +- Add permission `view-broadcast-member-list` ([#10753](https://github.com/RocketChat/Rocket.Chat/pull/10753) by [@cardoso](https://github.com/cardoso)) ### 🐛 Bug fixes @@ -1932,7 +2361,7 @@
🔍 Minor changes -- Release 0.65.0 ([#10893](https://github.com/RocketChat/Rocket.Chat/pull/10893) by [@Sameesunkaria](https://github.com/Sameesunkaria) & [@erhan-](https://github.com/erhan-) & [@gdelavald](https://github.com/gdelavald) & [@karlprieb](https://github.com/karlprieb) & [@peccu](https://github.com/peccu) & [@winterstefan](https://github.com/winterstefan)) +- Release 0.65.0 ([#10893](https://github.com/RocketChat/Rocket.Chat/pull/10893) by [@Sameesunkaria](https://github.com/Sameesunkaria) & [@cardoso](https://github.com/cardoso) & [@erhan-](https://github.com/erhan-) & [@gdelavald](https://github.com/gdelavald) & [@karlprieb](https://github.com/karlprieb) & [@peccu](https://github.com/peccu) & [@winterstefan](https://github.com/winterstefan)) - Apps: Command Previews, Message and Room Removal Events ([#10822](https://github.com/RocketChat/Rocket.Chat/pull/10822)) - Develop sync ([#10815](https://github.com/RocketChat/Rocket.Chat/pull/10815) by [@nsuchy](https://github.com/nsuchy)) - Major dependencies update ([#10661](https://github.com/RocketChat/Rocket.Chat/pull/10661)) @@ -1956,6 +2385,7 @@ - [@Sameesunkaria](https://github.com/Sameesunkaria) - [@ThomasRoehl](https://github.com/ThomasRoehl) - [@c0dzilla](https://github.com/c0dzilla) +- [@cardoso](https://github.com/cardoso) - [@cfunkles](https://github.com/cfunkles) - [@chuckAtCataworx](https://github.com/chuckAtCataworx) - [@erhan-](https://github.com/erhan-) @@ -1971,7 +2401,6 @@ - [@Hudell](https://github.com/Hudell) - [@MarcosSpessatto](https://github.com/MarcosSpessatto) -- [@cardoso](https://github.com/cardoso) - [@engelgabriel](https://github.com/engelgabriel) - [@geekgonecrazy](https://github.com/geekgonecrazy) - [@ggazzo](https://github.com/ggazzo) @@ -1990,11 +2419,11 @@ ### 🎉 New features -- Add REST endpoints `channels.roles` & `groups.roles` ([#10607](https://github.com/RocketChat/Rocket.Chat/pull/10607)) +- Add REST endpoints `channels.roles` & `groups.roles` ([#10607](https://github.com/RocketChat/Rocket.Chat/pull/10607) by [@cardoso](https://github.com/cardoso)) - Add more options for Wordpress OAuth configuration ([#10724](https://github.com/RocketChat/Rocket.Chat/pull/10724)) - Setup Wizard ([#10523](https://github.com/RocketChat/Rocket.Chat/pull/10523) by [@karlprieb](https://github.com/karlprieb)) - Improvements to notifications logic ([#10686](https://github.com/RocketChat/Rocket.Chat/pull/10686)) -- Add REST endpoints `channels.roles` & `groups.roles` ([#10607](https://github.com/RocketChat/Rocket.Chat/pull/10607)) +- Add REST endpoints `channels.roles` & `groups.roles` ([#10607](https://github.com/RocketChat/Rocket.Chat/pull/10607) by [@cardoso](https://github.com/cardoso)) - Add more options for Wordpress OAuth configuration ([#10724](https://github.com/RocketChat/Rocket.Chat/pull/10724)) - Setup Wizard ([#10523](https://github.com/RocketChat/Rocket.Chat/pull/10523) by [@karlprieb](https://github.com/karlprieb)) - Improvements to notifications logic ([#10686](https://github.com/RocketChat/Rocket.Chat/pull/10686)) @@ -2021,7 +2450,7 @@
🔍 Minor changes -- Release 0.64.2 ([#10812](https://github.com/RocketChat/Rocket.Chat/pull/10812) by [@Sameesunkaria](https://github.com/Sameesunkaria) & [@erhan-](https://github.com/erhan-) & [@gdelavald](https://github.com/gdelavald) & [@karlprieb](https://github.com/karlprieb) & [@peccu](https://github.com/peccu) & [@winterstefan](https://github.com/winterstefan)) +- Release 0.64.2 ([#10812](https://github.com/RocketChat/Rocket.Chat/pull/10812) by [@Sameesunkaria](https://github.com/Sameesunkaria) & [@cardoso](https://github.com/cardoso) & [@erhan-](https://github.com/erhan-) & [@gdelavald](https://github.com/gdelavald) & [@karlprieb](https://github.com/karlprieb) & [@peccu](https://github.com/peccu) & [@winterstefan](https://github.com/winterstefan)) - Prometheus: Add metric to track hooks time ([#10798](https://github.com/RocketChat/Rocket.Chat/pull/10798)) - Regression: Autorun of wizard was not destroyed after completion ([#10802](https://github.com/RocketChat/Rocket.Chat/pull/10802)) - Prometheus: Fix notification metric ([#10803](https://github.com/RocketChat/Rocket.Chat/pull/10803)) @@ -2058,6 +2487,7 @@ ### 👩‍💻👨‍💻 Contributors 😍 - [@Sameesunkaria](https://github.com/Sameesunkaria) +- [@cardoso](https://github.com/cardoso) - [@erhan-](https://github.com/erhan-) - [@gdelavald](https://github.com/gdelavald) - [@karlprieb](https://github.com/karlprieb) @@ -2068,7 +2498,6 @@ - [@Hudell](https://github.com/Hudell) - [@MarcosSpessatto](https://github.com/MarcosSpessatto) -- [@cardoso](https://github.com/cardoso) - [@engelgabriel](https://github.com/engelgabriel) - [@rafaelks](https://github.com/rafaelks) - [@rodrigok](https://github.com/rodrigok) @@ -2196,7 +2625,7 @@ - Release 0.64.0 ([#10613](https://github.com/RocketChat/Rocket.Chat/pull/10613) by [@christianh814](https://github.com/christianh814) & [@gdelavald](https://github.com/gdelavald) & [@tttt-conan](https://github.com/tttt-conan)) - Regression: Various search provider fixes ([#10591](https://github.com/RocketChat/Rocket.Chat/pull/10591) by [@tkurz](https://github.com/tkurz)) -- Regression: /api/v1/settings.oauth not sending needed info for SAML & CAS ([#10596](https://github.com/RocketChat/Rocket.Chat/pull/10596)) +- Regression: /api/v1/settings.oauth not sending needed info for SAML & CAS ([#10596](https://github.com/RocketChat/Rocket.Chat/pull/10596) by [@cardoso](https://github.com/cardoso)) - Regression: Apps and Livechats not getting along well with each other ([#10598](https://github.com/RocketChat/Rocket.Chat/pull/10598)) - Development: Add Visual Studio Code debugging configuration ([#10586](https://github.com/RocketChat/Rocket.Chat/pull/10586)) - Included missing lib for migrations ([#10532](https://github.com/RocketChat/Rocket.Chat/pull/10532)) @@ -2217,7 +2646,7 @@ - Regression: Revert announcement structure ([#10544](https://github.com/RocketChat/Rocket.Chat/pull/10544) by [@gdelavald](https://github.com/gdelavald)) - Regression: Upload was not working ([#10543](https://github.com/RocketChat/Rocket.Chat/pull/10543)) - Deps update ([#10549](https://github.com/RocketChat/Rocket.Chat/pull/10549)) -- Regression: /api/v1/settings.oauth not returning clientId for Twitter ([#10560](https://github.com/RocketChat/Rocket.Chat/pull/10560)) +- Regression: /api/v1/settings.oauth not returning clientId for Twitter ([#10560](https://github.com/RocketChat/Rocket.Chat/pull/10560) by [@cardoso](https://github.com/cardoso)) - Regression: Webhooks breaking due to restricted test ([#10555](https://github.com/RocketChat/Rocket.Chat/pull/10555)) - Regression: Rooms and Apps weren't playing nice with each other ([#10559](https://github.com/RocketChat/Rocket.Chat/pull/10559)) - Regression: Fix announcement bar being displayed without content ([#10554](https://github.com/RocketChat/Rocket.Chat/pull/10554) by [@gdelavald](https://github.com/gdelavald)) @@ -2235,6 +2664,7 @@ - [@abernix](https://github.com/abernix) - [@brendangadd](https://github.com/brendangadd) - [@c0dzilla](https://github.com/c0dzilla) +- [@cardoso](https://github.com/cardoso) - [@christianh814](https://github.com/christianh814) - [@dschuan](https://github.com/dschuan) - [@gdelavald](https://github.com/gdelavald) @@ -2254,7 +2684,6 @@ - [@Hudell](https://github.com/Hudell) - [@MarcosSpessatto](https://github.com/MarcosSpessatto) - [@TwizzyDizzy](https://github.com/TwizzyDizzy) -- [@cardoso](https://github.com/cardoso) - [@engelgabriel](https://github.com/engelgabriel) - [@geekgonecrazy](https://github.com/geekgonecrazy) - [@ggazzo](https://github.com/ggazzo) diff --git a/LIMITATION_OF_RESPONSIBILITY.md b/LIMITATION_OF_RESPONSIBILITY.md index f451e30c7128..531868d2c0d3 100644 --- a/LIMITATION_OF_RESPONSIBILITY.md +++ b/LIMITATION_OF_RESPONSIBILITY.md @@ -1,4 +1,4 @@ -## WARNING to ROCKET.CHAT USERS +## WARNING Rocket.Chat is open source software. Anyone in the world can download and run a Rocket.Chat server at any time. @@ -10,10 +10,14 @@ In particular: - Rocket.Chat Technologies Corp. do not and cannot control or regulate how these servers are operated. - Rocket.Chat Technologies Corp. cannot access, determine or regulate any contents or information flow on these servers. -## IMPORTANT +## PUBLIC SERVER For total transparency, Rocket.Chat Technologies Corp. owns and operates only ONE publicly available Rocket.Chat server in the world. The server that Rocket.Chat Technologies Corp. operates can only be accessed at: https://open.rocket.chat Any other Rocket.Chat server you access is not operated by Rocket.Chat Technologies Corp. and is subjected to the usage warning above. + +## ROCKET.CHAT CLOUD + +Rocket.Chat Technologies Corp. provides a cloud service for hosting Rocket.Chat instances. The data, messages and files on those instances are subject to our [Terms of Use](https://rocket.chat/terms). If you have evidence of misuse or a breach of our terms, contact us at [contact@rocket.chat](mailto:contact@rocket.chat) and include a description of the breach as well as the instance's URL. diff --git a/README.md b/README.md index 91f26b9ef607..a48aaf79ad04 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,6 @@ * [Snaps](#instant-server-installation-with-snaps) * [RocketChatLauncher](#rocketchatlauncher) * [Layershift](#layershift) - * [Sandstorm.io](#sandstormio) * [Yunohost.org](#yunohostorg) * [DPlatform](#dplatform) * [IndieHosters](#indiehosters) @@ -124,11 +123,6 @@ Instantly deploy your Rocket.Chat server for free on next generation auto-scalin Painless SSL. Automatically scale your server cluster based on usage demand. -## Sandstorm.io -Host your own Rocket.Chat server in four seconds flat. - -[![Rocket.Chat on Sandstorm.io](https://raw.githubusercontent.com/Sing-Li/bbug/master/images/sandstorm.jpg)](https://apps.sandstorm.io/app/vfnwptfn02ty21w715snyyczw0nqxkv3jvawcah10c6z7hj1hnu0) - ## Yunohost.org Host your own Rocket.Chat server in a few seconds. @@ -166,7 +160,7 @@ Host your own Rocket.Chat server for **FREE** with [One-Click Deploy](https://he [![Deploy](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy?template=https://github.com/RocketChat/Rocket.Chat/tree/master) ## Helm Kubernetes -Deploy on Kubernetes using the official [helm chart](https://github.com/kubernetes/charts/pull/752). +Deploy on Kubernetes using the official [helm chart](https://github.com/helm/charts/tree/master/stable/rocketchat). ## Scalingo Deploy your own Rocket.Chat server instantly on [Scalingo](https://scalingo.com). @@ -320,7 +314,6 @@ It is a great solution for communities and companies wanting to privately host t - Native Cross-Platform Desktop Application [Windows, macOS, or Linux](https://rocket.chat/) - Mobile app for iPhone, iPad, and iPod touch [Download on App Store](https://geo.itunes.apple.com/us/app/rocket-chat/id1148741252?mt=8) - Mobile app for Android phone, tablet, and TV stick [Available now on Google Play](https://play.google.com/store/apps/details?id=chat.rocket.android) -- Sandstorm.io instant Rocket.Chat server [Now on Sandstorm App Store](https://apps.sandstorm.io/app/vfnwptfn02ty21w715snyyczw0nqxkv3jvawcah10c6z7hj1hnu0) - Available on [Cloudron Store](https://cloudron.io/appstore.html#chat.rocket.cloudronapp) ## Roadmap @@ -471,9 +464,7 @@ Testing with [BrowserStack](https://www.browserstack.com) Rocket.Chat will be free forever, but you can help us speed up the development! -[![Donate](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=ZL94ZE6LGVUSN) - -[![Bitcoins](https://github.com/RocketChat/Rocket.Chat.Docs/blob/master/1.%20Contributing/Donating/coinbase.png?raw=true)](https://www.coinbase.com/checkouts/ac2fa967efca7f6fc1201d46bdccb875) +[![Donate](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=9MT88JJ9X4A6U&source=url) [BountySource](https://www.bountysource.com/teams/rocketchat) diff --git a/packages/rocketchat-2fa/README.md b/app/2fa/README.md similarity index 100% rename from packages/rocketchat-2fa/README.md rename to app/2fa/README.md diff --git a/packages/rocketchat-2fa/client/TOTPPassword.js b/app/2fa/client/TOTPPassword.js similarity index 94% rename from packages/rocketchat-2fa/client/TOTPPassword.js rename to app/2fa/client/TOTPPassword.js index 7bb15b1fa738..0cf6d8b9e9e4 100644 --- a/packages/rocketchat-2fa/client/TOTPPassword.js +++ b/app/2fa/client/TOTPPassword.js @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; -import { modal } from 'meteor/rocketchat:ui'; -import { t } from 'meteor/rocketchat:utils'; +import { modal } from '../../ui-utils'; +import { t } from '../../utils'; import toastr from 'toastr'; function reportError(error, callback) { diff --git a/packages/rocketchat-2fa/client/accountSecurity.html b/app/2fa/client/accountSecurity.html similarity index 100% rename from packages/rocketchat-2fa/client/accountSecurity.html rename to app/2fa/client/accountSecurity.html diff --git a/packages/rocketchat-2fa/client/accountSecurity.js b/app/2fa/client/accountSecurity.js similarity index 94% rename from packages/rocketchat-2fa/client/accountSecurity.js rename to app/2fa/client/accountSecurity.js index b99fb2fc3e49..4e9bc5573d1b 100644 --- a/packages/rocketchat-2fa/client/accountSecurity.js +++ b/app/2fa/client/accountSecurity.js @@ -1,9 +1,9 @@ import { Meteor } from 'meteor/meteor'; import { ReactiveVar } from 'meteor/reactive-var'; import { Template } from 'meteor/templating'; -import { modal } from 'meteor/rocketchat:ui'; -import { RocketChat } from 'meteor/rocketchat:lib'; -import { t } from 'meteor/rocketchat:utils'; +import { modal } from '../../ui-utils'; +import { settings } from '../../settings'; +import { t } from '../../utils'; import toastr from 'toastr'; import qrcode from 'yaqrcode'; @@ -27,7 +27,7 @@ Template.accountSecurity.helpers({ return Template.instance().state.get() === 'registering'; }, isAllowed() { - return RocketChat.settings.get('Accounts_TwoFactorAuthentication_Enabled'); + return settings.get('Accounts_TwoFactorAuthentication_Enabled'); }, codesRemaining() { if (Template.instance().codesRemaining.get()) { diff --git a/packages/rocketchat-2fa/client/index.js b/app/2fa/client/index.js similarity index 100% rename from packages/rocketchat-2fa/client/index.js rename to app/2fa/client/index.js diff --git a/app/2fa/server/index.js b/app/2fa/server/index.js new file mode 100644 index 000000000000..e5d5ab3fc445 --- /dev/null +++ b/app/2fa/server/index.js @@ -0,0 +1,7 @@ +import './startup/settings'; +import './methods/checkCodesRemaining'; +import './methods/disable'; +import './methods/enable'; +import './methods/regenerateCodes'; +import './methods/validateTempToken'; +import './loginHandler'; diff --git a/packages/rocketchat-2fa/server/lib/totp.js b/app/2fa/server/lib/totp.js similarity index 83% rename from packages/rocketchat-2fa/server/lib/totp.js rename to app/2fa/server/lib/totp.js index 8d794d9ec359..a1ad86147fe0 100644 --- a/packages/rocketchat-2fa/server/lib/totp.js +++ b/app/2fa/server/lib/totp.js @@ -1,9 +1,10 @@ import { SHA256 } from 'meteor/sha'; import { Random } from 'meteor/random'; -import { RocketChat } from 'meteor/rocketchat:lib'; +import { Users } from '../../../models'; +import { settings } from '../../../settings'; import speakeasy from 'speakeasy'; -RocketChat.TOTP = { +export const TOTP = { generateSecret() { return speakeasy.generateSecret(); }, @@ -25,14 +26,14 @@ RocketChat.TOTP = { backupTokens.splice(usedCode, 1); // mark the code as used (remove it from the list) - RocketChat.models.Users.update2FABackupCodesByUserId(userId, backupTokens); + Users.update2FABackupCodesByUserId(userId, backupTokens); return true; } return false; } - const maxDelta = RocketChat.settings.get('Accounts_TwoFactorAuthentication_MaxDelta'); + const maxDelta = settings.get('Accounts_TwoFactorAuthentication_MaxDelta'); if (maxDelta) { const verifiedDelta = speakeasy.totp.verifyDelta({ secret, diff --git a/app/2fa/server/loginHandler.js b/app/2fa/server/loginHandler.js new file mode 100644 index 000000000000..4067521d2c47 --- /dev/null +++ b/app/2fa/server/loginHandler.js @@ -0,0 +1,38 @@ +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; +import { settings } from '../../settings'; +import { callbacks } from '../../callbacks'; +import { TOTP } from './lib/totp'; + +Accounts.registerLoginHandler('totp', function(options) { + if (!options.totp || !options.totp.code) { + return; + } + + return Accounts._runLoginHandlers(this, options.totp.login); +}); + +callbacks.add('onValidateLogin', (login) => { + if (!settings.get('Accounts_TwoFactorAuthentication_Enabled')) { + return; + } + + if (login.type === 'password' && login.user.services && login.user.services.totp && login.user.services.totp.enabled === true) { + const { totp } = login.methodArguments[0]; + + if (!totp || !totp.code) { + throw new Meteor.Error('totp-required', 'TOTP Required'); + } + + const verified = TOTP.verify({ + secret: login.user.services.totp.secret, + token: totp.code, + userId: login.user._id, + backupTokens: login.user.services.totp.hashedBackup, + }); + + if (verified !== true) { + throw new Meteor.Error('totp-invalid', 'TOTP Invalid'); + } + } +}); diff --git a/packages/rocketchat-2fa/server/methods/checkCodesRemaining.js b/app/2fa/server/methods/checkCodesRemaining.js similarity index 100% rename from packages/rocketchat-2fa/server/methods/checkCodesRemaining.js rename to app/2fa/server/methods/checkCodesRemaining.js diff --git a/app/2fa/server/methods/disable.js b/app/2fa/server/methods/disable.js new file mode 100644 index 000000000000..096db7084cf6 --- /dev/null +++ b/app/2fa/server/methods/disable.js @@ -0,0 +1,26 @@ +import { Meteor } from 'meteor/meteor'; +import { Users } from '../../../models'; +import { TOTP } from '../lib/totp'; + +Meteor.methods({ + '2fa:disable'(code) { + if (!Meteor.userId()) { + throw new Meteor.Error('not-authorized'); + } + + const user = Meteor.user(); + + const verified = TOTP.verify({ + secret: user.services.totp.secret, + token: code, + userId: Meteor.userId(), + backupTokens: user.services.totp.hashedBackup, + }); + + if (!verified) { + return false; + } + + return Users.disable2FAByUserId(Meteor.userId()); + }, +}); diff --git a/app/2fa/server/methods/enable.js b/app/2fa/server/methods/enable.js new file mode 100644 index 000000000000..35954a5b3df6 --- /dev/null +++ b/app/2fa/server/methods/enable.js @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; +import { Users } from '../../../models'; +import { TOTP } from '../lib/totp'; + +Meteor.methods({ + '2fa:enable'() { + if (!Meteor.userId()) { + throw new Meteor.Error('not-authorized'); + } + + const user = Meteor.user(); + + const secret = TOTP.generateSecret(); + + Users.disable2FAAndSetTempSecretByUserId(Meteor.userId(), secret.base32); + + return { + secret: secret.base32, + url: TOTP.generateOtpauthURL(secret, user.username), + }; + }, +}); diff --git a/app/2fa/server/methods/regenerateCodes.js b/app/2fa/server/methods/regenerateCodes.js new file mode 100644 index 000000000000..cd723e66db81 --- /dev/null +++ b/app/2fa/server/methods/regenerateCodes.js @@ -0,0 +1,31 @@ +import { Meteor } from 'meteor/meteor'; +import { Users } from '../../../models'; +import { TOTP } from '../lib/totp'; + +Meteor.methods({ + '2fa:regenerateCodes'(userToken) { + if (!Meteor.userId()) { + throw new Meteor.Error('not-authorized'); + } + + const user = Meteor.user(); + + if (!user.services || !user.services.totp || !user.services.totp.enabled) { + throw new Meteor.Error('invalid-totp'); + } + + const verified = TOTP.verify({ + secret: user.services.totp.secret, + token: userToken, + userId: Meteor.userId(), + backupTokens: user.services.totp.hashedBackup, + }); + + if (verified) { + const { codes, hashedCodes } = TOTP.generateCodes(); + + Users.update2FABackupCodesByUserId(Meteor.userId(), hashedCodes); + return { codes }; + } + }, +}); diff --git a/app/2fa/server/methods/validateTempToken.js b/app/2fa/server/methods/validateTempToken.js new file mode 100644 index 000000000000..39ccc904365f --- /dev/null +++ b/app/2fa/server/methods/validateTempToken.js @@ -0,0 +1,29 @@ +import { Meteor } from 'meteor/meteor'; +import { Users } from '../../../models'; +import { TOTP } from '../lib/totp'; + +Meteor.methods({ + '2fa:validateTempToken'(userToken) { + if (!Meteor.userId()) { + throw new Meteor.Error('not-authorized'); + } + + const user = Meteor.user(); + + if (!user.services || !user.services.totp || !user.services.totp.tempSecret) { + throw new Meteor.Error('invalid-totp'); + } + + const verified = TOTP.verify({ + secret: user.services.totp.tempSecret, + token: userToken, + }); + + if (verified) { + const { codes, hashedCodes } = TOTP.generateCodes(); + + Users.enable2FAAndSetSecretAndCodesByUserId(Meteor.userId(), user.services.totp.tempSecret, hashedCodes); + return { codes }; + } + }, +}); diff --git a/app/2fa/server/startup/settings.js b/app/2fa/server/startup/settings.js new file mode 100644 index 000000000000..e83bcd8e4c99 --- /dev/null +++ b/app/2fa/server/startup/settings.js @@ -0,0 +1,21 @@ +import { settings } from '../../../settings'; + +settings.addGroup('Accounts', function() { + this.section('Two Factor Authentication', function() { + this.add('Accounts_TwoFactorAuthentication_Enabled', true, { + type: 'boolean', + public: true, + }); + this.add('Accounts_TwoFactorAuthentication_MaxDelta', 1, { + type: 'int', + public: true, + i18nLabel: 'Accounts_TwoFactorAuthentication_MaxDelta', + enableQuery: { + _id: 'Accounts_TwoFactorAuthentication_Enabled', + value: true, + }, + }); + }); +}); + + diff --git a/packages/rocketchat-accounts/README.md b/app/accounts/README.md similarity index 100% rename from packages/rocketchat-accounts/README.md rename to app/accounts/README.md diff --git a/app/accounts/index.js b/app/accounts/index.js new file mode 100644 index 000000000000..ca39cd0df4b1 --- /dev/null +++ b/app/accounts/index.js @@ -0,0 +1 @@ +export * from './server/index'; diff --git a/packages/rocketchat-accounts/server/config.js b/app/accounts/server/config.js similarity index 100% rename from packages/rocketchat-accounts/server/config.js rename to app/accounts/server/config.js diff --git a/packages/rocketchat-accounts/server/index.js b/app/accounts/server/index.js similarity index 100% rename from packages/rocketchat-accounts/server/index.js rename to app/accounts/server/index.js diff --git a/packages/rocketchat-action-links/README.md b/app/action-links/README.md similarity index 100% rename from packages/rocketchat-action-links/README.md rename to app/action-links/README.md diff --git a/app/action-links/both/lib/actionLinks.js b/app/action-links/both/lib/actionLinks.js new file mode 100644 index 000000000000..384cc450201d --- /dev/null +++ b/app/action-links/both/lib/actionLinks.js @@ -0,0 +1,35 @@ +import { Meteor } from 'meteor/meteor'; +import { Messages, Subscriptions } from '../../../models'; + +// Action Links namespace creation. +export const actionLinks = { + actions: {}, + register(name, funct) { + actionLinks.actions[name] = funct; + }, + getMessage(name, messageId) { + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { function: 'actionLinks.getMessage' }); + } + + const message = Messages.findOne({ _id: messageId }); + if (!message) { + throw new Meteor.Error('error-invalid-message', 'Invalid message', { function: 'actionLinks.getMessage' }); + } + + const subscription = Subscriptions.findOne({ + rid: message.rid, + 'u._id': userId, + }); + if (!subscription) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { function: 'actionLinks.getMessage' }); + } + + if (!message.actionLinks || !message.actionLinks[name]) { + throw new Meteor.Error('error-invalid-actionlink', 'Invalid action link', { function: 'actionLinks.getMessage' }); + } + + return message; + }, +}; diff --git a/app/action-links/client/index.js b/app/action-links/client/index.js new file mode 100644 index 000000000000..f49166a5c811 --- /dev/null +++ b/app/action-links/client/index.js @@ -0,0 +1,7 @@ +import { actionLinks } from '../both/lib/actionLinks'; +import './lib/actionLinks'; +import './init'; + +export { + actionLinks, +}; diff --git a/app/action-links/client/init.js b/app/action-links/client/init.js new file mode 100644 index 000000000000..4f85d99d9d53 --- /dev/null +++ b/app/action-links/client/init.js @@ -0,0 +1,33 @@ +import { Blaze } from 'meteor/blaze'; +import { Template } from 'meteor/templating'; +import { handleError } from '../../utils'; +import { fireGlobalEvent, Layout } from '../../ui-utils'; +import { messageArgs } from '../../ui-utils/client/lib/messageArgs'; +import { actionLinks } from '../both/lib/actionLinks'; + + + +Template.room.events({ + 'click .action-link'(event, instance) { + event.preventDefault(); + event.stopPropagation(); + + const data = Blaze.getData(event.currentTarget); + const { msg } = messageArgs(data); + if (Layout.isEmbedded()) { + return fireGlobalEvent('click-action-link', { + actionlink: $(event.currentTarget).data('actionlink'), + value: msg._id, + message: msg, + }); + } + + if (msg._id) { + actionLinks.run($(event.currentTarget).data('actionlink'), msg._id, instance, (err) => { + if (err) { + handleError(err); + } + }); + } + }, +}); diff --git a/app/action-links/client/lib/actionLinks.js b/app/action-links/client/lib/actionLinks.js new file mode 100644 index 000000000000..1c5b61dc4b91 --- /dev/null +++ b/app/action-links/client/lib/actionLinks.js @@ -0,0 +1,26 @@ +import { Meteor } from 'meteor/meteor'; +import { handleError } from '../../../utils'; +import { actionLinks } from '../../both/lib/actionLinks'; +// Action Links Handler. This method will be called off the client. + +actionLinks.run = (name, messageId, instance) => { + const message = actionLinks.getMessage(name, messageId); + + const actionLink = message.actionLinks[name]; + + let ranClient = false; + + if (actionLinks && actionLinks.actions && actionLinks.actions[actionLink.method_id]) { + // run just on client side + actionLinks.actions[actionLink.method_id](message, actionLink.params, instance); + + ranClient = true; + } + + // and run on server side + Meteor.call('actionLinkHandler', name, messageId, (err) => { + if (err && !ranClient) { + handleError(err); + } + }); +}; diff --git a/packages/rocketchat-action-links/client/stylesheets/actionLinks.css b/app/action-links/client/stylesheets/actionLinks.css similarity index 100% rename from packages/rocketchat-action-links/client/stylesheets/actionLinks.css rename to app/action-links/client/stylesheets/actionLinks.css diff --git a/app/action-links/index.js b/app/action-links/index.js new file mode 100644 index 000000000000..a67eca871efb --- /dev/null +++ b/app/action-links/index.js @@ -0,0 +1,8 @@ +import { Meteor } from 'meteor/meteor'; + +if (Meteor.isClient) { + module.exports = require('./client/index.js'); +} +if (Meteor.isServer) { + module.exports = require('./server/index.js'); +} diff --git a/app/action-links/server/actionLinkHandler.js b/app/action-links/server/actionLinkHandler.js new file mode 100644 index 000000000000..8940e1c8150d --- /dev/null +++ b/app/action-links/server/actionLinkHandler.js @@ -0,0 +1,17 @@ +import { Meteor } from 'meteor/meteor'; +import { actionLinks } from '../both/lib/actionLinks'; +// Action Links Handler. This method will be called off the client. + +Meteor.methods({ + actionLinkHandler(name, messageId) { + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'actionLinkHandler' }); + } + + const message = actionLinks.getMessage(name, messageId); + + const actionLink = message.actionLinks[name]; + + actionLinks.actions[actionLink.method_id](message, actionLink.params); + }, +}); diff --git a/app/action-links/server/index.js b/app/action-links/server/index.js new file mode 100644 index 000000000000..b1c484f79888 --- /dev/null +++ b/app/action-links/server/index.js @@ -0,0 +1,6 @@ +import { actionLinks } from '../both/lib/actionLinks'; +import './actionLinkHandler'; + +export { + actionLinks, +}; diff --git a/packages/rocketchat-analytics/README.md b/app/analytics/README.md similarity index 100% rename from packages/rocketchat-analytics/README.md rename to app/analytics/README.md diff --git a/packages/rocketchat-analytics/client/index.js b/app/analytics/client/index.js similarity index 100% rename from packages/rocketchat-analytics/client/index.js rename to app/analytics/client/index.js diff --git a/packages/rocketchat-analytics/client/loadScript.js b/app/analytics/client/loadScript.js similarity index 77% rename from packages/rocketchat-analytics/client/loadScript.js rename to app/analytics/client/loadScript.js index 19c7f95b8413..a07c8cb1ac96 100644 --- a/packages/rocketchat-analytics/client/loadScript.js +++ b/app/analytics/client/loadScript.js @@ -1,17 +1,17 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; import { Template } from 'meteor/templating'; -import { RocketChat } from 'meteor/rocketchat:lib'; +import { settings } from '../../settings'; Template.body.onRendered(() => { Tracker.autorun((c) => { - const piwikUrl = RocketChat.settings.get('PiwikAnalytics_enabled') && RocketChat.settings.get('PiwikAnalytics_url'); - const piwikSiteId = piwikUrl && RocketChat.settings.get('PiwikAnalytics_siteId'); - const piwikPrependDomain = piwikUrl && RocketChat.settings.get('PiwikAnalytics_prependDomain'); - const piwikCookieDomain = piwikUrl && RocketChat.settings.get('PiwikAnalytics_cookieDomain'); - const piwikDomains = piwikUrl && RocketChat.settings.get('PiwikAnalytics_domains'); - const piwikAdditionalTracker = piwikUrl && RocketChat.settings.get('PiwikAdditionalTrackers'); - const googleId = RocketChat.settings.get('GoogleAnalytics_enabled') && RocketChat.settings.get('GoogleAnalytics_ID'); + const piwikUrl = settings.get('PiwikAnalytics_enabled') && settings.get('PiwikAnalytics_url'); + const piwikSiteId = piwikUrl && settings.get('PiwikAnalytics_siteId'); + const piwikPrependDomain = piwikUrl && settings.get('PiwikAnalytics_prependDomain'); + const piwikCookieDomain = piwikUrl && settings.get('PiwikAnalytics_cookieDomain'); + const piwikDomains = piwikUrl && settings.get('PiwikAnalytics_domains'); + const piwikAdditionalTracker = piwikUrl && settings.get('PiwikAdditionalTrackers'); + const googleId = settings.get('GoogleAnalytics_enabled') && settings.get('GoogleAnalytics_ID'); if (piwikSiteId || googleId) { c.stop(); diff --git a/app/analytics/client/trackEvents.js b/app/analytics/client/trackEvents.js new file mode 100644 index 000000000000..acbbd13494fd --- /dev/null +++ b/app/analytics/client/trackEvents.js @@ -0,0 +1,150 @@ +import { Meteor } from 'meteor/meteor'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { settings } from '../../settings'; +import { callbacks } from '../../callbacks'; +import { ChatRoom } from '../../models'; +import { Tracker } from 'meteor/tracker'; + +function trackEvent(category, action, label) { + if (window._paq) { + window._paq.push(['trackEvent', category, action, label]); + } + if (window.ga) { + window.ga('send', 'event', category, action, label); + } +} + +if (!window._paq || window.ga) { + // Trigger the trackPageView manually as the page views are only loaded when the loadScript.js code is executed + FlowRouter.triggers.enter([(route) => { + if (window._paq) { + const http = location.protocol; + const slashes = http.concat('//'); + const host = slashes.concat(window.location.hostname); + window._paq.push(['setCustomUrl', host + route.path]); + window._paq.push(['trackPageView']); + } + if (window.ga) { + window.ga('send', 'pageview', route.path); + } + }]); + + // Login page has manual switches + callbacks.add('loginPageStateChange', (state) => { + trackEvent('Navigation', 'Login Page State Change', state); + }, callbacks.priority.MEDIUM, 'analytics-login-state-change'); + + // Messsages + callbacks.add('afterSaveMessage', (message) => { + if ((window._paq || window.ga) && settings.get('Analytics_features_messages')) { + const room = ChatRoom.findOne({ _id: message.rid }); + trackEvent('Message', 'Send', `${ room.name } (${ room._id })`); + } + }, 2000, 'trackEvents'); + + // Rooms + callbacks.add('afterCreateChannel', (owner, room) => { + if (settings.get('Analytics_features_rooms')) { + trackEvent('Room', 'Create', `${ room.name } (${ room._id })`); + } + }, callbacks.priority.MEDIUM, 'analytics-after-create-channel'); + + callbacks.add('roomNameChanged', (room) => { + if (settings.get('Analytics_features_rooms')) { + trackEvent('Room', 'Changed Name', `${ room.name } (${ room._id })`); + } + }, callbacks.priority.MEDIUM, 'analytics-room-name-changed'); + + callbacks.add('roomTopicChanged', (room) => { + if (settings.get('Analytics_features_rooms')) { + trackEvent('Room', 'Changed Topic', `${ room.name } (${ room._id })`); + } + }, callbacks.priority.MEDIUM, 'analytics-room-topic-changed'); + + callbacks.add('roomAnnouncementChanged', (room) => { + if (settings.get('Analytics_features_rooms')) { + trackEvent('Room', 'Changed Announcement', `${ room.name } (${ room._id })`); + } + }, callbacks.priority.MEDIUM, 'analytics-room-announcement-changed'); + + callbacks.add('roomTypeChanged', (room) => { + if (settings.get('Analytics_features_rooms')) { + trackEvent('Room', 'Changed Room Type', `${ room.name } (${ room._id })`); + } + }, callbacks.priority.MEDIUM, 'analytics-room-type-changed'); + + callbacks.add('archiveRoom', (room) => { + if (settings.get('Analytics_features_rooms')) { + trackEvent('Room', 'Archived', `${ room.name } (${ room._id })`); + } + }, callbacks.priority.MEDIUM, 'analytics-archive-room'); + + callbacks.add('unarchiveRoom', (room) => { + if (settings.get('Analytics_features_rooms')) { + trackEvent('Room', 'Unarchived', `${ room.name } (${ room._id })`); + } + }, callbacks.priority.MEDIUM, 'analytics-unarchive-room'); + + // Users + // Track logins and associate user ids with piwik + (() => { + let oldUserId = null; + + Tracker.autorun(() => { + const newUserId = Meteor.userId(); + if (oldUserId === null && newUserId) { + if (window._paq && settings.get('Analytics_features_users')) { + trackEvent('User', 'Login', newUserId); + window._paq.push(['setUserId', newUserId]); + } + } else if (newUserId === null && oldUserId) { + if (window._paq && settings.get('Analytics_features_users')) { + trackEvent('User', 'Logout', oldUserId); + } + } + oldUserId = Meteor.userId(); + }); + })(); + + callbacks.add('userRegistered', () => { + if (settings.get('Analytics_features_users')) { + trackEvent('User', 'Registered'); + } + }, callbacks.priority.MEDIUM, 'piwik-user-resitered'); + + callbacks.add('usernameSet', () => { + if (settings.get('Analytics_features_users')) { + trackEvent('User', 'Username Set'); + } + }, callbacks.priority.MEDIUM, 'piweik-username-set'); + + callbacks.add('userPasswordReset', () => { + if (settings.get('Analytics_features_users')) { + trackEvent('User', 'Reset Password'); + } + }, callbacks.priority.MEDIUM, 'piwik-user-password-reset'); + + callbacks.add('userConfirmationEmailRequested', () => { + if (settings.get('Analytics_features_users')) { + trackEvent('User', 'Confirmation Email Requested'); + } + }, callbacks.priority.MEDIUM, 'piwik-user-confirmation-email-requested'); + + callbacks.add('userForgotPasswordEmailRequested', () => { + if (settings.get('Analytics_features_users')) { + trackEvent('User', 'Forgot Password Email Requested'); + } + }, callbacks.priority.MEDIUM, 'piwik-user-forgot-password-email-requested'); + + callbacks.add('userStatusManuallySet', (status) => { + if (settings.get('Analytics_features_users')) { + trackEvent('User', 'Status Manually Changed', status); + } + }, callbacks.priority.MEDIUM, 'analytics-user-status-manually-set'); + + callbacks.add('userAvatarSet', (service) => { + if (settings.get('Analytics_features_users')) { + trackEvent('User', 'Avatar Changed', service); + } + }, callbacks.priority.MEDIUM, 'analytics-user-avatar-set'); +} diff --git a/packages/rocketchat-analytics/server/index.js b/app/analytics/server/index.js similarity index 100% rename from packages/rocketchat-analytics/server/index.js rename to app/analytics/server/index.js diff --git a/app/analytics/server/settings.js b/app/analytics/server/settings.js new file mode 100644 index 000000000000..932e834cdb6e --- /dev/null +++ b/app/analytics/server/settings.js @@ -0,0 +1,87 @@ +import { settings } from '../../settings'; + +settings.addGroup('Analytics', function addSettings() { + this.section('Piwik', function() { + const enableQuery = { _id: 'PiwikAnalytics_enabled', value: true }; + this.add('PiwikAnalytics_enabled', false, { + type: 'boolean', + public: true, + i18nLabel: 'Enable', + }); + this.add('PiwikAnalytics_url', '', { + type: 'string', + public: true, + i18nLabel: 'URL', + enableQuery, + }); + this.add('PiwikAnalytics_siteId', '', { + type: 'string', + public: true, + i18nLabel: 'Client_ID', + enableQuery, + }); + this.add('PiwikAdditionalTrackers', '', { + type: 'string', + multiline: true, + public: true, + i18nLabel: 'PiwikAdditionalTrackers', + enableQuery, + }); + this.add('PiwikAnalytics_prependDomain', false, { + type: 'boolean', + public: true, + i18nLabel: 'PiwikAnalytics_prependDomain', + enableQuery, + }); + this.add('PiwikAnalytics_cookieDomain', false, { + type: 'boolean', + public: true, + i18nLabel: 'PiwikAnalytics_cookieDomain', + enableQuery, + }); + this.add('PiwikAnalytics_domains', '', { + type: 'string', + multiline: true, + public: true, + i18nLabel: 'PiwikAnalytics_domains', + enableQuery, + }); + }); + + this.section('Analytics_Google', function() { + const enableQuery = { _id: 'GoogleAnalytics_enabled', value: true }; + this.add('GoogleAnalytics_enabled', false, { + type: 'boolean', + public: true, + i18nLabel: 'Enable', + }); + + this.add('GoogleAnalytics_ID', '', { + type: 'string', + public: true, + i18nLabel: 'Analytics_Google_id', + enableQuery, + }); + }); + + this.section('Analytics_features_enabled', function addFeaturesEnabledSettings() { + this.add('Analytics_features_messages', true, { + type: 'boolean', + public: true, + i18nLabel: 'Messages', + i18nDescription: 'Analytics_features_messages_Description', + }); + this.add('Analytics_features_rooms', true, { + type: 'boolean', + public: true, + i18nLabel: 'Rooms', + i18nDescription: 'Analytics_features_rooms_Description', + }); + this.add('Analytics_features_users', true, { + type: 'boolean', + public: true, + i18nLabel: 'Users', + i18nDescription: 'Analytics_features_users_Description', + }); + }); +}); diff --git a/app/api/index.js b/app/api/index.js new file mode 100644 index 000000000000..ca39cd0df4b1 --- /dev/null +++ b/app/api/index.js @@ -0,0 +1 @@ +export * from './server/index'; diff --git a/app/api/server/api.js b/app/api/server/api.js new file mode 100644 index 000000000000..2d5a544e4265 --- /dev/null +++ b/app/api/server/api.js @@ -0,0 +1,557 @@ +import { Meteor } from 'meteor/meteor'; +import { DDPCommon } from 'meteor/ddp-common'; +import { DDP } from 'meteor/ddp'; +import { Accounts } from 'meteor/accounts-base'; +import { Restivus } from 'meteor/nimble:restivus'; +import { Logger } from '../../logger'; +import { settings } from '../../settings'; +import { metrics } from '../../metrics'; +import { hasPermission, hasAllPermission } from '../../authorization'; +import { RateLimiter } from 'meteor/rate-limit'; + +import _ from 'underscore'; + +const logger = new Logger('API', {}); +const rateLimiterDictionary = {}; +const defaultRateLimiterOptions = { + numRequestsAllowed: settings.get('API_Enable_Rate_Limiter_Limit_Calls_Default'), + intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'), +}; + +export let API = {}; + +class APIClass extends Restivus { + constructor(properties) { + super(properties); + this.authMethods = []; + this.fieldSeparator = '.'; + this.defaultFieldsToExclude = { + joinCode: 0, + members: 0, + importIds: 0, + e2e: 0, + }; + this.limitedUserFieldsToExclude = { + avatarOrigin: 0, + emails: 0, + phone: 0, + statusConnection: 0, + createdAt: 0, + lastLogin: 0, + services: 0, + requirePasswordChange: 0, + requirePasswordChangeReason: 0, + roles: 0, + statusDefault: 0, + _updatedAt: 0, + customFields: 0, + settings: 0, + }; + this.limitedUserFieldsToExcludeIfIsPrivilegedUser = { + services: 0, + }; + } + + hasHelperMethods() { + return API.helperMethods.size !== 0; + } + + getHelperMethods() { + return API.helperMethods; + } + + getHelperMethod(name) { + return API.helperMethods.get(name); + } + + addAuthMethod(method) { + this.authMethods.push(method); + } + + success(result = {}) { + if (_.isObject(result)) { + result.success = true; + } + + result = { + statusCode: 200, + body: result, + }; + + logger.debug('Success', result); + + return result; + } + + failure(result, errorType, stack) { + if (_.isObject(result)) { + result.success = false; + } else { + result = { + success: false, + error: result, + stack, + }; + + if (errorType) { + result.errorType = errorType; + } + } + + result = { + statusCode: 400, + body: result, + }; + + logger.debug('Failure', result); + + return result; + } + + notFound(msg) { + return { + statusCode: 404, + body: { + success: false, + error: msg ? msg : 'Resource not found', + }, + }; + } + + unauthorized(msg) { + return { + statusCode: 403, + body: { + success: false, + error: msg ? msg : 'unauthorized', + }, + }; + } + + tooManyRequests(msg) { + return { + statusCode: 429, + body: { + success: false, + error: msg ? msg : 'Too many requests', + }, + }; + } + + reloadRoutesToRefreshRateLimiter() { + const { version } = this._config; + this._routes.forEach((route) => { + const shouldAddRateLimitToRoute = ((typeof route.options.rateLimiterOptions === 'object' || route.options.rateLimiterOptions === undefined) && Boolean(version) && !process.env.TEST_MODE && Boolean(defaultRateLimiterOptions.numRequestsAllowed && defaultRateLimiterOptions.intervalTimeInMS)); + if (shouldAddRateLimitToRoute) { + this.addRateLimiterRuleForRoutes({ + routes: [route.path], + rateLimiterOptions: route.options.rateLimiterOptions || defaultRateLimiterOptions, + endpoints: Object.keys(route.endpoints).filter((endpoint) => endpoint !== 'options'), + apiVersion: version, + }); + } + }); + } + + addRateLimiterRuleForRoutes({ routes, rateLimiterOptions, endpoints, apiVersion }) { + if (!rateLimiterOptions.numRequestsAllowed) { + throw new Meteor.Error('You must set "numRequestsAllowed" property in rateLimiter for REST API endpoint'); + } + if (!rateLimiterOptions.intervalTimeInMS) { + throw new Meteor.Error('You must set "intervalTimeInMS" property in rateLimiter for REST API endpoint'); + } + const nameRoute = (route) => { + const routeActions = Array.isArray(endpoints) ? endpoints : Object.keys(endpoints); + return routeActions.map((endpoint) => `/api/${ apiVersion }/${ route }${ endpoint }`); + }; + const addRateLimitRuleToEveryRoute = (routes) => { + routes.forEach((route) => { + rateLimiterDictionary[route] = { + rateLimiter: new RateLimiter(), + options: rateLimiterOptions, + }; + const rateLimitRule = { + IPAddr: (input) => input, + route, + }; + rateLimiterDictionary[route].rateLimiter.addRule(rateLimitRule, rateLimiterOptions.numRequestsAllowed, rateLimiterOptions.intervalTimeInMS); + }); + }; + routes + .map(nameRoute) + .map(addRateLimitRuleToEveryRoute); + } + + addRoute(routes, options, endpoints) { + // Note: required if the developer didn't provide options + if (typeof endpoints === 'undefined') { + endpoints = options; + options = {}; + } + + let shouldVerifyPermissions; + + if (!_.isArray(options.permissionsRequired)) { + options.permissionsRequired = undefined; + shouldVerifyPermissions = false; + } else { + shouldVerifyPermissions = !!options.permissionsRequired.length; + } + + + // Allow for more than one route using the same option and endpoints + if (!_.isArray(routes)) { + routes = [routes]; + } + const { version } = this._config; + const shouldAddRateLimitToRoute = ((typeof options.rateLimiterOptions === 'object' || options.rateLimiterOptions === undefined) && Boolean(version) && !process.env.TEST_MODE && Boolean(defaultRateLimiterOptions.numRequestsAllowed && defaultRateLimiterOptions.intervalTimeInMS)); + if (shouldAddRateLimitToRoute) { + this.addRateLimiterRuleForRoutes({ + routes, + rateLimiterOptions: options.rateLimiterOptions || defaultRateLimiterOptions, + endpoints, + apiVersion: version, + }); + } + routes.forEach((route) => { + // Note: This is required due to Restivus calling `addRoute` in the constructor of itself + Object.keys(endpoints).forEach((method) => { + if (typeof endpoints[method] === 'function') { + endpoints[method] = { action: endpoints[method] }; + } + // Add a try/catch for each endpoint + const originalAction = endpoints[method].action; + endpoints[method].action = function _internalRouteActionHandler() { + const rocketchatRestApiEnd = metrics.rocketchatRestApi.startTimer({ + method, + version, + user_agent: this.request.headers['user-agent'], + entrypoint: route, + }); + + logger.debug(`${ this.request.method.toUpperCase() }: ${ this.request.url }`); + const requestIp = this.request.headers['x-forwarded-for'] || this.request.connection.remoteAddress || this.request.socket.remoteAddress || this.request.connection.socket.remoteAddress; + const objectForRateLimitMatch = { + IPAddr: requestIp, + route: `${ this.request.route }${ this.request.method.toLowerCase() }`, + }; + let result; + try { + const shouldVerifyRateLimit = rateLimiterDictionary.hasOwnProperty(objectForRateLimitMatch.route) + && (!this.userId || !hasPermission(this.userId, 'api-bypass-rate-limit')) + && ((process.env.NODE_ENV === 'development' && settings.get('API_Enable_Rate_Limiter_Dev') === true) || process.env.NODE_ENV !== 'development'); + if (shouldVerifyRateLimit) { + rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.increment(objectForRateLimitMatch); + const attemptResult = rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.check(objectForRateLimitMatch); + const timeToResetAttempsInSeconds = Math.ceil(attemptResult.timeToReset / 1000); + this.response.setHeader('X-RateLimit-Limit', rateLimiterDictionary[objectForRateLimitMatch.route].options.numRequestsAllowed); + this.response.setHeader('X-RateLimit-Remaining', attemptResult.numInvocationsLeft); + this.response.setHeader('X-RateLimit-Reset', new Date().getTime() + attemptResult.timeToReset); + if (!attemptResult.allowed) { + throw new Meteor.Error('error-too-many-requests', `Error, too many requests. Please slow down. You must wait ${ timeToResetAttempsInSeconds } seconds before trying this endpoint again.`, { + timeToReset: attemptResult.timeToReset, + seconds: timeToResetAttempsInSeconds, + }); + } + } + + if (shouldVerifyPermissions && (!this.userId || !hasAllPermission(this.userId, options.permissionsRequired))) { + throw new Meteor.Error('error-unauthorized', 'User does not have the permissions required for this action', { + permissions: options.permissionsRequired, + }); + } + + result = originalAction.apply(this); + } catch (e) { + logger.debug(`${ method } ${ route } threw an error:`, e.stack); + + const apiMethod = { + 'error-too-many-requests': 'tooManyRequests', + 'error-unauthorized': 'unauthorized', + }[e.error] || 'failure'; + + result = API.v1[apiMethod](e.message, e.error); + } + + result = result || API.v1.success(); + + rocketchatRestApiEnd({ + status: result.statusCode, + }); + + return result; + }; + + if (this.hasHelperMethods()) { + for (const [name, helperMethod] of this.getHelperMethods()) { + endpoints[method][name] = helperMethod; + } + } + + // Allow the endpoints to make usage of the logger which respects the user's settings + endpoints[method].logger = logger; + }); + + super.addRoute(route, options, endpoints); + }); + } + + _initAuth() { + const loginCompatibility = (bodyParams) => { + // Grab the username or email that the user is logging in with + const { user, username, email, password, code } = bodyParams; + + if (password == null) { + return bodyParams; + } + + if (_.without(Object.keys(bodyParams), 'user', 'username', 'email', 'password', 'code').length > 0) { + return bodyParams; + } + + const auth = { + password, + }; + + if (typeof user === 'string') { + auth.user = user.includes('@') ? { email: user } : { username: user }; + } else if (username) { + auth.user = { username }; + } else if (email) { + auth.user = { email }; + } + + if (auth.user == null) { + return bodyParams; + } + + if (auth.password.hashed) { + auth.password = { + digest: auth.password, + algorithm: 'sha-256', + }; + } + + if (code) { + return { + totp: { + code, + login: auth, + }, + }; + } + + return auth; + }; + + const self = this; + + this.addRoute('login', { authRequired: false }, { + post() { + const args = loginCompatibility(this.bodyParams); + const getUserInfo = self.getHelperMethod('getUserInfo'); + + const invocation = new DDPCommon.MethodInvocation({ + connection: { + close() {}, + }, + }); + + let auth; + try { + auth = DDP._CurrentInvocation.withValue(invocation, () => Meteor.call('login', args)); + } catch (error) { + let e = error; + if (error.reason === 'User not found') { + e = { + error: 'Unauthorized', + reason: 'Unauthorized', + }; + } + + return { + statusCode: 401, + body: { + status: 'error', + error: e.error, + message: e.reason || e.message, + }, + }; + } + + this.user = Meteor.users.findOne({ + _id: auth.id, + }); + + this.userId = this.user._id; + + const response = { + status: 'success', + data: { + userId: this.userId, + authToken: auth.token, + me: getUserInfo(this.user), + }, + }; + + const extraData = self._config.onLoggedIn && self._config.onLoggedIn.call(this); + + if (extraData != null) { + _.extend(response.data, { + extra: extraData, + }); + } + + return response; + }, + }); + + const logout = function() { + // Remove the given auth token from the user's account + const authToken = this.request.headers['x-auth-token']; + const hashedToken = Accounts._hashLoginToken(authToken); + const tokenLocation = self._config.auth.token; + const index = tokenLocation.lastIndexOf('.'); + const tokenPath = tokenLocation.substring(0, index); + const tokenFieldName = tokenLocation.substring(index + 1); + const tokenToRemove = {}; + tokenToRemove[tokenFieldName] = hashedToken; + const tokenRemovalQuery = {}; + tokenRemovalQuery[tokenPath] = tokenToRemove; + + Meteor.users.update(this.user._id, { + $pull: tokenRemovalQuery, + }); + + const response = { + status: 'success', + data: { + message: 'You\'ve been logged out!', + }, + }; + + // Call the logout hook with the authenticated user attached + const extraData = self._config.onLoggedOut && self._config.onLoggedOut.call(this); + if (extraData != null) { + _.extend(response.data, { + extra: extraData, + }); + } + return response; + }; + + /* + Add a logout endpoint to the API + After the user is logged out, the onLoggedOut hook is called (see Restfully.configure() for + adding hook). + */ + return this.addRoute('logout', { + authRequired: true, + }, { + get() { + console.warn('Warning: Default logout via GET will be removed in Restivus v1.0. Use POST instead.'); + console.warn(' See https://github.com/kahmali/meteor-restivus/issues/100'); + return logout.call(this); + }, + post: logout, + }); + } +} + +const getUserAuth = function _getUserAuth(...args) { + const invalidResults = [undefined, null, false]; + return { + token: 'services.resume.loginTokens.hashedToken', + user() { + if (this.bodyParams && this.bodyParams.payload) { + this.bodyParams = JSON.parse(this.bodyParams.payload); + } + + for (let i = 0; i < API.v1.authMethods.length; i++) { + const method = API.v1.authMethods[i]; + + if (typeof method === 'function') { + const result = method.apply(this, args); + if (!invalidResults.includes(result)) { + return result; + } + } + } + + let token; + if (this.request.headers['x-auth-token']) { + token = Accounts._hashLoginToken(this.request.headers['x-auth-token']); + } + + return { + userId: this.request.headers['x-user-id'], + token, + }; + }, + }; +}; + +API = { + helperMethods: new Map(), + getUserAuth, + ApiClass: APIClass, +}; + +const defaultOptionsEndpoint = function _defaultOptionsEndpoint() { + if (this.request.method === 'OPTIONS' && this.request.headers['access-control-request-method']) { + if (settings.get('API_Enable_CORS') === true) { + this.response.writeHead(200, { + 'Access-Control-Allow-Origin': settings.get('API_CORS_Origin'), + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, HEAD, PATCH', + 'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token', + }); + } else { + this.response.writeHead(405); + this.response.write('CORS not enabled. Go to "Admin > General > REST Api" to enable it.'); + } + } else { + this.response.writeHead(404); + } + this.done(); +}; + +const createApi = function _createApi(enableCors) { + if (!API.v1 || API.v1._config.enableCors !== enableCors) { + API.v1 = new APIClass({ + version: 'v1', + useDefaultAuth: true, + prettyJson: process.env.NODE_ENV === 'development', + enableCors, + defaultOptionsEndpoint, + auth: getUserAuth(), + }); + } + + if (!API.default || API.default._config.enableCors !== enableCors) { + API.default = new APIClass({ + useDefaultAuth: true, + prettyJson: process.env.NODE_ENV === 'development', + enableCors, + defaultOptionsEndpoint, + auth: getUserAuth(), + }); + } +}; + +// also create the API immediately +createApi(!!settings.get('API_Enable_CORS')); + +// register the API to be re-created once the CORS-setting changes. +settings.get('API_Enable_CORS', (key, value) => { + createApi(value); +}); + +settings.get('API_Enable_Rate_Limiter_Limit_Time_Default', (key, value) => { + defaultRateLimiterOptions.intervalTimeInMS = value; + API.v1.reloadRoutesToRefreshRateLimiter(); +}); + +settings.get('API_Enable_Rate_Limiter_Limit_Calls_Default', (key, value) => { + defaultRateLimiterOptions.numRequestsAllowed = value; + API.v1.reloadRoutesToRefreshRateLimiter(); +}); diff --git a/app/api/server/default/info.js b/app/api/server/default/info.js new file mode 100644 index 000000000000..7c397de09cb1 --- /dev/null +++ b/app/api/server/default/info.js @@ -0,0 +1,19 @@ +import { hasRole } from '../../../authorization'; +import { Info } from '../../../utils'; +import { API } from '../api'; + +API.default.addRoute('info', { authRequired: false }, { + get() { + const user = this.getLoggedInUser(); + + if (user && hasRole(user._id, 'admin')) { + return API.v1.success({ + info: Info, + }); + } + + return API.v1.success({ + version: Info.version, + }); + }, +}); diff --git a/packages/rocketchat-api/server/helpers/README.md b/app/api/server/helpers/README.md similarity index 100% rename from packages/rocketchat-api/server/helpers/README.md rename to app/api/server/helpers/README.md diff --git a/app/api/server/helpers/composeRoomWithLastMessage.js b/app/api/server/helpers/composeRoomWithLastMessage.js new file mode 100644 index 000000000000..529a493d86fd --- /dev/null +++ b/app/api/server/helpers/composeRoomWithLastMessage.js @@ -0,0 +1,9 @@ +import { composeMessageObjectWithUser } from '../../../utils'; +import { API } from '../api'; + +API.helperMethods.set('composeRoomWithLastMessage', function _composeRoomWithLastMessage(room, userId) { + if (room.lastMessage) { + room.lastMessage = composeMessageObjectWithUser(room.lastMessage, userId); + } + return room; +}); diff --git a/app/api/server/helpers/deprecationWarning.js b/app/api/server/helpers/deprecationWarning.js new file mode 100644 index 000000000000..52590c041f02 --- /dev/null +++ b/app/api/server/helpers/deprecationWarning.js @@ -0,0 +1,15 @@ +import { API } from '../api'; + +API.helperMethods.set('deprecationWarning', function _deprecationWarning({ endpoint, versionWillBeRemoved, response }) { + const warningMessage = `The endpoint "${ endpoint }" is deprecated and will be removed after version ${ versionWillBeRemoved }`; + console.warn(warningMessage); + if (process.env.NODE_ENV === 'development') { + return { + warning: warningMessage, + ...response, + }; + } + + return response; +}); + diff --git a/app/api/server/helpers/getLoggedInUser.js b/app/api/server/helpers/getLoggedInUser.js new file mode 100644 index 000000000000..2acd73c71e8f --- /dev/null +++ b/app/api/server/helpers/getLoggedInUser.js @@ -0,0 +1,16 @@ +import { Accounts } from 'meteor/accounts-base'; +import { Users } from '../../../models'; +import { API } from '../api'; + +API.helperMethods.set('getLoggedInUser', function _getLoggedInUser() { + let user; + + if (this.request.headers['x-auth-token'] && this.request.headers['x-user-id']) { + user = Users.findOne({ + _id: this.request.headers['x-user-id'], + 'services.resume.loginTokens.hashedToken': Accounts._hashLoginToken(this.request.headers['x-auth-token']), + }); + } + + return user; +}); diff --git a/app/api/server/helpers/getPaginationItems.js b/app/api/server/helpers/getPaginationItems.js new file mode 100644 index 000000000000..93a19b2cbf9f --- /dev/null +++ b/app/api/server/helpers/getPaginationItems.js @@ -0,0 +1,32 @@ +// If the count query param is higher than the "API_Upper_Count_Limit" setting, then we limit that +// If the count query param isn't defined, then we set it to the "API_Default_Count" setting +// If the count is zero, then that means unlimited and is only allowed if the setting "API_Allow_Infinite_Count" is true +import { settings } from '../../../settings'; +import { API } from '../api'; + +API.helperMethods.set('getPaginationItems', function _getPaginationItems() { + const hardUpperLimit = settings.get('API_Upper_Count_Limit') <= 0 ? 100 : settings.get('API_Upper_Count_Limit'); + const defaultCount = settings.get('API_Default_Count') <= 0 ? 50 : settings.get('API_Default_Count'); + const offset = this.queryParams.offset ? parseInt(this.queryParams.offset) : 0; + let count = defaultCount; + + // Ensure count is an appropiate amount + if (typeof this.queryParams.count !== 'undefined') { + count = parseInt(this.queryParams.count); + } else { + count = defaultCount; + } + + if (count > hardUpperLimit) { + count = hardUpperLimit; + } + + if (count === 0 && !settings.get('API_Allow_Infinite_Count')) { + count = defaultCount; + } + + return { + offset, + count, + }; +}); diff --git a/app/api/server/helpers/getUserFromParams.js b/app/api/server/helpers/getUserFromParams.js new file mode 100644 index 000000000000..6265f074e615 --- /dev/null +++ b/app/api/server/helpers/getUserFromParams.js @@ -0,0 +1,26 @@ +// Convenience method, almost need to turn it into a middleware of sorts +import { Meteor } from 'meteor/meteor'; +import { Users } from '../../../models'; +import { API } from '../api'; + +API.helperMethods.set('getUserFromParams', function _getUserFromParams() { + const doesntExist = { _doesntExist: true }; + let user; + const params = this.requestParams(); + + if (params.userId && params.userId.trim()) { + user = Users.findOneById(params.userId) || doesntExist; + } else if (params.username && params.username.trim()) { + user = Users.findOneByUsername(params.username) || doesntExist; + } else if (params.user && params.user.trim()) { + user = Users.findOneByUsername(params.user) || doesntExist; + } else { + throw new Meteor.Error('error-user-param-not-provided', 'The required "userId" or "username" param was not provided'); + } + + if (user._doesntExist) { + throw new Meteor.Error('error-invalid-user', 'The required "userId" or "username" param provided does not match any users'); + } + + return user; +}); diff --git a/app/api/server/helpers/getUserInfo.js b/app/api/server/helpers/getUserInfo.js new file mode 100644 index 000000000000..f7098beff333 --- /dev/null +++ b/app/api/server/helpers/getUserInfo.js @@ -0,0 +1,65 @@ +import { settings } from '../../../settings'; +import { getUserPreference, getURL } from '../../../utils'; +import { API } from '../api'; + +const getInfoFromUserObject = (user) => { + const { + _id, + name, + emails, + status, + statusConnection, + username, + utcOffset, + active, + language, + roles, + settings, + customFields, + } = user; + return { + _id, + name, + emails, + status, + statusConnection, + username, + utcOffset, + active, + language, + roles, + settings, + customFields, + }; +}; + + +API.helperMethods.set('getUserInfo', function _getUserInfo(user) { + const me = getInfoFromUserObject(user); + const isVerifiedEmail = () => { + if (me && me.emails && Array.isArray(me.emails)) { + return me.emails.find((email) => email.verified); + } + return false; + }; + const getUserPreferences = () => { + const defaultUserSettingPrefix = 'Accounts_Default_User_Preferences_'; + const allDefaultUserSettings = settings.get(new RegExp(`^${ defaultUserSettingPrefix }.*$`)); + + return allDefaultUserSettings.reduce((accumulator, setting) => { + const settingWithoutPrefix = setting.key.replace(defaultUserSettingPrefix, ' ').trim(); + accumulator[settingWithoutPrefix] = getUserPreference(user, settingWithoutPrefix); + return accumulator; + }, {}); + }; + const verifiedEmail = isVerifiedEmail(); + me.email = verifiedEmail ? verifiedEmail.address : undefined; + + me.avatarUrl = getURL(`/avatar/${ me.username }`, { cdn: false, full: true }); + + me.settings = { + preferences: getUserPreferences(), + }; + + return me; +}); diff --git a/app/api/server/helpers/insertUserObject.js b/app/api/server/helpers/insertUserObject.js new file mode 100644 index 000000000000..1e52665d1142 --- /dev/null +++ b/app/api/server/helpers/insertUserObject.js @@ -0,0 +1,18 @@ +import { Users } from '../../../models'; +import { API } from '../api'; + +API.helperMethods.set('insertUserObject', function _addUserToObject({ object, userId }) { + const user = Users.findOneById(userId); + object.user = { }; + if (user) { + object.user = { + _id: userId, + username: user.username, + name: user.name, + }; + } + + + return object; +}); + diff --git a/app/api/server/helpers/isUserFromParams.js b/app/api/server/helpers/isUserFromParams.js new file mode 100644 index 000000000000..a96779c2eedc --- /dev/null +++ b/app/api/server/helpers/isUserFromParams.js @@ -0,0 +1,10 @@ +import { API } from '../api'; + +API.helperMethods.set('isUserFromParams', function _isUserFromParams() { + const params = this.requestParams(); + + return (!params.userId && !params.username && !params.user) || + (params.userId && this.userId === params.userId) || + (params.username && this.user.username === params.username) || + (params.user && this.user.username === params.user); +}); diff --git a/app/api/server/helpers/parseJsonQuery.js b/app/api/server/helpers/parseJsonQuery.js new file mode 100644 index 000000000000..f752fe918d2a --- /dev/null +++ b/app/api/server/helpers/parseJsonQuery.js @@ -0,0 +1,85 @@ +import { Meteor } from 'meteor/meteor'; +import { hasPermission } from '../../../authorization'; +import { EJSON } from 'meteor/ejson'; +import { API } from '../api'; + +API.helperMethods.set('parseJsonQuery', function _parseJsonQuery() { + let sort; + if (this.queryParams.sort) { + try { + sort = JSON.parse(this.queryParams.sort); + } catch (e) { + this.logger.warn(`Invalid sort parameter provided "${ this.queryParams.sort }":`, e); + throw new Meteor.Error('error-invalid-sort', `Invalid sort parameter provided: "${ this.queryParams.sort }"`, { helperMethod: 'parseJsonQuery' }); + } + } + + let fields; + if (this.queryParams.fields) { + try { + fields = JSON.parse(this.queryParams.fields); + } catch (e) { + this.logger.warn(`Invalid fields parameter provided "${ this.queryParams.fields }":`, e); + throw new Meteor.Error('error-invalid-fields', `Invalid fields parameter provided: "${ this.queryParams.fields }"`, { helperMethod: 'parseJsonQuery' }); + } + } + + // Verify the user's selected fields only contains ones which their role allows + if (typeof fields === 'object') { + let nonSelectableFields = Object.keys(API.v1.defaultFieldsToExclude); + if (this.request.route.includes('/v1/users.')) { + const getFields = () => Object.keys(hasPermission(this.userId, 'view-full-other-user-info') ? API.v1.limitedUserFieldsToExcludeIfIsPrivilegedUser : API.v1.limitedUserFieldsToExclude); + nonSelectableFields = nonSelectableFields.concat(getFields()); + } + + Object.keys(fields).forEach((k) => { + if (nonSelectableFields.includes(k) || nonSelectableFields.includes(k.split(API.v1.fieldSeparator)[0])) { + delete fields[k]; + } + }); + } + + // Limit the fields by default + fields = Object.assign({}, fields, API.v1.defaultFieldsToExclude); + if (this.request.route.includes('/v1/users.')) { + if (hasPermission(this.userId, 'view-full-other-user-info')) { + fields = Object.assign(fields, API.v1.limitedUserFieldsToExcludeIfIsPrivilegedUser); + } else { + fields = Object.assign(fields, API.v1.limitedUserFieldsToExclude); + } + } + + let query = {}; + if (this.queryParams.query) { + try { + query = EJSON.parse(this.queryParams.query); + } catch (e) { + this.logger.warn(`Invalid query parameter provided "${ this.queryParams.query }":`, e); + throw new Meteor.Error('error-invalid-query', `Invalid query parameter provided: "${ this.queryParams.query }"`, { helperMethod: 'parseJsonQuery' }); + } + } + + // Verify the user has permission to query the fields they are + if (typeof query === 'object') { + let nonQueryableFields = Object.keys(API.v1.defaultFieldsToExclude); + if (this.request.route.includes('/v1/users.')) { + if (hasPermission(this.userId, 'view-full-other-user-info')) { + nonQueryableFields = nonQueryableFields.concat(Object.keys(API.v1.limitedUserFieldsToExcludeIfIsPrivilegedUser)); + } else { + nonQueryableFields = nonQueryableFields.concat(Object.keys(API.v1.limitedUserFieldsToExclude)); + } + } + + Object.keys(query).forEach((k) => { + if (nonQueryableFields.includes(k) || nonQueryableFields.includes(k.split(API.v1.fieldSeparator)[0])) { + delete query[k]; + } + }); + } + + return { + sort, + fields, + query, + }; +}); diff --git a/app/api/server/helpers/requestParams.js b/app/api/server/helpers/requestParams.js new file mode 100644 index 000000000000..2883c94a727e --- /dev/null +++ b/app/api/server/helpers/requestParams.js @@ -0,0 +1,5 @@ +import { API } from '../api'; + +API.helperMethods.set('requestParams', function _requestParams() { + return ['POST', 'PUT'].includes(this.request.method) ? this.bodyParams : this.queryParams; +}); diff --git a/app/api/server/index.js b/app/api/server/index.js new file mode 100644 index 000000000000..483de4253475 --- /dev/null +++ b/app/api/server/index.js @@ -0,0 +1,33 @@ +import './settings'; +export { API } from './api'; +import './helpers/composeRoomWithLastMessage'; +import './helpers/deprecationWarning'; +import './helpers/getLoggedInUser'; +import './helpers/getPaginationItems'; +import './helpers/getUserFromParams'; +import './helpers/getUserInfo'; +import './helpers/insertUserObject'; +import './helpers/isUserFromParams'; +import './helpers/parseJsonQuery'; +import './helpers/requestParams'; +import './default/info'; +import './v1/assets'; +import './v1/channels'; +import './v1/chat'; +import './v1/commands'; +import './v1/e2e'; +import './v1/emoji-custom'; +import './v1/groups'; +import './v1/im'; +import './v1/integrations'; +import './v1/import'; +import './v1/misc'; +import './v1/permissions'; +import './v1/push'; +import './v1/roles'; +import './v1/rooms'; +import './v1/settings'; +import './v1/stats'; +import './v1/subscriptions'; +import './v1/users'; +import './v1/video-conference'; diff --git a/app/api/server/settings.js b/app/api/server/settings.js new file mode 100644 index 000000000000..afc90469617d --- /dev/null +++ b/app/api/server/settings.js @@ -0,0 +1,14 @@ +import { settings } from '../../settings'; + +settings.addGroup('General', function() { + this.section('REST API', function() { + this.add('API_Upper_Count_Limit', 100, { type: 'int', public: false }); + this.add('API_Default_Count', 50, { type: 'int', public: false }); + this.add('API_Allow_Infinite_Count', true, { type: 'boolean', public: false }); + this.add('API_Enable_Direct_Message_History_EndPoint', false, { type: 'boolean', public: false }); + this.add('API_Enable_Shields', true, { type: 'boolean', public: false }); + this.add('API_Shield_Types', '*', { type: 'string', public: false, enableQuery: { _id: 'API_Enable_Shields', value: true } }); + this.add('API_Enable_CORS', false, { type: 'boolean', public: false }); + this.add('API_CORS_Origin', '*', { type: 'string', public: false, enableQuery: { _id: 'API_Enable_CORS', value: true } }); + }); +}); diff --git a/app/api/server/v1/assets.js b/app/api/server/v1/assets.js new file mode 100644 index 000000000000..a80d5044c1c3 --- /dev/null +++ b/app/api/server/v1/assets.js @@ -0,0 +1,56 @@ +import { Meteor } from 'meteor/meteor'; +import { RocketChatAssets } from '../../../assets'; +import Busboy from 'busboy'; +import { API } from '../api'; + +API.v1.addRoute('assets.setAsset', { authRequired: true }, { + post() { + const busboy = new Busboy({ headers: this.request.headers }); + const fields = {}; + let asset = {}; + + Meteor.wrapAsync((callback) => { + busboy.on('field', (fieldname, value) => fields[fieldname] = value); + busboy.on('file', Meteor.bindEnvironment((fieldname, file, filename, encoding, mimetype) => { + const isValidAsset = Object.keys(RocketChatAssets.assets).includes(fieldname); + if (!isValidAsset) { + callback(new Meteor.Error('error-invalid-asset', 'Invalid asset')); + } + const assetData = []; + file.on('data', Meteor.bindEnvironment((data) => { + assetData.push(data); + })); + + file.on('end', Meteor.bindEnvironment(() => { + asset = { + buffer: Buffer.concat(assetData), + name: fieldname, + mimetype, + }; + })); + })); + busboy.on('finish', () => callback()); + this.request.pipe(busboy); + })(); + Meteor.runAsUser(this.userId, () => Meteor.call('setAsset', asset.buffer, asset.mimetype, asset.name)); + if (fields.refreshAllClients) { + Meteor.runAsUser(this.userId, () => Meteor.call('refreshClients')); + } + return API.v1.success(); + }, +}); + +API.v1.addRoute('assets.unsetAsset', { authRequired: true }, { + post() { + const { assetName, refreshAllClients } = this.bodyParams; + const isValidAsset = Object.keys(RocketChatAssets.assets).includes(assetName); + if (!isValidAsset) { + throw new Meteor.Error('error-invalid-asset', 'Invalid asset'); + } + Meteor.runAsUser(this.userId, () => Meteor.call('unsetAsset', assetName)); + if (refreshAllClients) { + Meteor.runAsUser(this.userId, () => Meteor.call('refreshClients')); + } + return API.v1.success(); + }, +}); diff --git a/app/api/server/v1/channels.js b/app/api/server/v1/channels.js new file mode 100644 index 000000000000..789d5c343892 --- /dev/null +++ b/app/api/server/v1/channels.js @@ -0,0 +1,1007 @@ +import { Meteor } from 'meteor/meteor'; +import { Rooms, Subscriptions, Messages, Uploads, Integrations, Users } from '../../../models'; +import { hasPermission } from '../../../authorization'; +import { composeMessageObjectWithUser } from '../../../utils'; +import { API } from '../api'; +import _ from 'underscore'; + +// Returns the channel IF found otherwise it will return the failure of why it didn't. Check the `statusCode` property +function findChannelByIdOrName({ params, checkedArchived = true, userId }) { + if ((!params.roomId || !params.roomId.trim()) && (!params.roomName || !params.roomName.trim())) { + throw new Meteor.Error('error-roomid-param-not-provided', 'The parameter "roomId" or "roomName" is required'); + } + + const fields = { ...API.v1.defaultFieldsToExclude }; + + let room; + if (params.roomId) { + room = Rooms.findOneById(params.roomId, { fields }); + } else if (params.roomName) { + room = Rooms.findOneByName(params.roomName, { fields }); + } + + if (!room || room.t !== 'c') { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any channel'); + } + + if (checkedArchived && room.archived) { + throw new Meteor.Error('error-room-archived', `The channel, ${ room.name }, is archived`); + } + if (userId && room.lastMessage) { + room.lastMessage = composeMessageObjectWithUser(room.lastMessage, userId); + } + + return room; +} + +API.v1.addRoute('channels.addAll', { authRequired: true }, { + post() { + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('addAllUserToRoom', findResult._id, this.bodyParams.activeUsersOnly); + }); + + return API.v1.success({ + channel: findChannelByIdOrName({ params: this.requestParams(), userId: this.userId }), + }); + }, +}); + +API.v1.addRoute('channels.addModerator', { authRequired: true }, { + post() { + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + const user = this.getUserFromParams(); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('addRoomModerator', findResult._id, user._id); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('channels.addOwner', { authRequired: true }, { + post() { + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + const user = this.getUserFromParams(); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('addRoomOwner', findResult._id, user._id); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('channels.archive', { authRequired: true }, { + post() { + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('archiveRoom', findResult._id); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('channels.close', { authRequired: true }, { + post() { + const findResult = findChannelByIdOrName({ params: this.requestParams(), checkedArchived: false }); + + const sub = Subscriptions.findOneByRoomIdAndUserId(findResult._id, this.userId); + + if (!sub) { + return API.v1.failure(`The user/callee is not in the channel "${ findResult.name }.`); + } + + if (!sub.open) { + return API.v1.failure(`The channel, ${ findResult.name }, is already closed to the sender`); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('hideRoom', findResult._id); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('channels.counters', { authRequired: true }, { + get() { + const access = hasPermission(this.userId, 'view-room-administration'); + const { userId } = this.requestParams(); + let user = this.userId; + let unreads = null; + let userMentions = null; + let unreadsFrom = null; + let joined = false; + let msgs = null; + let latest = null; + let members = null; + + if (userId) { + if (!access) { + return API.v1.unauthorized(); + } + user = userId; + } + const room = findChannelByIdOrName({ + params: this.requestParams(), + returnUsernames: true, + }); + const subscription = Subscriptions.findOneByRoomIdAndUserId(room._id, user); + const lm = room.lm ? room.lm : room._updatedAt; + + if (typeof subscription !== 'undefined' && subscription.open) { + unreads = Messages.countVisibleByRoomIdBetweenTimestampsInclusive(subscription.rid, subscription.ls, lm); + unreadsFrom = subscription.ls || subscription.ts; + userMentions = subscription.userMentions; + joined = true; + } + + if (access || joined) { + msgs = room.msgs; + latest = lm; + members = room.usersCount; + } + + return API.v1.success({ + joined, + members, + unreads, + unreadsFrom, + msgs, + latest, + userMentions, + }); + }, +}); + +// Channel -> create + +function createChannelValidator(params) { + if (!hasPermission(params.user.value, 'create-c')) { + throw new Error('unauthorized'); + } + + if (!params.name || !params.name.value) { + throw new Error(`Param "${ params.name.key }" is required`); + } + + if (params.members && params.members.value && !_.isArray(params.members.value)) { + throw new Error(`Param "${ params.members.key }" must be an array if provided`); + } + + if (params.customFields && params.customFields.value && !(typeof params.customFields.value === 'object')) { + throw new Error(`Param "${ params.customFields.key }" must be an object if provided`); + } +} + +function createChannel(userId, params) { + const readOnly = typeof params.readOnly !== 'undefined' ? params.readOnly : false; + const id = Meteor.runAsUser(userId, () => Meteor.call('createChannel', params.name, params.members ? params.members : [], readOnly, params.customFields)); + + return { + channel: findChannelByIdOrName({ params: { roomId: id.rid }, userId: this.userId }), + }; +} + +API.channels = {}; +API.channels.create = { + validate: createChannelValidator, + execute: createChannel, +}; + +API.v1.addRoute('channels.create', { authRequired: true }, { + post() { + const { userId, bodyParams } = this; + + let error; + + try { + API.channels.create.validate({ + user: { + value: userId, + }, + name: { + value: bodyParams.name, + key: 'name', + }, + members: { + value: bodyParams.members, + key: 'members', + }, + }); + } catch (e) { + if (e.message === 'unauthorized') { + error = API.v1.unauthorized(); + } else { + error = API.v1.failure(e.message); + } + } + + if (error) { + return error; + } + + return API.v1.success(API.channels.create.execute(userId, bodyParams)); + }, +}); + +API.v1.addRoute('channels.delete', { authRequired: true }, { + post() { + const findResult = findChannelByIdOrName({ params: this.requestParams(), checkedArchived: false }); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('eraseRoom', findResult._id); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('channels.files', { authRequired: true }, { + get() { + const findResult = findChannelByIdOrName({ params: this.requestParams(), checkedArchived: false }); + const addUserObjectToEveryObject = (file) => { + if (file.userId) { + file = this.insertUserObject({ object: file, userId: file.userId }); + } + return file; + }; + + Meteor.runAsUser(this.userId, () => { + Meteor.call('canAccessRoom', findResult._id, this.userId); + }); + + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + const ourQuery = Object.assign({}, query, { rid: findResult._id }); + + const files = Uploads.find(ourQuery, { + sort: sort ? sort : { name: 1 }, + skip: offset, + limit: count, + fields, + }).fetch(); + + return API.v1.success({ + files: files.map(addUserObjectToEveryObject), + count: + files.length, + offset, + total: Uploads.find(ourQuery).count(), + }); + }, +}); + +API.v1.addRoute('channels.getIntegrations', { authRequired: true }, { + get() { + if (!hasPermission(this.userId, 'manage-integrations')) { + return API.v1.unauthorized(); + } + + const findResult = findChannelByIdOrName({ params: this.requestParams(), checkedArchived: false }); + + let includeAllPublicChannels = true; + if (typeof this.queryParams.includeAllPublicChannels !== 'undefined') { + includeAllPublicChannels = this.queryParams.includeAllPublicChannels === 'true'; + } + + let ourQuery = { + channel: `#${ findResult.name }`, + }; + + if (includeAllPublicChannels) { + ourQuery.channel = { + $in: [ourQuery.channel, 'all_public_channels'], + }; + } + + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + ourQuery = Object.assign({}, query, ourQuery); + + const integrations = Integrations.find(ourQuery, { + sort: sort ? sort : { _createdAt: 1 }, + skip: offset, + limit: count, + fields, + }).fetch(); + + return API.v1.success({ + integrations, + count: integrations.length, + offset, + total: Integrations.find(ourQuery).count(), + }); + }, +}); + +API.v1.addRoute('channels.history', { authRequired: true }, { + get() { + const findResult = findChannelByIdOrName({ params: this.requestParams(), checkedArchived: false }); + + let latestDate = new Date(); + if (this.queryParams.latest) { + latestDate = new Date(this.queryParams.latest); + } + + let oldestDate = undefined; + if (this.queryParams.oldest) { + oldestDate = new Date(this.queryParams.oldest); + } + + const inclusive = this.queryParams.inclusive || false; + + let count = 20; + if (this.queryParams.count) { + count = parseInt(this.queryParams.count); + } + + let offset = 0; + if (this.queryParams.offset) { + offset = parseInt(this.queryParams.offset); + } + + const unreads = this.queryParams.unreads || false; + + let result; + Meteor.runAsUser(this.userId, () => { + result = Meteor.call('getChannelHistory', { + rid: findResult._id, + latest: latestDate, + oldest: oldestDate, + inclusive, + offset, + count, + unreads, + }); + }); + + if (!result) { + return API.v1.unauthorized(); + } + + return API.v1.success(result); + }, +}); + +API.v1.addRoute('channels.info', { authRequired: true }, { + get() { + return API.v1.success({ + channel: findChannelByIdOrName({ + params: this.requestParams(), + checkedArchived: false, + userId: this.userId, + }), + }); + }, +}); + +API.v1.addRoute('channels.invite', { authRequired: true }, { + post() { + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + const user = this.getUserFromParams(); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('addUserToRoom', { rid: findResult._id, username: user.username }); + }); + + return API.v1.success({ + channel: findChannelByIdOrName({ params: this.requestParams(), userId: this.userId }), + }); + }, +}); + +API.v1.addRoute('channels.join', { authRequired: true }, { + post() { + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('joinRoom', findResult._id, this.bodyParams.joinCode); + }); + + return API.v1.success({ + channel: findChannelByIdOrName({ params: this.requestParams(), userId: this.userId }), + }); + }, +}); + +API.v1.addRoute('channels.kick', { authRequired: true }, { + post() { + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + const user = this.getUserFromParams(); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('removeUserFromRoom', { rid: findResult._id, username: user.username }); + }); + + return API.v1.success({ + channel: findChannelByIdOrName({ params: this.requestParams(), userId: this.userId }), + }); + }, +}); + +API.v1.addRoute('channels.leave', { authRequired: true }, { + post() { + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('leaveRoom', findResult._id); + }); + + return API.v1.success({ + channel: findChannelByIdOrName({ params: this.requestParams(), userId: this.userId }), + }); + }, +}); + +API.v1.addRoute('channels.list', { authRequired: true }, { + get: { + // This is defined as such only to provide an example of how the routes can be defined :X + action() { + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + const hasPermissionToSeeAllPublicChannels = hasPermission(this.userId, 'view-c-room'); + + const ourQuery = { ...query, t: 'c' }; + + if (!hasPermissionToSeeAllPublicChannels) { + if (!hasPermission(this.userId, 'view-joined-room')) { + return API.v1.unauthorized(); + } + const roomIds = Subscriptions.findByUserIdAndType(this.userId, 'c', { fields: { rid: 1 } }).fetch().map((s) => s.rid); + ourQuery._id = { $in: roomIds }; + } + + const cursor = Rooms.find(ourQuery, { + sort: sort ? sort : { name: 1 }, + skip: offset, + limit: count, + fields, + }); + + const total = cursor.count(); + + const rooms = cursor.fetch(); + + return API.v1.success({ + channels: rooms.map((room) => this.composeRoomWithLastMessage(room, this.userId)), + count: rooms.length, + offset, + total, + }); + }, + }, +}); + +API.v1.addRoute('channels.list.joined', { authRequired: true }, { + get() { + const { offset, count } = this.getPaginationItems(); + const { sort, fields } = this.parseJsonQuery(); + + // TODO: CACHE: Add Breacking notice since we removed the query param + const cursor = Rooms.findBySubscriptionTypeAndUserId('c', this.userId, { + sort: sort ? sort : { name: 1 }, + skip: offset, + limit: count, + fields, + }); + + const totalCount = cursor.count(); + const rooms = cursor.fetch(); + + return API.v1.success({ + channels: rooms.map((room) => this.composeRoomWithLastMessage(room, this.userId)), + offset, + count: rooms.length, + total: totalCount, + }); + }, +}); + +API.v1.addRoute('channels.members', { authRequired: true }, { + get() { + const findResult = findChannelByIdOrName({ + params: this.requestParams(), + checkedArchived: false, + }); + + if (findResult.broadcast && !hasPermission(this.userId, 'view-broadcast-member-list')) { + return API.v1.unauthorized(); + } + + const { offset, count } = this.getPaginationItems(); + const { sort = {} } = this.parseJsonQuery(); + + const subscriptions = Subscriptions.findByRoomId(findResult._id, { + fields: { 'u._id': 1 }, + sort: { 'u.username': sort.username != null ? sort.username : 1 }, + skip: offset, + limit: count, + }); + + const total = subscriptions.count(); + + const members = subscriptions.fetch().map((s) => s.u && s.u._id); + + const users = Users.find({ _id: { $in: members } }, { + fields: { _id: 1, username: 1, name: 1, status: 1, utcOffset: 1 }, + sort: { username: sort.username != null ? sort.username : 1 }, + }).fetch(); + + return API.v1.success({ + members: users, + count: users.length, + offset, + total, + }); + }, +}); + +API.v1.addRoute('channels.messages', { authRequired: true }, { + get() { + const findResult = findChannelByIdOrName({ + params: this.requestParams(), + checkedArchived: false, + }); + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + const ourQuery = Object.assign({}, query, { rid: findResult._id }); + + // Special check for the permissions + if (hasPermission(this.userId, 'view-joined-room') && !Subscriptions.findOneByRoomIdAndUserId(findResult._id, this.userId, { fields: { _id: 1 } })) { + return API.v1.unauthorized(); + } + if (!hasPermission(this.userId, 'view-c-room')) { + return API.v1.unauthorized(); + } + + const cursor = Messages.find(ourQuery, { + sort: sort ? sort : { ts: -1 }, + skip: offset, + limit: count, + fields, + }); + + const total = cursor.count(); + const messages = cursor.fetch(); + + return API.v1.success({ + messages: messages.map((record) => composeMessageObjectWithUser(record, this.userId)), + count: messages.length, + offset, + total, + }); + }, +}); +// TODO: CACHE: I dont like this method( functionality and how we implemented ) its very expensive +// TODO check if this code is better or not +// RocketChat.API.v1.addRoute('channels.online', { authRequired: true }, { +// get() { +// const { query } = this.parseJsonQuery(); +// const ourQuery = Object.assign({}, query, { t: 'c' }); + +// const room = RocketChat.models.Rooms.findOne(ourQuery); + +// if (room == null) { +// return RocketChat.API.v1.failure('Channel does not exists'); +// } + +// const ids = RocketChat.models.Subscriptions.find({ rid: room._id }, { fields: { 'u._id': 1 } }).fetch().map(sub => sub.u._id); + +// const online = RocketChat.models.Users.find({ +// username: { $exists: 1 }, +// _id: { $in: ids }, +// status: { $in: ['online', 'away', 'busy'] } +// }, { +// fields: { username: 1 } +// }).fetch(); + +// return RocketChat.API.v1.success({ +// online +// }); +// } +// }); + +API.v1.addRoute('channels.online', { authRequired: true }, { + get() { + const { query } = this.parseJsonQuery(); + const ourQuery = Object.assign({}, query, { t: 'c' }); + + const room = Rooms.findOne(ourQuery); + + if (room == null) { + return API.v1.failure('Channel does not exists'); + } + + const online = Users.findUsersNotOffline({ + fields: { username: 1 }, + }).fetch(); + + const onlineInRoom = []; + online.forEach((user) => { + const subscription = Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { fields: { _id: 1 } }); + if (subscription) { + onlineInRoom.push({ + _id: user._id, + username: user.username, + }); + } + }); + + return API.v1.success({ + online: onlineInRoom, + }); + }, +}); + +API.v1.addRoute('channels.open', { authRequired: true }, { + post() { + const findResult = findChannelByIdOrName({ params: this.requestParams(), checkedArchived: false }); + + const sub = Subscriptions.findOneByRoomIdAndUserId(findResult._id, this.userId); + + if (!sub) { + return API.v1.failure(`The user/callee is not in the channel "${ findResult.name }".`); + } + + if (sub.open) { + return API.v1.failure(`The channel, ${ findResult.name }, is already open to the sender`); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('openRoom', findResult._id); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('channels.removeModerator', { authRequired: true }, { + post() { + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + const user = this.getUserFromParams(); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('removeRoomModerator', findResult._id, user._id); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('channels.removeOwner', { authRequired: true }, { + post() { + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + const user = this.getUserFromParams(); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('removeRoomOwner', findResult._id, user._id); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('channels.rename', { authRequired: true }, { + post() { + if (!this.bodyParams.name || !this.bodyParams.name.trim()) { + return API.v1.failure('The bodyParam "name" is required'); + } + + const findResult = findChannelByIdOrName({ params: { roomId: this.bodyParams.roomId } }); + + if (findResult.name === this.bodyParams.name) { + return API.v1.failure('The channel name is the same as what it would be renamed to.'); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('saveRoomSettings', findResult._id, 'roomName', this.bodyParams.name); + }); + + return API.v1.success({ + channel: findChannelByIdOrName({ params: { roomId: this.bodyParams.roomId }, userId: this.userId }), + }); + }, +}); + +API.v1.addRoute('channels.setCustomFields', { authRequired: true }, { + post() { + if (!this.bodyParams.customFields || !(typeof this.bodyParams.customFields === 'object')) { + return API.v1.failure('The bodyParam "customFields" is required with a type like object.'); + } + + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('saveRoomSettings', findResult._id, 'roomCustomFields', this.bodyParams.customFields); + }); + + return API.v1.success({ + channel: findChannelByIdOrName({ params: this.requestParams(), userId: this.userId }), + }); + }, +}); + +API.v1.addRoute('channels.setDefault', { authRequired: true }, { + post() { + if (typeof this.bodyParams.default === 'undefined') { + return API.v1.failure('The bodyParam "default" is required', 'error-channels-setdefault-is-same'); + } + + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + if (findResult.default === this.bodyParams.default) { + return API.v1.failure('The channel default setting is the same as what it would be changed to.', 'error-channels-setdefault-missing-default-param'); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('saveRoomSettings', findResult._id, 'default', this.bodyParams.default.toString()); + }); + + return API.v1.success({ + channel: findChannelByIdOrName({ params: this.requestParams(), userId: this.userId }), + }); + }, +}); + +API.v1.addRoute('channels.setDescription', { authRequired: true }, { + post() { + if (!this.bodyParams.description || !this.bodyParams.description.trim()) { + return API.v1.failure('The bodyParam "description" is required'); + } + + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + if (findResult.description === this.bodyParams.description) { + return API.v1.failure('The channel description is the same as what it would be changed to.'); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('saveRoomSettings', findResult._id, 'roomDescription', this.bodyParams.description); + }); + + return API.v1.success({ + description: this.bodyParams.description, + }); + }, +}); + +API.v1.addRoute('channels.setJoinCode', { authRequired: true }, { + post() { + if (!this.bodyParams.joinCode || !this.bodyParams.joinCode.trim()) { + return API.v1.failure('The bodyParam "joinCode" is required'); + } + + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('saveRoomSettings', findResult._id, 'joinCode', this.bodyParams.joinCode); + }); + + return API.v1.success({ + channel: findChannelByIdOrName({ params: this.requestParams(), userId: this.userId }), + }); + }, +}); + +API.v1.addRoute('channels.setPurpose', { authRequired: true }, { + post() { + if (!this.bodyParams.purpose || !this.bodyParams.purpose.trim()) { + return API.v1.failure('The bodyParam "purpose" is required'); + } + + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + if (findResult.description === this.bodyParams.purpose) { + return API.v1.failure('The channel purpose (description) is the same as what it would be changed to.'); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('saveRoomSettings', findResult._id, 'roomDescription', this.bodyParams.purpose); + }); + + return API.v1.success({ + purpose: this.bodyParams.purpose, + }); + }, +}); + +API.v1.addRoute('channels.setReadOnly', { authRequired: true }, { + post() { + if (typeof this.bodyParams.readOnly === 'undefined') { + return API.v1.failure('The bodyParam "readOnly" is required'); + } + + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + if (findResult.ro === this.bodyParams.readOnly) { + return API.v1.failure('The channel read only setting is the same as what it would be changed to.'); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('saveRoomSettings', findResult._id, 'readOnly', this.bodyParams.readOnly); + }); + + return API.v1.success({ + channel: findChannelByIdOrName({ params: this.requestParams(), userId: this.userId }), + }); + }, +}); + +API.v1.addRoute('channels.setTopic', { authRequired: true }, { + post() { + if (!this.bodyParams.topic || !this.bodyParams.topic.trim()) { + return API.v1.failure('The bodyParam "topic" is required'); + } + + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + if (findResult.topic === this.bodyParams.topic) { + return API.v1.failure('The channel topic is the same as what it would be changed to.'); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('saveRoomSettings', findResult._id, 'roomTopic', this.bodyParams.topic); + }); + + return API.v1.success({ + topic: this.bodyParams.topic, + }); + }, +}); + +API.v1.addRoute('channels.setAnnouncement', { authRequired: true }, { + post() { + if (!this.bodyParams.announcement || !this.bodyParams.announcement.trim()) { + return API.v1.failure('The bodyParam "announcement" is required'); + } + + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('saveRoomSettings', findResult._id, 'roomAnnouncement', this.bodyParams.announcement); + }); + + return API.v1.success({ + announcement: this.bodyParams.announcement, + }); + }, +}); + +API.v1.addRoute('channels.setType', { authRequired: true }, { + post() { + if (!this.bodyParams.type || !this.bodyParams.type.trim()) { + return API.v1.failure('The bodyParam "type" is required'); + } + + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + if (findResult.t === this.bodyParams.type) { + return API.v1.failure('The channel type is the same as what it would be changed to.'); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('saveRoomSettings', findResult._id, 'roomType', this.bodyParams.type); + }); + + return API.v1.success({ + channel: this.composeRoomWithLastMessage(Rooms.findOneById(findResult._id, { fields: API.v1.defaultFieldsToExclude }), this.userId), + }); + }, +}); + +API.v1.addRoute('channels.unarchive', { authRequired: true }, { + post() { + const findResult = findChannelByIdOrName({ params: this.requestParams(), checkedArchived: false }); + + if (!findResult.archived) { + return API.v1.failure(`The channel, ${ findResult.name }, is not archived`); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('unarchiveRoom', findResult._id); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('channels.getAllUserMentionsByChannel', { authRequired: true }, { + get() { + const { roomId } = this.requestParams(); + const { offset, count } = this.getPaginationItems(); + const { sort } = this.parseJsonQuery(); + + if (!roomId) { + return API.v1.failure('The request param "roomId" is required'); + } + + const mentions = Meteor.runAsUser(this.userId, () => Meteor.call('getUserMentionsByChannel', { + roomId, + options: { + sort: sort ? sort : { ts: 1 }, + skip: offset, + limit: count, + }, + })); + + const allMentions = Meteor.runAsUser(this.userId, () => Meteor.call('getUserMentionsByChannel', { + roomId, + options: {}, + })); + + return API.v1.success({ + mentions, + count: mentions.length, + offset, + total: allMentions.length, + }); + }, +}); + +API.v1.addRoute('channels.roles', { authRequired: true }, { + get() { + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + const roles = Meteor.runAsUser(this.userId, () => Meteor.call('getRoomRoles', findResult._id)); + + return API.v1.success({ + roles, + }); + }, +}); + +API.v1.addRoute('channels.moderators', { authRequired: true }, { + get() { + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + const moderators = Subscriptions.findByRoomIdAndRoles(findResult._id, ['moderator'], { fields: { u: 1 } }).fetch().map((sub) => sub.u); + + return API.v1.success({ + moderators, + }); + }, +}); + +API.v1.addRoute('channels.addLeader', { authRequired: true }, { + post() { + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + const user = this.getUserFromParams(); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('addRoomLeader', findResult._id, user._id); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('channels.removeLeader', { authRequired: true }, { + post() { + const findResult = findChannelByIdOrName({ params: this.requestParams() }); + + const user = this.getUserFromParams(); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('removeRoomLeader', findResult._id, user._id); + }); + + return API.v1.success(); + }, +}); + diff --git a/app/api/server/v1/chat.js b/app/api/server/v1/chat.js new file mode 100644 index 000000000000..d2c1b153cc8f --- /dev/null +++ b/app/api/server/v1/chat.js @@ -0,0 +1,560 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; +import { Messages } from '../../../models'; +import { canAccessRoom, hasPermission } from '../../../authorization'; +import { composeMessageObjectWithUser } from '../../../utils'; +import { processWebhookMessage } from '../../../lib'; +import { API } from '../api'; +import Rooms from '../../../models/server/models/Rooms'; +import Users from '../../../models/server/models/Users'; +import { settings } from '../../../settings'; + +API.v1.addRoute('chat.delete', { authRequired: true }, { + post() { + check(this.bodyParams, Match.ObjectIncluding({ + msgId: String, + roomId: String, + asUser: Match.Maybe(Boolean), + })); + + const msg = Messages.findOneById(this.bodyParams.msgId, { fields: { u: 1, rid: 1 } }); + + if (!msg) { + return API.v1.failure(`No message found with the id of "${ this.bodyParams.msgId }".`); + } + + if (this.bodyParams.roomId !== msg.rid) { + return API.v1.failure('The room id provided does not match where the message is from.'); + } + + if (this.bodyParams.asUser && msg.u._id !== this.userId && !hasPermission(this.userId, 'force-delete-message', msg.rid)) { + return API.v1.failure('Unauthorized. You must have the permission "force-delete-message" to delete other\'s message as them.'); + } + + Meteor.runAsUser(this.bodyParams.asUser ? msg.u._id : this.userId, () => { + Meteor.call('deleteMessage', { _id: msg._id }); + }); + + return API.v1.success({ + _id: msg._id, + ts: Date.now(), + message: msg, + }); + }, +}); + +API.v1.addRoute('chat.syncMessages', { authRequired: true }, { + get() { + const { roomId, lastUpdate } = this.queryParams; + + if (!roomId) { + throw new Meteor.Error('error-roomId-param-not-provided', 'The required "roomId" query param is missing.'); + } + + if (!lastUpdate) { + throw new Meteor.Error('error-lastUpdate-param-not-provided', 'The required "lastUpdate" query param is missing.'); + } else if (isNaN(Date.parse(lastUpdate))) { + throw new Meteor.Error('error-roomId-param-invalid', 'The "lastUpdate" query parameter must be a valid date.'); + } + + let result; + Meteor.runAsUser(this.userId, () => { + result = Meteor.call('messages/get', roomId, { lastUpdate: new Date(lastUpdate) }); + }); + + if (!result) { + return API.v1.failure(); + } + + return API.v1.success({ + result: { + updated: result.updated.map((message) => composeMessageObjectWithUser(message, this.userId)), + deleted: result.deleted.map((message) => composeMessageObjectWithUser(message, this.userId)), + }, + }); + }, +}); + +API.v1.addRoute('chat.getMessage', { authRequired: true }, { + get() { + if (!this.queryParams.msgId) { + return API.v1.failure('The "msgId" query parameter must be provided.'); + } + + let msg; + Meteor.runAsUser(this.userId, () => { + msg = Meteor.call('getSingleMessage', this.queryParams.msgId); + }); + + if (!msg) { + return API.v1.failure(); + } + + return API.v1.success({ + message: composeMessageObjectWithUser(msg, this.userId), + }); + }, +}); + +API.v1.addRoute('chat.pinMessage', { authRequired: true }, { + post() { + if (!this.bodyParams.messageId || !this.bodyParams.messageId.trim()) { + throw new Meteor.Error('error-messageid-param-not-provided', 'The required "messageId" param is missing.'); + } + + const msg = Messages.findOneById(this.bodyParams.messageId); + + if (!msg) { + throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); + } + + let pinnedMessage; + Meteor.runAsUser(this.userId, () => pinnedMessage = Meteor.call('pinMessage', msg)); + + return API.v1.success({ + message: composeMessageObjectWithUser(pinnedMessage, this.userId), + }); + }, +}); + +API.v1.addRoute('chat.postMessage', { authRequired: true }, { + post() { + const messageReturn = processWebhookMessage(this.bodyParams, this.user, undefined, true)[0]; + + if (!messageReturn) { + return API.v1.failure('unknown-error'); + } + + return API.v1.success({ + ts: Date.now(), + channel: messageReturn.channel, + message: composeMessageObjectWithUser(messageReturn.message, this.userId), + }); + }, +}); + +API.v1.addRoute('chat.search', { authRequired: true }, { + get() { + const { roomId, searchText } = this.queryParams; + const { count } = this.getPaginationItems(); + + if (!roomId) { + throw new Meteor.Error('error-roomId-param-not-provided', 'The required "roomId" query param is missing.'); + } + + if (!searchText) { + throw new Meteor.Error('error-searchText-param-not-provided', 'The required "searchText" query param is missing.'); + } + + let result; + Meteor.runAsUser(this.userId, () => result = Meteor.call('messageSearch', searchText, roomId, count).message.docs); + + return API.v1.success({ + messages: result.map((message) => composeMessageObjectWithUser(message, this.userId)), + }); + }, +}); + +// The difference between `chat.postMessage` and `chat.sendMessage` is that `chat.sendMessage` allows +// for passing a value for `_id` and the other one doesn't. Also, `chat.sendMessage` only sends it to +// one channel whereas the other one allows for sending to more than one channel at a time. +API.v1.addRoute('chat.sendMessage', { authRequired: true }, { + post() { + if (!this.bodyParams.message) { + throw new Meteor.Error('error-invalid-params', 'The "message" parameter must be provided.'); + } + + let message; + Meteor.runAsUser(this.userId, () => message = Meteor.call('sendMessage', this.bodyParams.message)); + + return API.v1.success({ + message: composeMessageObjectWithUser(message, this.userId), + }); + }, +}); + +API.v1.addRoute('chat.starMessage', { authRequired: true }, { + post() { + if (!this.bodyParams.messageId || !this.bodyParams.messageId.trim()) { + throw new Meteor.Error('error-messageid-param-not-provided', 'The required "messageId" param is required.'); + } + + const msg = Messages.findOneById(this.bodyParams.messageId); + + if (!msg) { + throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); + } + + Meteor.runAsUser(this.userId, () => Meteor.call('starMessage', { + _id: msg._id, + rid: msg.rid, + starred: true, + })); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('chat.unPinMessage', { authRequired: true }, { + post() { + if (!this.bodyParams.messageId || !this.bodyParams.messageId.trim()) { + throw new Meteor.Error('error-messageid-param-not-provided', 'The required "messageId" param is required.'); + } + + const msg = Messages.findOneById(this.bodyParams.messageId); + + if (!msg) { + throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); + } + + Meteor.runAsUser(this.userId, () => Meteor.call('unpinMessage', msg)); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('chat.unStarMessage', { authRequired: true }, { + post() { + if (!this.bodyParams.messageId || !this.bodyParams.messageId.trim()) { + throw new Meteor.Error('error-messageid-param-not-provided', 'The required "messageId" param is required.'); + } + + const msg = Messages.findOneById(this.bodyParams.messageId); + + if (!msg) { + throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); + } + + Meteor.runAsUser(this.userId, () => Meteor.call('starMessage', { + _id: msg._id, + rid: msg.rid, + starred: false, + })); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('chat.update', { authRequired: true }, { + post() { + check(this.bodyParams, Match.ObjectIncluding({ + roomId: String, + msgId: String, + text: String, // Using text to be consistant with chat.postMessage + })); + + const msg = Messages.findOneById(this.bodyParams.msgId); + + // Ensure the message exists + if (!msg) { + return API.v1.failure(`No message found with the id of "${ this.bodyParams.msgId }".`); + } + + if (this.bodyParams.roomId !== msg.rid) { + return API.v1.failure('The room id provided does not match where the message is from.'); + } + + // Permission checks are already done in the updateMessage method, so no need to duplicate them + Meteor.runAsUser(this.userId, () => { + Meteor.call('updateMessage', { _id: msg._id, msg: this.bodyParams.text, rid: msg.rid }); + }); + + return API.v1.success({ + message: composeMessageObjectWithUser(Messages.findOneById(msg._id), this.userId), + }); + }, +}); + +API.v1.addRoute('chat.react', { authRequired: true }, { + post() { + if (!this.bodyParams.messageId || !this.bodyParams.messageId.trim()) { + throw new Meteor.Error('error-messageid-param-not-provided', 'The required "messageId" param is missing.'); + } + + const msg = Messages.findOneById(this.bodyParams.messageId); + + if (!msg) { + throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); + } + + const emoji = this.bodyParams.emoji || this.bodyParams.reaction; + + if (!emoji) { + throw new Meteor.Error('error-emoji-param-not-provided', 'The required "emoji" param is missing.'); + } + + Meteor.runAsUser(this.userId, () => Meteor.call('setReaction', emoji, msg._id, this.bodyParams.shouldReact)); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('chat.getMessageReadReceipts', { authRequired: true }, { + get() { + const { messageId } = this.queryParams; + if (!messageId) { + return API.v1.failure({ + error: 'The required \'messageId\' param is missing.', + }); + } + + try { + const messageReadReceipts = Meteor.runAsUser(this.userId, () => Meteor.call('getReadReceipts', { messageId })); + return API.v1.success({ + receipts: messageReadReceipts, + }); + } catch (error) { + return API.v1.failure({ + error: error.message, + }); + } + }, +}); + +API.v1.addRoute('chat.reportMessage', { authRequired: true }, { + post() { + const { messageId, description } = this.bodyParams; + if (!messageId) { + return API.v1.failure('The required "messageId" param is missing.'); + } + + if (!description) { + return API.v1.failure('The required "description" param is missing.'); + } + + Meteor.runAsUser(this.userId, () => Meteor.call('reportMessage', messageId, description)); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('chat.ignoreUser', { authRequired: true }, { + get() { + const { rid, userId } = this.queryParams; + let { ignore = true } = this.queryParams; + + ignore = typeof ignore === 'string' ? /true|1/.test(ignore) : ignore; + + if (!rid || !rid.trim()) { + throw new Meteor.Error('error-room-id-param-not-provided', 'The required "rid" param is missing.'); + } + + if (!userId || !userId.trim()) { + throw new Meteor.Error('error-user-id-param-not-provided', 'The required "userId" param is missing.'); + } + + Meteor.runAsUser(this.userId, () => Meteor.call('ignoreUser', { rid, userId, ignore })); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('chat.getDeletedMessages', { authRequired: true }, { + get() { + const { roomId, since } = this.queryParams; + const { offset, count } = this.getPaginationItems(); + + if (!roomId) { + throw new Meteor.Error('The required "roomId" query param is missing.'); + } + + if (!since) { + throw new Meteor.Error('The required "since" query param is missing.'); + } else if (isNaN(Date.parse(since))) { + throw new Meteor.Error('The "since" query parameter must be a valid date.'); + } + const cursor = Messages.trashFindDeletedAfter(new Date(since), { rid: roomId }, { + skip: offset, + limit: count, + fields: { _id: 1 }, + }); + + const total = cursor.count(); + + const messages = cursor.fetch(); + + return API.v1.success({ + messages, + count: messages.length, + offset, + total, + }); + }, +}); + +API.v1.addRoute('chat.getThreadsList', { authRequired: true }, { + get() { + const { rid } = this.queryParams; + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + if (!rid) { + throw new Meteor.Error('The required "rid" query param is missing.'); + } + if (!settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'Threads Disabled'); + } + const user = Users.findOneById(this.userId, { fields: { _id: 1 } }); + const room = Rooms.findOneById(rid, { fields: { t: 1, _id: 1 } }); + if (!canAccessRoom(room, user)) { + throw new Meteor.Error('error-not-allowed', 'Not Allowed'); + } + const threadQuery = Object.assign({}, query, { rid, tcount: { $exists: true } }); + const cursor = Messages.find(threadQuery, { + sort: sort ? sort : { ts: 1 }, + skip: offset, + limit: count, + fields, + }); + + const total = cursor.count(); + + const threads = cursor.fetch(); + + return API.v1.success({ + threads, + count: threads.length, + offset, + total, + }); + }, +}); + +API.v1.addRoute('chat.syncThreadsList', { authRequired: true }, { + get() { + const { rid } = this.queryParams; + const { query, fields, sort } = this.parseJsonQuery(); + const { updatedSince } = this.queryParams; + let updatedSinceDate; + if (!settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'Threads Disabled'); + } + if (!rid) { + throw new Meteor.Error('error-room-id-param-not-provided', 'The required "rid" query param is missing.'); + } + if (!updatedSince) { + throw new Meteor.Error('error-updatedSince-param-invalid', 'The required param "updatedSince" is missing.'); + } + if (isNaN(Date.parse(updatedSince))) { + throw new Meteor.Error('error-updatedSince-param-invalid', 'The "updatedSince" query parameter must be a valid date.'); + } else { + updatedSinceDate = new Date(updatedSince); + } + const user = Users.findOneById(this.userId, { fields: { _id: 1 } }); + const room = Rooms.findOneById(rid, { fields: { t: 1, _id: 1 } }); + if (!canAccessRoom(room, user)) { + throw new Meteor.Error('error-not-allowed', 'Not Allowed'); + } + const threadQuery = Object.assign({}, query, { rid, tcount: { $exists: true } }); + return API.v1.success({ + threads: { + update: Messages.find({ ...threadQuery, _updatedAt: { $gt: updatedSinceDate } }, { fields, sort }).fetch(), + remove: Messages.trashFindDeletedAfter(updatedSinceDate, threadQuery, { fields, sort }).fetch(), + }, + }); + }, +}); + +API.v1.addRoute('chat.getThreadMessages', { authRequired: true }, { + get() { + const { tmid } = this.queryParams; + const { query, fields, sort } = this.parseJsonQuery(); + const { offset, count } = this.getPaginationItems(); + + if (!settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'Threads Disabled'); + } + if (!tmid) { + throw new Meteor.Error('error-invalid-params', 'The required "tmid" query param is missing.'); + } + const thread = Messages.findOneById(tmid, { fields: { rid: 1 } }); + if (!thread || !thread.rid) { + throw new Meteor.Error('error-invalid-message', 'Invalid Message'); + } + const user = Users.findOneById(this.userId, { fields: { _id: 1 } }); + const room = Rooms.findOneById(thread.rid, { fields: { t: 1, _id: 1 } }); + + if (!canAccessRoom(room, user)) { + throw new Meteor.Error('error-not-allowed', 'Not Allowed'); + } + const cursor = Messages.find({ ...query, tmid }, { + sort: sort ? sort : { ts: 1 }, + skip: offset, + limit: count, + fields, + }); + + const total = cursor.count(); + + const messages = cursor.fetch(); + + return API.v1.success({ + messages, + count: messages.length, + offset, + total, + }); + }, +}); + +API.v1.addRoute('chat.syncThreadMessages', { authRequired: true }, { + get() { + const { tmid } = this.queryParams; + const { query, fields, sort } = this.parseJsonQuery(); + const { updatedSince } = this.queryParams; + let updatedSinceDate; + if (!settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'Threads Disabled'); + } + if (!tmid) { + throw new Meteor.Error('error-invalid-params', 'The required "tmid" query param is missing.'); + } + if (!updatedSince) { + throw new Meteor.Error('error-updatedSince-param-invalid', 'The required param "updatedSince" is missing.'); + } + if (isNaN(Date.parse(updatedSince))) { + throw new Meteor.Error('error-updatedSince-param-invalid', 'The "updatedSince" query parameter must be a valid date.'); + } else { + updatedSinceDate = new Date(updatedSince); + } + const thread = Messages.findOneById(tmid, { fields: { rid: 1 } }); + if (!thread || !thread.rid) { + throw new Meteor.Error('error-invalid-message', 'Invalid Message'); + } + const user = Users.findOneById(this.userId, { fields: { _id: 1 } }); + const room = Rooms.findOneById(thread.rid, { fields: { t: 1, _id: 1 } }); + + if (!canAccessRoom(room, user)) { + throw new Meteor.Error('error-not-allowed', 'Not Allowed'); + } + return API.v1.success({ + messages: { + update: Messages.find({ ...query, tmid, _updatedAt: { $gt: updatedSinceDate } }, { fields, sort }).fetch(), + remove: Messages.trashFindDeletedAfter(updatedSinceDate, { ...query, tmid }, { fields, sort }).fetch(), + }, + }); + }, +}); + +API.v1.addRoute('chat.followMessage', { authRequired: true }, { + post() { + const { mid } = this.bodyParams; + + if (!mid) { + throw new Meteor.Error('The required "mid" body param is missing.'); + } + Meteor.runAsUser(this.userId, () => Meteor.call('followMessage', { mid })); + return API.v1.success(); + }, +}); + +API.v1.addRoute('chat.unfollowMessage', { authRequired: true }, { + post() { + const { mid } = this.bodyParams; + + if (!mid) { + throw new Meteor.Error('The required "mid" body param is missing.'); + } + Meteor.runAsUser(this.userId, () => Meteor.call('unfollowMessage', { mid })); + return API.v1.success(); + }, +}); diff --git a/app/api/server/v1/commands.js b/app/api/server/v1/commands.js new file mode 100644 index 000000000000..61c1be7ea672 --- /dev/null +++ b/app/api/server/v1/commands.js @@ -0,0 +1,170 @@ +import { Meteor } from 'meteor/meteor'; +import { Random } from 'meteor/random'; +import { slashCommands } from '../../../utils'; +import { Rooms } from '../../../models'; +import { API } from '../api'; + +API.v1.addRoute('commands.get', { authRequired: true }, { + get() { + const params = this.queryParams; + + if (typeof params.command !== 'string') { + return API.v1.failure('The query param "command" must be provided.'); + } + + const cmd = slashCommands.commands[params.command.toLowerCase()]; + + if (!cmd) { + return API.v1.failure(`There is no command in the system by the name of: ${ params.command }`); + } + + return API.v1.success({ command: cmd }); + }, +}); + +API.v1.addRoute('commands.list', { authRequired: true }, { + get() { + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + let commands = Object.values(slashCommands.commands); + + if (query && query.command) { + commands = commands.filter((command) => command.command === query.command); + } + + const totalCount = commands.length; + commands = Rooms.processQueryOptionsOnResult(commands, { + sort: sort ? sort : { name: 1 }, + skip: offset, + limit: count, + fields, + }); + + return API.v1.success({ + commands, + offset, + count: commands.length, + total: totalCount, + }); + }, +}); + +// Expects a body of: { command: 'gimme', params: 'any string value', roomId: 'value' } +API.v1.addRoute('commands.run', { authRequired: true }, { + post() { + const body = this.bodyParams; + const user = this.getLoggedInUser(); + + if (typeof body.command !== 'string') { + return API.v1.failure('You must provide a command to run.'); + } + + if (body.params && typeof body.params !== 'string') { + return API.v1.failure('The parameters for the command must be a single string.'); + } + + if (typeof body.roomId !== 'string') { + return API.v1.failure('The room\'s id where to execute this command must be provided and be a string.'); + } + + const cmd = body.command.toLowerCase(); + if (!slashCommands.commands[body.command.toLowerCase()]) { + return API.v1.failure('The command provided does not exist (or is disabled).'); + } + + // This will throw an error if they can't or the room is invalid + Meteor.call('canAccessRoom', body.roomId, user._id); + + const params = body.params ? body.params : ''; + + let result; + Meteor.runAsUser(user._id, () => { + result = slashCommands.run(cmd, params, { + _id: Random.id(), + rid: body.roomId, + msg: `/${ cmd } ${ params }`, + }); + }); + + return API.v1.success({ result }); + }, +}); + +API.v1.addRoute('commands.preview', { authRequired: true }, { + // Expects these query params: command: 'giphy', params: 'mine', roomId: 'value' + get() { + const query = this.queryParams; + const user = this.getLoggedInUser(); + + if (typeof query.command !== 'string') { + return API.v1.failure('You must provide a command to get the previews from.'); + } + + if (query.params && typeof query.params !== 'string') { + return API.v1.failure('The parameters for the command must be a single string.'); + } + + if (typeof query.roomId !== 'string') { + return API.v1.failure('The room\'s id where the previews are being displayed must be provided and be a string.'); + } + + const cmd = query.command.toLowerCase(); + if (!slashCommands.commands[cmd]) { + return API.v1.failure('The command provided does not exist (or is disabled).'); + } + + // This will throw an error if they can't or the room is invalid + Meteor.call('canAccessRoom', query.roomId, user._id); + + const params = query.params ? query.params : ''; + + let preview; + Meteor.runAsUser(user._id, () => { + preview = Meteor.call('getSlashCommandPreviews', { cmd, params, msg: { rid: query.roomId } }); + }); + + return API.v1.success({ preview }); + }, + // Expects a body format of: { command: 'giphy', params: 'mine', roomId: 'value', previewItem: { id: 'sadf8' type: 'image', value: 'https://dev.null/gif } } + post() { + const body = this.bodyParams; + const user = this.getLoggedInUser(); + + if (typeof body.command !== 'string') { + return API.v1.failure('You must provide a command to run the preview item on.'); + } + + if (body.params && typeof body.params !== 'string') { + return API.v1.failure('The parameters for the command must be a single string.'); + } + + if (typeof body.roomId !== 'string') { + return API.v1.failure('The room\'s id where the preview is being executed in must be provided and be a string.'); + } + + if (typeof body.previewItem === 'undefined') { + return API.v1.failure('The preview item being executed must be provided.'); + } + + if (!body.previewItem.id || !body.previewItem.type || typeof body.previewItem.value === 'undefined') { + return API.v1.failure('The preview item being executed is in the wrong format.'); + } + + const cmd = body.command.toLowerCase(); + if (!slashCommands.commands[cmd]) { + return API.v1.failure('The command provided does not exist (or is disabled).'); + } + + // This will throw an error if they can't or the room is invalid + Meteor.call('canAccessRoom', body.roomId, user._id); + + const params = body.params ? body.params : ''; + + Meteor.runAsUser(user._id, () => { + Meteor.call('executeSlashCommandPreview', { cmd, params, msg: { rid: body.roomId } }, body.previewItem); + }); + + return API.v1.success(); + }, +}); diff --git a/app/api/server/v1/e2e.js b/app/api/server/v1/e2e.js new file mode 100644 index 000000000000..c4974add4774 --- /dev/null +++ b/app/api/server/v1/e2e.js @@ -0,0 +1,61 @@ +import { Meteor } from 'meteor/meteor'; +import { API } from '../api'; + +API.v1.addRoute('e2e.fetchMyKeys', { authRequired: true }, { + get() { + let result; + Meteor.runAsUser(this.userId, () => result = Meteor.call('e2e.fetchMyKeys')); + + return API.v1.success(result); + }, +}); + +API.v1.addRoute('e2e.getUsersOfRoomWithoutKey', { authRequired: true }, { + get() { + const { rid } = this.queryParams; + + let result; + Meteor.runAsUser(this.userId, () => result = Meteor.call('e2e.getUsersOfRoomWithoutKey', rid)); + + return API.v1.success(result); + }, +}); + +API.v1.addRoute('e2e.setRoomKeyID', { authRequired: true }, { + post() { + const { rid, keyID } = this.bodyParams; + + Meteor.runAsUser(this.userId, () => { + API.v1.success(Meteor.call('e2e.setRoomKeyID', rid, keyID)); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('e2e.setUserPublicAndPivateKeys', { authRequired: true }, { + post() { + const { public_key, private_key } = this.bodyParams; + + Meteor.runAsUser(this.userId, () => { + API.v1.success(Meteor.call('e2e.setUserPublicAndPivateKeys', { + public_key, + private_key, + })); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('e2e.updateGroupKey', { authRequired: true }, { + post() { + const { uid, rid, key } = this.bodyParams; + + Meteor.runAsUser(this.userId, () => { + API.v1.success(Meteor.call('e2e.updateGroupKey', rid, uid, key)); + }); + + return API.v1.success(); + }, +}); diff --git a/app/api/server/v1/emoji-custom.js b/app/api/server/v1/emoji-custom.js new file mode 100644 index 000000000000..79e54e62e479 --- /dev/null +++ b/app/api/server/v1/emoji-custom.js @@ -0,0 +1,159 @@ +import { Meteor } from 'meteor/meteor'; +import { EmojiCustom } from '../../../models'; +import { API } from '../api'; +import Busboy from 'busboy'; + +// DEPRECATED +// Will be removed after v1.12.0 +API.v1.addRoute('emoji-custom', { authRequired: true }, { + get() { + const warningMessage = 'The endpoint "emoji-custom" is deprecated and will be removed after version v1.12.0'; + console.warn(warningMessage); + const { query } = this.parseJsonQuery(); + const emojis = Meteor.call('listEmojiCustom', query); + + return API.v1.success(this.deprecationWarning({ + endpoint: 'emoji-custom', + versionWillBeRemoved: '1.12.0', + response: { + emojis, + }, + })); + }, +}); + +API.v1.addRoute('emoji-custom.list', { authRequired: true }, { + get() { + const { query } = this.parseJsonQuery(); + const { updatedSince } = this.queryParams; + let updatedSinceDate; + if (updatedSince) { + if (isNaN(Date.parse(updatedSince))) { + throw new Meteor.Error('error-roomId-param-invalid', 'The "updatedSince" query parameter must be a valid date.'); + } else { + updatedSinceDate = new Date(updatedSince); + } + return API.v1.success({ + emojis: { + update: EmojiCustom.find({ ...query, _updatedAt: { $gt: updatedSinceDate } }).fetch(), + remove: EmojiCustom.trashFindDeletedAfter(updatedSinceDate).fetch(), + }, + }); + } + + return API.v1.success({ + emojis: { + update: EmojiCustom.find(query).fetch(), + remove: [], + }, + }); + }, +}); + +API.v1.addRoute('emoji-custom.create', { authRequired: true }, { + post() { + Meteor.runAsUser(this.userId, () => { + const fields = {}; + const busboy = new Busboy({ headers: this.request.headers }); + const emojiData = []; + let emojiMimetype = ''; + + Meteor.wrapAsync((callback) => { + busboy.on('file', Meteor.bindEnvironment((fieldname, file, filename, encoding, mimetype) => { + if (fieldname !== 'emoji') { + return callback(new Meteor.Error('invalid-field')); + } + + file.on('data', Meteor.bindEnvironment((data) => emojiData.push(data))); + + file.on('end', Meteor.bindEnvironment(() => { + const extension = mimetype.split('/')[1]; + emojiMimetype = mimetype; + fields.extension = extension; + })); + })); + busboy.on('field', (fieldname, val) => { + fields[fieldname] = val; + }); + busboy.on('finish', Meteor.bindEnvironment(() => { + fields.newFile = true; + fields.aliases = fields.aliases || ''; + try { + Meteor.call('insertOrUpdateEmoji', fields); + Meteor.call('uploadEmojiCustom', Buffer.concat(emojiData), emojiMimetype, fields); + callback(); + } catch (error) { + return callback(error); + } + })); + this.request.pipe(busboy); + })(); + }); + }, +}); + +API.v1.addRoute('emoji-custom.update', { authRequired: true }, { + post() { + Meteor.runAsUser(this.userId, () => { + const fields = {}; + const busboy = new Busboy({ headers: this.request.headers }); + const emojiData = []; + let emojiMimetype = ''; + + Meteor.wrapAsync((callback) => { + busboy.on('file', Meteor.bindEnvironment((fieldname, file, filename, encoding, mimetype) => { + if (fieldname !== 'emoji') { + return callback(new Meteor.Error('invalid-field')); + } + file.on('data', Meteor.bindEnvironment((data) => emojiData.push(data))); + + file.on('end', Meteor.bindEnvironment(() => { + const extension = mimetype.split('/')[1]; + emojiMimetype = mimetype; + fields.extension = extension; + })); + })); + busboy.on('field', (fieldname, val) => { + fields[fieldname] = val; + }); + busboy.on('finish', Meteor.bindEnvironment(() => { + try { + if (!fields._id) { + return callback(new Meteor.Error('The required "_id" query param is missing.')); + } + const emojiToUpdate = EmojiCustom.findOneByID(fields._id); + if (!emojiToUpdate) { + return callback(new Meteor.Error('Emoji not found.')); + } + fields.previousName = emojiToUpdate.name; + fields.previousExtension = emojiToUpdate.extension; + fields.aliases = fields.aliases || ''; + fields.newFile = Boolean(emojiData.length); + Meteor.call('insertOrUpdateEmoji', fields); + if (emojiData.length) { + Meteor.call('uploadEmojiCustom', Buffer.concat(emojiData), emojiMimetype, fields); + } + callback(); + } catch (error) { + return callback(error); + } + })); + this.request.pipe(busboy); + })(); + + }); + }, +}); + +API.v1.addRoute('emoji-custom.delete', { authRequired: true }, { + post() { + const { emojiId } = this.bodyParams; + if (!emojiId) { + return API.v1.failure('The "emojiId" params is required!'); + } + + Meteor.runAsUser(this.userId, () => Meteor.call('deleteEmojiCustom', emojiId)); + + return API.v1.success(); + }, +}); diff --git a/app/api/server/v1/groups.js b/app/api/server/v1/groups.js new file mode 100644 index 000000000000..41a93d3fd9a8 --- /dev/null +++ b/app/api/server/v1/groups.js @@ -0,0 +1,818 @@ +import _ from 'underscore'; + +import { Meteor } from 'meteor/meteor'; + +import { Subscriptions, Rooms, Messages, Uploads, Integrations, Users } from '../../../models/server'; +import { hasPermission, canAccessRoom } from '../../../authorization/server'; +import { composeMessageObjectWithUser } from '../../../utils/server'; + +import { API } from '../api'; + +// Returns the private group subscription IF found otherwise it will return the failure of why it didn't. Check the `statusCode` property +function findPrivateGroupByIdOrName({ params, userId, checkedArchived = true }) { + if ((!params.roomId || !params.roomId.trim()) && (!params.roomName || !params.roomName.trim())) { + throw new Meteor.Error('error-room-param-not-provided', 'The parameter "roomId" or "roomName" is required'); + } + + const roomOptions = { + fields: { + t: 1, + ro: 1, + name: 1, + fname: 1, + prid: 1, + archived: 1, + }, + }; + const room = params.roomId ? + Rooms.findOneById(params.roomId, roomOptions) : + 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'); + } + + const user = Users.findOneById(userId, { fields: { username: 1 } }); + + if (!canAccessRoom(room, user)) { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } + + // discussions have their names saved on `fname` property + const roomName = room.prid ? room.fname : room.name; + + if (checkedArchived && room.archived) { + throw new Meteor.Error('error-room-archived', `The private group, ${ roomName }, is archived`); + } + + const sub = Subscriptions.findOneByRoomIdAndUserId(room._id, userId, { fields: { open: 1 } }); + + return { + rid: room._id, + open: sub && sub.open, + ro: room.ro, + t: room.t, + name: roomName, + }; +} + +API.v1.addRoute('groups.addAll', { authRequired: true }, { + post() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('addAllUserToRoom', findResult.rid, this.bodyParams.activeUsersOnly); + }); + + return API.v1.success({ + group: this.composeRoomWithLastMessage(Rooms.findOneById(findResult.rid, { fields: API.v1.defaultFieldsToExclude }), this.userId), + }); + }, +}); + +API.v1.addRoute('groups.addModerator', { authRequired: true }, { + post() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + + const user = this.getUserFromParams(); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('addRoomModerator', findResult.rid, user._id); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('groups.addOwner', { authRequired: true }, { + post() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + + const user = this.getUserFromParams(); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('addRoomOwner', findResult.rid, user._id); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('groups.addLeader', { authRequired: true }, { + post() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + const user = this.getUserFromParams(); + Meteor.runAsUser(this.userId, () => { + Meteor.call('addRoomLeader', findResult.rid, user._id); + }); + + return API.v1.success(); + }, +}); + +// Archives a private group only if it wasn't +API.v1.addRoute('groups.archive', { authRequired: true }, { + post() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('archiveRoom', findResult.rid); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('groups.close', { authRequired: true }, { + post() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId, checkedArchived: false }); + + if (!findResult.open) { + return API.v1.failure(`The private group, ${ findResult.name }, is already closed to the sender`); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('hideRoom', findResult.rid); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('groups.counters', { authRequired: true }, { + get() { + const access = hasPermission(this.userId, 'view-room-administration'); + const params = this.requestParams(); + let user = this.userId; + let room; + let unreads = null; + let userMentions = null; + let unreadsFrom = null; + let joined = false; + let msgs = null; + let latest = null; + let members = null; + + if ((!params.roomId || !params.roomId.trim()) && (!params.roomName || !params.roomName.trim())) { + throw new Meteor.Error('error-room-param-not-provided', 'The parameter "roomId" or "roomName" is required'); + } + + if (params.roomId) { + room = Rooms.findOneById(params.roomId); + } else if (params.roomName) { + room = Rooms.findOneByName(params.roomName); + } + + 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'); + } + + if (room.archived) { + throw new Meteor.Error('error-room-archived', `The private group, ${ room.name }, is archived`); + } + + if (params.userId) { + if (!access) { + return API.v1.unauthorized(); + } + user = params.userId; + } + const subscription = Subscriptions.findOneByRoomIdAndUserId(room._id, user); + const lm = room.lm ? room.lm : room._updatedAt; + + if (typeof subscription !== 'undefined' && subscription.open) { + unreads = Messages.countVisibleByRoomIdBetweenTimestampsInclusive(subscription.rid, (subscription.ls || subscription.ts), lm); + unreadsFrom = subscription.ls || subscription.ts; + userMentions = subscription.userMentions; + joined = true; + } + + if (access || joined) { + msgs = room.msgs; + latest = lm; + members = room.usersCount; + } + + return API.v1.success({ + joined, + members, + unreads, + unreadsFrom, + msgs, + latest, + userMentions, + }); + }, +}); + +// Create Private Group +API.v1.addRoute('groups.create', { authRequired: true }, { + post() { + if (!hasPermission(this.userId, 'create-p')) { + return API.v1.unauthorized(); + } + + if (!this.bodyParams.name) { + return API.v1.failure('Body param "name" is required'); + } + + if (this.bodyParams.members && !_.isArray(this.bodyParams.members)) { + return API.v1.failure('Body param "members" must be an array if provided'); + } + + if (this.bodyParams.customFields && !(typeof this.bodyParams.customFields === 'object')) { + return API.v1.failure('Body param "customFields" must be an object if provided'); + } + + const readOnly = typeof this.bodyParams.readOnly !== 'undefined' ? this.bodyParams.readOnly : false; + + let id; + Meteor.runAsUser(this.userId, () => { + id = Meteor.call('createPrivateGroup', this.bodyParams.name, this.bodyParams.members ? this.bodyParams.members : [], readOnly, this.bodyParams.customFields); + }); + + return API.v1.success({ + group: this.composeRoomWithLastMessage(Rooms.findOneById(id.rid, { fields: API.v1.defaultFieldsToExclude }), this.userId), + }); + }, +}); + +API.v1.addRoute('groups.delete', { authRequired: true }, { + post() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId, checkedArchived: false }); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('eraseRoom', findResult.rid); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('groups.files', { authRequired: true }, { + get() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId, checkedArchived: false }); + const addUserObjectToEveryObject = (file) => { + if (file.userId) { + file = this.insertUserObject({ object: file, userId: file.userId }); + } + return file; + }; + + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + const ourQuery = Object.assign({}, query, { rid: findResult.rid }); + + const files = Uploads.find(ourQuery, { + sort: sort ? sort : { name: 1 }, + skip: offset, + limit: count, + fields, + }).fetch(); + + return API.v1.success({ + files: files.map(addUserObjectToEveryObject), + count: files.length, + offset, + total: Uploads.find(ourQuery).count(), + }); + }, +}); + +API.v1.addRoute('groups.getIntegrations', { authRequired: true }, { + get() { + if (!hasPermission(this.userId, 'manage-integrations')) { + return API.v1.unauthorized(); + } + + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId, checkedArchived: false }); + + let includeAllPrivateGroups = true; + if (typeof this.queryParams.includeAllPrivateGroups !== 'undefined') { + includeAllPrivateGroups = this.queryParams.includeAllPrivateGroups === 'true'; + } + + const channelsToSearch = [`#${ findResult.name }`]; + if (includeAllPrivateGroups) { + channelsToSearch.push('all_private_groups'); + } + + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + const ourQuery = Object.assign({}, query, { channel: { $in: channelsToSearch } }); + const integrations = Integrations.find(ourQuery, { + sort: sort ? sort : { _createdAt: 1 }, + skip: offset, + limit: count, + fields, + }).fetch(); + + return API.v1.success({ + integrations, + count: integrations.length, + offset, + total: Integrations.find(ourQuery).count(), + }); + }, +}); + +API.v1.addRoute('groups.history', { authRequired: true }, { + get() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId, checkedArchived: false }); + + let latestDate = new Date(); + if (this.queryParams.latest) { + latestDate = new Date(this.queryParams.latest); + } + + let oldestDate = undefined; + if (this.queryParams.oldest) { + oldestDate = new Date(this.queryParams.oldest); + } + + const inclusive = this.queryParams.inclusive || false; + + let count = 20; + if (this.queryParams.count) { + count = parseInt(this.queryParams.count); + } + + let offset = 0; + if (this.queryParams.offset) { + offset = parseInt(this.queryParams.offset); + } + + const unreads = this.queryParams.unreads || false; + + let result; + Meteor.runAsUser(this.userId, () => { + result = Meteor.call('getChannelHistory', { rid: findResult.rid, latest: latestDate, oldest: oldestDate, inclusive, offset, count, unreads }); + }); + + if (!result) { + return API.v1.unauthorized(); + } + + return API.v1.success(result); + }, +}); + +API.v1.addRoute('groups.info', { authRequired: true }, { + get() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId, checkedArchived: false }); + + return API.v1.success({ + group: this.composeRoomWithLastMessage(Rooms.findOneById(findResult.rid, { fields: API.v1.defaultFieldsToExclude }), this.userId), + }); + }, +}); + +API.v1.addRoute('groups.invite', { authRequired: true }, { + post() { + const { roomId = '', roomName = '' } = this.requestParams(); + const idOrName = roomId || roomName; + if (!idOrName.trim()) { + throw new Meteor.Error('error-room-param-not-provided', 'The parameter "roomId" or "roomName" is required'); + } + + const { _id: rid, t: type } = Rooms.findOneByIdOrName(idOrName) || {}; + + if (!rid || type !== 'p') { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } + + const { username } = this.getUserFromParams(); + + Meteor.runAsUser(this.userId, () => Meteor.call('addUserToRoom', { rid, username })); + + return API.v1.success({ + group: this.composeRoomWithLastMessage(Rooms.findOneById(rid, { fields: API.v1.defaultFieldsToExclude }), this.userId), + }); + }, +}); + +API.v1.addRoute('groups.kick', { authRequired: true }, { + post() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + + const user = this.getUserFromParams(); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('removeUserFromRoom', { rid: findResult.rid, username: user.username }); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('groups.leave', { authRequired: true }, { + post() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('leaveRoom', findResult.rid); + }); + + return API.v1.success(); + }, +}); + +// List Private Groups a user has access to +API.v1.addRoute('groups.list', { authRequired: true }, { + get() { + const { offset, count } = this.getPaginationItems(); + const { sort, fields } = this.parseJsonQuery(); + + // TODO: CACHE: Add Breacking notice since we removed the query param + const cursor = Rooms.findBySubscriptionTypeAndUserId('p', this.userId, { + sort: sort ? sort : { name: 1 }, + skip: offset, + limit: count, + fields, + }); + + const totalCount = cursor.count(); + const rooms = cursor.fetch(); + + + return API.v1.success({ + groups: rooms.map((room) => this.composeRoomWithLastMessage(room, this.userId)), + offset, + count: rooms.length, + total: totalCount, + }); + }, +}); + + +API.v1.addRoute('groups.listAll', { authRequired: true }, { + get() { + if (!hasPermission(this.userId, 'view-room-administration')) { + return API.v1.unauthorized(); + } + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + const ourQuery = Object.assign({}, query, { t: 'p' }); + + let rooms = Rooms.find(ourQuery).fetch(); + const totalCount = rooms.length; + + rooms = Rooms.processQueryOptionsOnResult(rooms, { + sort: sort ? sort : { name: 1 }, + skip: offset, + limit: count, + fields, + }); + + return API.v1.success({ + groups: rooms.map((room) => this.composeRoomWithLastMessage(room, this.userId)), + offset, + count: rooms.length, + total: totalCount, + }); + }, +}); + +API.v1.addRoute('groups.members', { authRequired: true }, { + get() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + const room = Rooms.findOneById(findResult.rid, { fields: { broadcast: 1 } }); + + if (room.broadcast && !hasPermission(this.userId, 'view-broadcast-member-list')) { + return API.v1.unauthorized(); + } + + const { offset, count } = this.getPaginationItems(); + const { sort = {} } = this.parseJsonQuery(); + + const subscriptions = Subscriptions.findByRoomId(findResult.rid, { + fields: { 'u._id': 1 }, + sort: { 'u.username': sort.username != null ? sort.username : 1 }, + skip: offset, + limit: count, + }); + + const total = subscriptions.count(); + + const members = subscriptions.fetch().map((s) => s.u && s.u._id); + + const users = Users.find({ _id: { $in: members } }, { + fields: { _id: 1, username: 1, name: 1, status: 1, utcOffset: 1 }, + sort: { username: sort.username != null ? sort.username : 1 }, + }).fetch(); + + return API.v1.success({ + members: users, + count: users.length, + offset, + total, + }); + }, +}); + +API.v1.addRoute('groups.messages', { authRequired: true }, { + get() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + const ourQuery = Object.assign({}, query, { rid: findResult.rid }); + + const messages = Messages.find(ourQuery, { + sort: sort ? sort : { ts: -1 }, + skip: offset, + limit: count, + fields, + }).fetch(); + + return API.v1.success({ + messages: messages.map((message) => composeMessageObjectWithUser(message, this.userId)), + count: messages.length, + offset, + total: Messages.find(ourQuery).count(), + }); + }, +}); +// TODO: CACHE: same as channels.online +API.v1.addRoute('groups.online', { authRequired: true }, { + get() { + const { query } = this.parseJsonQuery(); + const ourQuery = Object.assign({}, query, { t: 'p' }); + + const room = Rooms.findOne(ourQuery); + + if (room == null) { + return API.v1.failure('Group does not exists'); + } + + const online = Users.findUsersNotOffline({ + fields: { + username: 1, + }, + }).fetch(); + + const onlineInRoom = []; + online.forEach((user) => { + const subscription = Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { fields: { _id: 1 } }); + if (subscription) { + onlineInRoom.push({ + _id: user._id, + username: user.username, + }); + } + }); + + return API.v1.success({ + online: onlineInRoom, + }); + }, +}); + +API.v1.addRoute('groups.open', { authRequired: true }, { + post() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId, checkedArchived: false }); + + if (findResult.open) { + return API.v1.failure(`The private group, ${ findResult.name }, is already open for the sender`); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('openRoom', findResult.rid); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('groups.removeModerator', { authRequired: true }, { + post() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + + const user = this.getUserFromParams(); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('removeRoomModerator', findResult.rid, user._id); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('groups.removeOwner', { authRequired: true }, { + post() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + + const user = this.getUserFromParams(); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('removeRoomOwner', findResult.rid, user._id); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('groups.removeLeader', { authRequired: true }, { + post() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + + const user = this.getUserFromParams(); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('removeRoomLeader', findResult.rid, user._id); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('groups.rename', { authRequired: true }, { + post() { + if (!this.bodyParams.name || !this.bodyParams.name.trim()) { + return API.v1.failure('The bodyParam "name" is required'); + } + + const findResult = findPrivateGroupByIdOrName({ params: { roomId: this.bodyParams.roomId }, userId: this.userId }); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('saveRoomSettings', findResult.rid, 'roomName', this.bodyParams.name); + }); + + return API.v1.success({ + group: this.composeRoomWithLastMessage(Rooms.findOneById(findResult.rid, { fields: API.v1.defaultFieldsToExclude }), this.userId), + }); + }, +}); + +API.v1.addRoute('groups.setCustomFields', { authRequired: true }, { + post() { + if (!this.bodyParams.customFields || !(typeof this.bodyParams.customFields === 'object')) { + return API.v1.failure('The bodyParam "customFields" is required with a type like object.'); + } + + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('saveRoomSettings', findResult.rid, 'roomCustomFields', this.bodyParams.customFields); + }); + + return API.v1.success({ + group: this.composeRoomWithLastMessage(Rooms.findOneById(findResult.rid, { fields: API.v1.defaultFieldsToExclude }), this.userId), + }); + }, +}); + +API.v1.addRoute('groups.setDescription', { authRequired: true }, { + post() { + if (!this.bodyParams.description || !this.bodyParams.description.trim()) { + return API.v1.failure('The bodyParam "description" is required'); + } + + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('saveRoomSettings', findResult.rid, 'roomDescription', this.bodyParams.description); + }); + + return API.v1.success({ + description: this.bodyParams.description, + }); + }, +}); + +API.v1.addRoute('groups.setPurpose', { authRequired: true }, { + post() { + if (!this.bodyParams.purpose || !this.bodyParams.purpose.trim()) { + return API.v1.failure('The bodyParam "purpose" is required'); + } + + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('saveRoomSettings', findResult.rid, 'roomDescription', this.bodyParams.purpose); + }); + + return API.v1.success({ + purpose: this.bodyParams.purpose, + }); + }, +}); + +API.v1.addRoute('groups.setReadOnly', { authRequired: true }, { + post() { + if (typeof this.bodyParams.readOnly === 'undefined') { + return API.v1.failure('The bodyParam "readOnly" is required'); + } + + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + + if (findResult.ro === this.bodyParams.readOnly) { + return API.v1.failure('The private group read only setting is the same as what it would be changed to.'); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('saveRoomSettings', findResult.rid, 'readOnly', this.bodyParams.readOnly); + }); + + return API.v1.success({ + group: this.composeRoomWithLastMessage(Rooms.findOneById(findResult.rid, { fields: API.v1.defaultFieldsToExclude }), this.userId), + }); + }, +}); + +API.v1.addRoute('groups.setTopic', { authRequired: true }, { + post() { + if (!this.bodyParams.topic || !this.bodyParams.topic.trim()) { + return API.v1.failure('The bodyParam "topic" is required'); + } + + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('saveRoomSettings', findResult.rid, 'roomTopic', this.bodyParams.topic); + }); + + return API.v1.success({ + topic: this.bodyParams.topic, + }); + }, +}); + +API.v1.addRoute('groups.setType', { authRequired: true }, { + post() { + if (!this.bodyParams.type || !this.bodyParams.type.trim()) { + return API.v1.failure('The bodyParam "type" is required'); + } + + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + + if (findResult.t === this.bodyParams.type) { + return API.v1.failure('The private group type is the same as what it would be changed to.'); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('saveRoomSettings', findResult.rid, 'roomType', this.bodyParams.type); + }); + + return API.v1.success({ + group: this.composeRoomWithLastMessage(Rooms.findOneById(findResult.rid, { fields: API.v1.defaultFieldsToExclude }), this.userId), + }); + }, +}); + +API.v1.addRoute('groups.setAnnouncement', { authRequired: true }, { + post() { + if (!this.bodyParams.announcement || !this.bodyParams.announcement.trim()) { + return API.v1.failure('The bodyParam "announcement" is required'); + } + + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('saveRoomSettings', findResult.rid, 'roomAnnouncement', this.bodyParams.announcement); + }); + + return API.v1.success({ + announcement: this.bodyParams.announcement, + }); + }, +}); + +API.v1.addRoute('groups.unarchive', { authRequired: true }, { + post() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId, checkedArchived: false }); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('unarchiveRoom', findResult.rid); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('groups.roles', { authRequired: true }, { + get() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + + const roles = Meteor.runAsUser(this.userId, () => Meteor.call('getRoomRoles', findResult.rid)); + + return API.v1.success({ + roles, + }); + }, +}); + +API.v1.addRoute('groups.moderators', { authRequired: true }, { + get() { + const findResult = findPrivateGroupByIdOrName({ params: this.requestParams(), userId: this.userId }); + + const moderators = Subscriptions.findByRoomIdAndRoles(findResult.rid, ['moderator'], { fields: { u: 1 } }).fetch().map((sub) => sub.u); + + return API.v1.success({ + moderators, + }); + }, +}); + diff --git a/app/api/server/v1/im.js b/app/api/server/v1/im.js new file mode 100644 index 000000000000..20f2f4fd56d2 --- /dev/null +++ b/app/api/server/v1/im.js @@ -0,0 +1,368 @@ +import { Meteor } from 'meteor/meteor'; +import { getRoomByNameOrIdWithOptionToJoin } from '../../../lib'; +import { Subscriptions, Uploads, Users, Messages, Rooms } from '../../../models'; +import { hasPermission } from '../../../authorization'; +import { composeMessageObjectWithUser } from '../../../utils'; +import { settings } from '../../../settings'; +import { API } from '../api'; + +function findDirectMessageRoom(params, user) { + if ((!params.roomId || !params.roomId.trim()) && (!params.username || !params.username.trim())) { + throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" or "username" is required'); + } + + const room = getRoomByNameOrIdWithOptionToJoin({ + currentUserId: user._id, + nameOrId: params.username || params.roomId, + type: 'd', + }); + + const canAccess = Meteor.call('canAccessRoom', room._id, user._id); + if (!canAccess || !room || room.t !== 'd') { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "username" param provided does not match any dirct message'); + } + + const subscription = Subscriptions.findOneByRoomIdAndUserId(room._id, user._id); + + return { + room, + subscription, + }; +} + +API.v1.addRoute(['dm.create', 'im.create'], { authRequired: true }, { + post() { + const findResult = findDirectMessageRoom(this.requestParams(), this.user); + + return API.v1.success({ + room: findResult.room, + }); + }, +}); + +API.v1.addRoute(['dm.close', 'im.close'], { authRequired: true }, { + post() { + const findResult = findDirectMessageRoom(this.requestParams(), this.user); + + if (!findResult.subscription.open) { + return API.v1.failure(`The direct message room, ${ this.bodyParams.name }, is already closed to the sender`); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('hideRoom', findResult.room._id); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute(['dm.counters', 'im.counters'], { authRequired: true }, { + get() { + const access = hasPermission(this.userId, 'view-room-administration'); + const ruserId = this.requestParams().userId; + let user = this.userId; + let unreads = null; + let userMentions = null; + let unreadsFrom = null; + let joined = false; + let msgs = null; + let latest = null; + let members = null; + let lm = null; + + if (ruserId) { + if (!access) { + return API.v1.unauthorized(); + } + user = ruserId; + } + const rs = findDirectMessageRoom(this.requestParams(), { _id: user }); + const { room } = rs; + const dm = rs.subscription; + lm = room.lm ? room.lm : room._updatedAt; + + if (typeof dm !== 'undefined' && dm.open) { + if (dm.ls && room.msgs) { + unreads = dm.unread; + unreadsFrom = dm.ls; + } + userMentions = dm.userMentions; + joined = true; + } + + if (access || joined) { + msgs = room.msgs; + latest = lm; + members = room.usersCount; + } + + return API.v1.success({ + joined, + members, + unreads, + unreadsFrom, + msgs, + latest, + userMentions, + }); + }, +}); + +API.v1.addRoute(['dm.files', 'im.files'], { authRequired: true }, { + get() { + const findResult = findDirectMessageRoom(this.requestParams(), this.user); + const addUserObjectToEveryObject = (file) => { + if (file.userId) { + file = this.insertUserObject({ object: file, userId: file.userId }); + } + return file; + }; + + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + const ourQuery = Object.assign({}, query, { rid: findResult.room._id }); + + const files = Uploads.find(ourQuery, { + sort: sort ? sort : { name: 1 }, + skip: offset, + limit: count, + fields, + }).fetch(); + + return API.v1.success({ + files: files.map(addUserObjectToEveryObject), + count: files.length, + offset, + total: Uploads.find(ourQuery).count(), + }); + }, +}); + +API.v1.addRoute(['dm.history', 'im.history'], { authRequired: true }, { + get() { + const findResult = findDirectMessageRoom(this.requestParams(), this.user); + + let latestDate = new Date(); + if (this.queryParams.latest) { + latestDate = new Date(this.queryParams.latest); + } + + let oldestDate = undefined; + if (this.queryParams.oldest) { + oldestDate = new Date(this.queryParams.oldest); + } + + const inclusive = this.queryParams.inclusive || false; + + let count = 20; + if (this.queryParams.count) { + count = parseInt(this.queryParams.count); + } + + let offset = 0; + if (this.queryParams.offset) { + offset = parseInt(this.queryParams.offset); + } + + const unreads = this.queryParams.unreads || false; + + let result; + Meteor.runAsUser(this.userId, () => { + result = Meteor.call('getChannelHistory', { + rid: findResult.room._id, + latest: latestDate, + oldest: oldestDate, + inclusive, + offset, + count, + unreads, + }); + }); + + if (!result) { + return API.v1.unauthorized(); + } + + return API.v1.success(result); + }, +}); + +API.v1.addRoute(['dm.members', 'im.members'], { authRequired: true }, { + get() { + const findResult = findDirectMessageRoom(this.requestParams(), this.user); + + const { offset, count } = this.getPaginationItems(); + const { sort } = this.parseJsonQuery(); + const cursor = Subscriptions.findByRoomId(findResult.room._id, { + sort: { 'u.username': sort && sort.username ? sort.username : 1 }, + skip: offset, + limit: count, + }); + + const total = cursor.count(); + const members = cursor.fetch().map((s) => s.u && s.u.username); + + const users = Users.find({ username: { $in: members } }, { + fields: { _id: 1, username: 1, name: 1, status: 1, utcOffset: 1 }, + sort: { username: sort && sort.username ? sort.username : 1 }, + }).fetch(); + + return API.v1.success({ + members: users, + count: members.length, + offset, + total, + }); + }, +}); + +API.v1.addRoute(['dm.messages', 'im.messages'], { authRequired: true }, { + get() { + const findResult = findDirectMessageRoom(this.requestParams(), this.user); + + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + const ourQuery = Object.assign({}, query, { rid: findResult.room._id }); + + const messages = Messages.find(ourQuery, { + sort: sort ? sort : { ts: -1 }, + skip: offset, + limit: count, + fields, + }).fetch(); + + return API.v1.success({ + messages: messages.map((message) => composeMessageObjectWithUser(message, this.userId)), + count: messages.length, + offset, + total: Messages.find(ourQuery).count(), + }); + }, +}); + +API.v1.addRoute(['dm.messages.others', 'im.messages.others'], { authRequired: true }, { + get() { + if (settings.get('API_Enable_Direct_Message_History_EndPoint') !== true) { + throw new Meteor.Error('error-endpoint-disabled', 'This endpoint is disabled', { route: '/api/v1/im.messages.others' }); + } + + if (!hasPermission(this.userId, 'view-room-administration')) { + return API.v1.unauthorized(); + } + + const { roomId } = this.queryParams; + if (!roomId || !roomId.trim()) { + throw new Meteor.Error('error-roomid-param-not-provided', 'The parameter "roomId" is required'); + } + + const room = Rooms.findOneById(roomId); + if (!room || room.t !== 'd') { + throw new Meteor.Error('error-room-not-found', `No direct message room found by the id of: ${ roomId }`); + } + + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + const ourQuery = Object.assign({}, query, { rid: room._id }); + + const msgs = Messages.find(ourQuery, { + sort: sort ? sort : { ts: -1 }, + skip: offset, + limit: count, + fields, + }).fetch(); + + return API.v1.success({ + messages: msgs.map((message) => composeMessageObjectWithUser(message, this.userId)), + offset, + count: msgs.length, + total: Messages.find(ourQuery).count(), + }); + }, +}); + +API.v1.addRoute(['dm.list', 'im.list'], { authRequired: true }, { + get() { + const { offset, count } = this.getPaginationItems(); + const { sort = { name: 1 }, fields } = this.parseJsonQuery(); + + // TODO: CACHE: Add Breacking notice since we removed the query param + + const cursor = Rooms.findBySubscriptionTypeAndUserId('d', this.userId, { + sort, + skip: offset, + limit: count, + fields, + }); + + const total = cursor.count(); + const rooms = cursor.fetch(); + + return API.v1.success({ + ims: rooms.map((room) => this.composeRoomWithLastMessage(room, this.userId)), + offset, + count: rooms.length, + total, + }); + }, +}); + +API.v1.addRoute(['dm.list.everyone', 'im.list.everyone'], { authRequired: true }, { + get() { + if (!hasPermission(this.userId, 'view-room-administration')) { + return API.v1.unauthorized(); + } + + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + const ourQuery = Object.assign({}, query, { t: 'd' }); + + const rooms = Rooms.find(ourQuery, { + sort: sort ? sort : { name: 1 }, + skip: offset, + limit: count, + fields, + }).fetch(); + + return API.v1.success({ + ims: rooms.map((room) => this.composeRoomWithLastMessage(room, this.userId)), + offset, + count: rooms.length, + total: Rooms.find(ourQuery).count(), + }); + }, +}); + +API.v1.addRoute(['dm.open', 'im.open'], { authRequired: true }, { + post() { + const findResult = findDirectMessageRoom(this.requestParams(), this.user); + + if (!findResult.subscription.open) { + Meteor.runAsUser(this.userId, () => { + Meteor.call('openRoom', findResult.room._id); + }); + } + + return API.v1.success(); + }, +}); + +API.v1.addRoute(['dm.setTopic', 'im.setTopic'], { authRequired: true }, { + post() { + if (!this.bodyParams.topic || !this.bodyParams.topic.trim()) { + return API.v1.failure('The bodyParam "topic" is required'); + } + + const findResult = findDirectMessageRoom(this.requestParams(), this.user); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('saveRoomSettings', findResult.room._id, 'roomTopic', this.bodyParams.topic); + }); + + return API.v1.success({ + topic: this.bodyParams.topic, + }); + }, +}); diff --git a/app/api/server/v1/import.js b/app/api/server/v1/import.js new file mode 100644 index 000000000000..a68788a40a55 --- /dev/null +++ b/app/api/server/v1/import.js @@ -0,0 +1,50 @@ +import { Meteor } from 'meteor/meteor'; +import { API } from '../api'; + +API.v1.addRoute('uploadImportFile', { authRequired: true }, { + post() { + const { binaryContent, contentType, fileName, importerKey } = this.bodyParams; + + Meteor.runAsUser(this.userId, () => { + API.v1.success(Meteor.call('uploadImportFile', binaryContent, contentType, fileName, importerKey)); + }); + + return API.v1.success(); + }, + +}); + +API.v1.addRoute('downloadPublicImportFile', { authRequired: true }, { + post() { + const { fileUrl, importerKey } = this.bodyParams; + + Meteor.runAsUser(this.userId, () => { + API.v1.success(Meteor.call('downloadPublicImportFile', fileUrl, importerKey)); + }); + + return API.v1.success(); + }, + +}); + +API.v1.addRoute('getImportFileData', { authRequired: true }, { + get() { + const { importerKey } = this.requestParams(); + let result; + Meteor.runAsUser(this.userId, () => { + result = Meteor.call('getImportFileData', importerKey); + }); + + return API.v1.success(result); + }, + +}); + +API.v1.addRoute('getLatestImportOperations', { authRequired: true }, { + get() { + let result; + Meteor.runAsUser(this.userId, () => result = Meteor.call('getLatestImportOperations')); + + return API.v1.success(result); + }, +}); diff --git a/app/api/server/v1/integrations.js b/app/api/server/v1/integrations.js new file mode 100644 index 000000000000..a7e9fff127f2 --- /dev/null +++ b/app/api/server/v1/integrations.js @@ -0,0 +1,155 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; +import { hasPermission } from '../../../authorization'; +import { IntegrationHistory, Integrations } from '../../../models'; +import { API } from '../api'; + +API.v1.addRoute('integrations.create', { authRequired: true }, { + post() { + check(this.bodyParams, Match.ObjectIncluding({ + type: String, + name: String, + enabled: Boolean, + username: String, + urls: Match.Maybe([String]), + channel: String, + event: Match.Maybe(String), + triggerWords: Match.Maybe([String]), + alias: Match.Maybe(String), + avatar: Match.Maybe(String), + emoji: Match.Maybe(String), + token: Match.Maybe(String), + scriptEnabled: Boolean, + script: Match.Maybe(String), + targetChannel: Match.Maybe(String), + })); + + let integration; + + switch (this.bodyParams.type) { + case 'webhook-outgoing': + Meteor.runAsUser(this.userId, () => { + integration = Meteor.call('addOutgoingIntegration', this.bodyParams); + }); + break; + case 'webhook-incoming': + Meteor.runAsUser(this.userId, () => { + integration = Meteor.call('addIncomingIntegration', this.bodyParams); + }); + break; + default: + return API.v1.failure('Invalid integration type.'); + } + + return API.v1.success({ integration }); + }, +}); + +API.v1.addRoute('integrations.history', { authRequired: true }, { + get() { + if (!hasPermission(this.userId, 'manage-integrations')) { + return API.v1.unauthorized(); + } + + if (!this.queryParams.id || this.queryParams.id.trim() === '') { + return API.v1.failure('Invalid integration id.'); + } + + const { id } = this.queryParams; + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + const ourQuery = Object.assign({}, query, { 'integration._id': id }); + const history = IntegrationHistory.find(ourQuery, { + sort: sort ? sort : { _updatedAt: -1 }, + skip: offset, + limit: count, + fields, + }).fetch(); + + return API.v1.success({ + history, + offset, + items: history.length, + total: IntegrationHistory.find(ourQuery).count(), + }); + }, +}); + +API.v1.addRoute('integrations.list', { authRequired: true }, { + get() { + if (!hasPermission(this.userId, 'manage-integrations')) { + return API.v1.unauthorized(); + } + + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + const ourQuery = Object.assign({}, query); + const integrations = Integrations.find(ourQuery, { + sort: sort ? sort : { ts: -1 }, + skip: offset, + limit: count, + fields, + }).fetch(); + + return API.v1.success({ + integrations, + offset, + items: integrations.length, + total: Integrations.find(ourQuery).count(), + }); + }, +}); + +API.v1.addRoute('integrations.remove', { authRequired: true }, { + post() { + check(this.bodyParams, Match.ObjectIncluding({ + type: String, + target_url: Match.Maybe(String), + integrationId: Match.Maybe(String), + })); + + if (!this.bodyParams.target_url && !this.bodyParams.integrationId) { + return API.v1.failure('An integrationId or target_url needs to be provided.'); + } + + let integration; + switch (this.bodyParams.type) { + case 'webhook-outgoing': + if (this.bodyParams.target_url) { + integration = Integrations.findOne({ urls: this.bodyParams.target_url }); + } else if (this.bodyParams.integrationId) { + integration = Integrations.findOne({ _id: this.bodyParams.integrationId }); + } + + if (!integration) { + return API.v1.failure('No integration found.'); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('deleteOutgoingIntegration', integration._id); + }); + + return API.v1.success({ + integration, + }); + case 'webhook-incoming': + integration = Integrations.findOne({ _id: this.bodyParams.integrationId }); + + if (!integration) { + return API.v1.failure('No integration found.'); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('deleteIncomingIntegration', integration._id); + }); + + return API.v1.success({ + integration, + }); + default: + return API.v1.failure('Invalid integration type.'); + } + }, +}); diff --git a/app/api/server/v1/misc.js b/app/api/server/v1/misc.js new file mode 100644 index 000000000000..8f792eeb6c84 --- /dev/null +++ b/app/api/server/v1/misc.js @@ -0,0 +1,202 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { TAPi18n } from 'meteor/tap:i18n'; +import { hasRole } from '../../../authorization'; +import { Info } from '../../../utils'; +import { Users } from '../../../models'; +import { settings } from '../../../settings'; +import { API } from '../api'; + +import s from 'underscore.string'; + +// DEPRECATED +// Will be removed after v1.12.0 +API.v1.addRoute('info', { authRequired: false }, { + get() { + const warningMessage = 'The endpoint "/v1/info" is deprecated and will be removed after version v1.12.0'; + console.warn(warningMessage); + const user = this.getLoggedInUser(); + + if (user && hasRole(user._id, 'admin')) { + return API.v1.success(this.deprecationWarning({ + endpoint: 'info', + versionWillBeRemoved: '1.12.0', + response: { + info: Info, + }, + })); + } + + return API.v1.success(this.deprecationWarning({ + endpoint: 'info', + versionWillBeRemoved: '1.12.0', + response: { + info: { + version: Info.version, + }, + }, + })); + }, +}); + +API.v1.addRoute('me', { authRequired: true }, { + get() { + return API.v1.success(this.getUserInfo(Users.findOneById(this.userId))); + }, +}); + +let onlineCache = 0; +let onlineCacheDate = 0; +const cacheInvalid = 60000; // 1 minute +API.v1.addRoute('shield.svg', { authRequired: false }, { + get() { + const { type, icon } = this.queryParams; + let { channel, name } = this.queryParams; + if (!settings.get('API_Enable_Shields')) { + throw new Meteor.Error('error-endpoint-disabled', 'This endpoint is disabled', { route: '/api/v1/shield.svg' }); + } + + const types = settings.get('API_Shield_Types'); + if (type && (types !== '*' && !types.split(',').map((t) => t.trim()).includes(type))) { + throw new Meteor.Error('error-shield-disabled', 'This shield type is disabled', { route: '/api/v1/shield.svg' }); + } + + const hideIcon = icon === 'false'; + if (hideIcon && (!name || !name.trim())) { + return API.v1.failure('Name cannot be empty when icon is hidden'); + } + + let text; + let backgroundColor = '#4c1'; + switch (type) { + case 'online': + if (Date.now() - onlineCacheDate > cacheInvalid) { + onlineCache = Users.findUsersNotOffline().count(); + onlineCacheDate = Date.now(); + } + + text = `${ onlineCache } ${ TAPi18n.__('Online') }`; + break; + case 'channel': + if (!channel) { + return API.v1.failure('Shield channel is required for type "channel"'); + } + + text = `#${ channel }`; + break; + case 'user': + const user = this.getUserFromParams(); + + // Respect the server's choice for using their real names or not + if (user.name && settings.get('UI_Use_Real_Name')) { + text = `${ user.name }`; + } else { + text = `@${ user.username }`; + } + + switch (user.status) { + case 'online': + backgroundColor = '#1fb31f'; + break; + case 'away': + backgroundColor = '#dc9b01'; + break; + case 'busy': + backgroundColor = '#bc2031'; + break; + case 'offline': + backgroundColor = '#a5a1a1'; + } + break; + default: + text = TAPi18n.__('Join_Chat').toUpperCase(); + } + + const iconSize = hideIcon ? 7 : 24; + const leftSize = name ? name.length * 6 + 7 + iconSize : iconSize; + const rightSize = text.length * 6 + 20; + const width = leftSize + rightSize; + const height = 20; + + channel = s.escapeHTML(channel); + text = s.escapeHTML(text); + name = s.escapeHTML(name); + + return { + headers: { 'Content-Type': 'image/svg+xml;charset=utf-8' }, + body: ` + + + + + + + + + + + + + + ${ hideIcon ? '' : '' } + + ${ name ? `${ name } + ${ name }` : '' } + ${ text } + ${ text } + + + `.trim().replace(/\>[\s]+\<'), + }; + }, +}); + +API.v1.addRoute('spotlight', { authRequired: true }, { + get() { + check(this.queryParams, { + query: String, + }); + + const { query } = this.queryParams; + + const result = Meteor.runAsUser(this.userId, () => + Meteor.call('spotlight', query) + ); + + return API.v1.success(result); + }, +}); + +API.v1.addRoute('directory', { authRequired: true }, { + get() { + const { offset, count } = this.getPaginationItems(); + const { sort, query } = this.parseJsonQuery(); + + const { text, type, workspace = 'local' } = query; + if (sort && Object.keys(sort).length > 1) { + return API.v1.failure('This method support only one "sort" parameter'); + } + const sortBy = sort ? Object.keys(sort)[0] : undefined; + const sortDirection = sort && Object.values(sort)[0] === 1 ? 'asc' : 'desc'; + + const result = Meteor.runAsUser(this.userId, () => Meteor.call('browseChannels', { + text, + type, + workspace, + sortBy, + sortDirection, + offset: Math.max(0, offset), + limit: Math.max(0, count), + })); + + if (!result) { + return API.v1.failure('Please verify the parameters'); + } + return API.v1.success({ + result: result.results, + count: result.results.length, + offset, + total: result.total, + }); + }, +}); diff --git a/app/api/server/v1/permissions.js b/app/api/server/v1/permissions.js new file mode 100644 index 000000000000..e0d0d55f3f58 --- /dev/null +++ b/app/api/server/v1/permissions.js @@ -0,0 +1,119 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; +import { hasPermission } from '../../../authorization'; +import { Permissions, Roles } from '../../../models'; +import { API } from '../api'; + +/** + This API returns all permissions that exists + on the server, with respective roles. + + Method: GET + Route: api/v1/permissions + */ +API.v1.addRoute('permissions', { authRequired: true }, { + get() { + const warningMessage = 'The endpoint "permissions" is deprecated and will be removed after version v0.69'; + console.warn(warningMessage); + + const result = Meteor.runAsUser(this.userId, () => Meteor.call('permissions/get')); + + return API.v1.success(result); + }, +}); + +// DEPRECATED +// TODO: Remove this after three versions have been released. That means at 0.85 this should be gone. +API.v1.addRoute('permissions.list', { authRequired: true }, { + get() { + const result = Meteor.runAsUser(this.userId, () => Meteor.call('permissions/get')); + + return API.v1.success(this.deprecationWarning({ + endpoint: 'permissions.list', + versionWillBeRemoved: '0.85', + response: { + permissions: result, + }, + })); + }, +}); + +API.v1.addRoute('permissions.listAll', { authRequired: true }, { + get() { + const { updatedSince } = this.queryParams; + + let updatedSinceDate; + if (updatedSince) { + if (isNaN(Date.parse(updatedSince))) { + throw new Meteor.Error('error-roomId-param-invalid', 'The "updatedSince" query parameter must be a valid date.'); + } else { + updatedSinceDate = new Date(updatedSince); + } + } + + let result; + Meteor.runAsUser(this.userId, () => result = Meteor.call('permissions/get', updatedSinceDate)); + + if (Array.isArray(result)) { + result = { + update: result, + remove: [], + }; + } + + return API.v1.success(result); + }, +}); + +API.v1.addRoute('permissions.update', { authRequired: true }, { + post() { + if (!hasPermission(this.userId, 'access-permissions')) { + return API.v1.failure('Editing permissions is not allowed', 'error-edit-permissions-not-allowed'); + } + + check(this.bodyParams, { + permissions: [ + Match.ObjectIncluding({ + _id: String, + roles: [String], + }), + ], + }); + + let permissionNotFound = false; + let roleNotFound = false; + Object.keys(this.bodyParams.permissions).forEach((key) => { + const element = this.bodyParams.permissions[key]; + + if (!Permissions.findOneById(element._id)) { + permissionNotFound = true; + } + + Object.keys(element.roles).forEach((key) => { + const subelement = element.roles[key]; + + if (!Roles.findOneById(subelement)) { + roleNotFound = true; + } + }); + }); + + if (permissionNotFound) { + return API.v1.failure('Invalid permission', 'error-invalid-permission'); + } else if (roleNotFound) { + return API.v1.failure('Invalid role', 'error-invalid-role'); + } + + Object.keys(this.bodyParams.permissions).forEach((key) => { + const element = this.bodyParams.permissions[key]; + + Permissions.createOrUpdate(element._id, element.roles); + }); + + const result = Meteor.runAsUser(this.userId, () => Meteor.call('permissions/get')); + + return API.v1.success({ + permissions: result, + }); + }, +}); diff --git a/packages/rocketchat-api/server/v1/push.js b/app/api/server/v1/push.js similarity index 86% rename from packages/rocketchat-api/server/v1/push.js rename to app/api/server/v1/push.js index 50b46f79e62a..fa5b2a70f652 100644 --- a/packages/rocketchat-api/server/v1/push.js +++ b/app/api/server/v1/push.js @@ -1,9 +1,9 @@ import { Meteor } from 'meteor/meteor'; import { Random } from 'meteor/random'; -import { RocketChat } from 'meteor/rocketchat:lib'; import { Push } from 'meteor/rocketchat:push'; +import { API } from '../api'; -RocketChat.API.v1.addRoute('push.token', { authRequired: true }, { +API.v1.addRoute('push.token', { authRequired: true }, { post() { const { type, value, appName } = this.bodyParams; let { id } = this.bodyParams; @@ -35,7 +35,7 @@ RocketChat.API.v1.addRoute('push.token', { authRequired: true }, { userId: this.userId, })); - return RocketChat.API.v1.success({ result }); + return API.v1.success({ result }); }, delete() { const { token } = this.bodyParams; @@ -54,9 +54,9 @@ RocketChat.API.v1.addRoute('push.token', { authRequired: true }, { }); if (affectedRecords === 0) { - return RocketChat.API.v1.notFound(); + return API.v1.notFound(); } - return RocketChat.API.v1.success(); + return API.v1.success(); }, }); diff --git a/app/api/server/v1/roles.js b/app/api/server/v1/roles.js new file mode 100644 index 000000000000..78858819e5bb --- /dev/null +++ b/app/api/server/v1/roles.js @@ -0,0 +1,56 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; +import { Roles } from '../../../models'; +import { API } from '../api'; + +API.v1.addRoute('roles.list', { authRequired: true }, { + get() { + const roles = Roles.find({}, { fields: { _updatedAt: 0 } }).fetch(); + + return API.v1.success({ roles }); + }, +}); + +API.v1.addRoute('roles.create', { authRequired: true }, { + post() { + check(this.bodyParams, { + name: String, + scope: Match.Maybe(String), + description: Match.Maybe(String), + }); + + const roleData = { + name: this.bodyParams.name, + scope: this.bodyParams.scope, + description: this.bodyParams.description, + }; + + Meteor.runAsUser(this.userId, () => { + Meteor.call('authorization:saveRole', roleData); + }); + + return API.v1.success({ + role: Roles.findOneByIdOrName(roleData.name, { fields: API.v1.defaultFieldsToExclude }), + }); + }, +}); + +API.v1.addRoute('roles.addUserToRole', { authRequired: true }, { + post() { + check(this.bodyParams, { + roleName: String, + username: String, + roomId: Match.Maybe(String), + }); + + const user = this.getUserFromParams(); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('authorization:addUserToRole', this.bodyParams.roleName, user.username, this.bodyParams.roomId); + }); + + return API.v1.success({ + role: Roles.findOneByIdOrName(this.bodyParams.roleName, { fields: API.v1.defaultFieldsToExclude }), + }); + }, +}); diff --git a/app/api/server/v1/rooms.js b/app/api/server/v1/rooms.js new file mode 100644 index 000000000000..0191f6bd9e19 --- /dev/null +++ b/app/api/server/v1/rooms.js @@ -0,0 +1,271 @@ +import { Meteor } from 'meteor/meteor'; +import { FileUpload } from '../../../file-upload'; +import { Rooms } from '../../../models'; +import Busboy from 'busboy'; +import { API } from '../api'; + +function findRoomByIdOrName({ params, checkedArchived = true }) { + if ((!params.roomId || !params.roomId.trim()) && (!params.roomName || !params.roomName.trim())) { + throw new Meteor.Error('error-roomid-param-not-provided', 'The parameter "roomId" or "roomName" is required'); + } + + const fields = { ...API.v1.defaultFieldsToExclude }; + + let room; + if (params.roomId) { + room = Rooms.findOneById(params.roomId, { fields }); + } else if (params.roomName) { + room = Rooms.findOneByName(params.roomName, { fields }); + } + if (!room) { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any channel'); + } + if (checkedArchived && room.archived) { + throw new Meteor.Error('error-room-archived', `The channel, ${ room.name }, is archived`); + } + + return room; +} + +API.v1.addRoute('rooms.get', { authRequired: true }, { + get() { + const { updatedSince } = this.queryParams; + + let updatedSinceDate; + if (updatedSince) { + if (isNaN(Date.parse(updatedSince))) { + throw new Meteor.Error('error-updatedSince-param-invalid', 'The "updatedSince" query parameter must be a valid date.'); + } else { + updatedSinceDate = new Date(updatedSince); + } + } + + let result; + Meteor.runAsUser(this.userId, () => result = Meteor.call('rooms/get', updatedSinceDate)); + + if (Array.isArray(result)) { + result = { + update: result, + remove: [], + }; + } + + return API.v1.success({ + update: result.update.map((room) => this.composeRoomWithLastMessage(room, this.userId)), + remove: result.remove.map((room) => this.composeRoomWithLastMessage(room, this.userId)), + }); + }, +}); + +API.v1.addRoute('rooms.upload/:rid', { authRequired: true }, { + post() { + const room = Meteor.call('canAccessRoom', this.urlParams.rid, this.userId); + + if (!room) { + return API.v1.unauthorized(); + } + + const busboy = new Busboy({ headers: this.request.headers }); + const files = []; + const fields = {}; + + Meteor.wrapAsync((callback) => { + busboy.on('file', (fieldname, file, filename, encoding, mimetype) => { + if (fieldname !== 'file') { + return files.push(new Meteor.Error('invalid-field')); + } + + const fileDate = []; + file.on('data', (data) => fileDate.push(data)); + + file.on('end', () => { + files.push({ fieldname, file, filename, encoding, mimetype, fileBuffer: Buffer.concat(fileDate) }); + }); + }); + + busboy.on('field', (fieldname, value) => fields[fieldname] = value); + + busboy.on('finish', Meteor.bindEnvironment(() => callback())); + + this.request.pipe(busboy); + })(); + + if (files.length === 0) { + return API.v1.failure('File required'); + } + + if (files.length > 1) { + return API.v1.failure('Just 1 file is allowed'); + } + + const file = files[0]; + + const fileStore = FileUpload.getStore('Uploads'); + + const details = { + name: file.filename, + size: file.fileBuffer.length, + type: file.mimetype, + rid: this.urlParams.rid, + userId: this.userId, + }; + + Meteor.runAsUser(this.userId, () => { + const uploadedFile = Meteor.wrapAsync(fileStore.insert.bind(fileStore))(details, file.fileBuffer); + + uploadedFile.description = fields.description; + + delete fields.description; + + API.v1.success(Meteor.call('sendFileMessage', this.urlParams.rid, null, uploadedFile, fields)); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('rooms.saveNotification', { authRequired: true }, { + post() { + const saveNotifications = (notifications, roomId) => { + Object.keys(notifications).forEach((notificationKey) => + Meteor.runAsUser(this.userId, () => + Meteor.call('saveNotificationSettings', roomId, notificationKey, notifications[notificationKey]) + ) + ); + }; + const { roomId, notifications } = this.bodyParams; + + if (!roomId) { + return API.v1.failure('The \'roomId\' param is required'); + } + + if (!notifications || Object.keys(notifications).length === 0) { + return API.v1.failure('The \'notifications\' param is required'); + } + + saveNotifications(notifications, roomId); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('rooms.favorite', { authRequired: true }, { + post() { + const { favorite } = this.bodyParams; + + if (!this.bodyParams.hasOwnProperty('favorite')) { + return API.v1.failure('The \'favorite\' param is required'); + } + + const room = findRoomByIdOrName({ params: this.bodyParams }); + + Meteor.runAsUser(this.userId, () => Meteor.call('toggleFavorite', room._id, favorite)); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('rooms.cleanHistory', { authRequired: true }, { + post() { + const findResult = findRoomByIdOrName({ params: this.bodyParams }); + + if (!this.bodyParams.latest) { + return API.v1.failure('Body parameter "latest" is required.'); + } + + if (!this.bodyParams.oldest) { + return API.v1.failure('Body parameter "oldest" is required.'); + } + + const latest = new Date(this.bodyParams.latest); + const oldest = new Date(this.bodyParams.oldest); + + const inclusive = this.bodyParams.inclusive || false; + + Meteor.runAsUser(this.userId, () => Meteor.call('cleanRoomHistory', { + roomId: findResult._id, + latest, + oldest, + inclusive, + limit: this.bodyParams.limit, + excludePinned: this.bodyParams.excludePinned, + filesOnly: this.bodyParams.filesOnly, + fromUsers: this.bodyParams.users, + })); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('rooms.info', { authRequired: true }, { + get() { + const room = findRoomByIdOrName({ params: this.requestParams() }); + const { fields } = this.parseJsonQuery(); + if (!Meteor.call('canAccessRoom', room._id, this.userId, {})) { + return API.v1.failure('not-allowed', 'Not Allowed'); + } + return API.v1.success({ room: Rooms.findOneByIdOrName(room._id, { fields }) }); + }, +}); + +API.v1.addRoute('rooms.leave', { authRequired: true }, { + post() { + const room = findRoomByIdOrName({ params: this.bodyParams }); + Meteor.runAsUser(this.userId, () => { + Meteor.call('leaveRoom', room._id); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('rooms.createDiscussion', { authRequired: true }, { + post() { + const { prid, pmid, reply, t_name, users } = this.bodyParams; + if (!prid) { + return API.v1.failure('Body parameter "prid" is required.'); + } + if (!t_name) { + return API.v1.failure('Body parameter "t_name" is required.'); + } + if (users && !Array.isArray(users)) { + return API.v1.failure('Body parameter "users" must be an array.'); + } + + const discussion = Meteor.runAsUser(this.userId, () => Meteor.call('createDiscussion', { + prid, + pmid, + t_name, + reply, + users: users || [], + })); + + return API.v1.success({ discussion }); + }, +}); + +API.v1.addRoute('rooms.getDiscussions', { authRequired: true }, { + get() { + const room = findRoomByIdOrName({ params: this.requestParams() }); + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + if (!Meteor.call('canAccessRoom', room._id, this.userId, {})) { + return API.v1.failure('not-allowed', 'Not Allowed'); + } + const ourQuery = Object.assign(query, { prid: room._id }); + + const discussions = Rooms.find(ourQuery, { + sort: sort ? sort : { fname: 1 }, + skip: offset, + limit: count, + fields, + }).fetch(); + + return API.v1.success({ + discussions, + count: discussions.length, + offset, + total: Rooms.find(ourQuery).count(), + }); + }, +}); diff --git a/app/api/server/v1/settings.js b/app/api/server/v1/settings.js new file mode 100644 index 000000000000..284bf7558fbc --- /dev/null +++ b/app/api/server/v1/settings.js @@ -0,0 +1,141 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; +import { ServiceConfiguration } from 'meteor/service-configuration'; +import { Settings } from '../../../models'; +import { hasPermission } from '../../../authorization'; +import { API } from '../api'; +import _ from 'underscore'; + +// settings endpoints +API.v1.addRoute('settings.public', { authRequired: false }, { + get() { + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + let ourQuery = { + hidden: { $ne: true }, + public: true, + }; + + ourQuery = Object.assign({}, query, ourQuery); + + const settings = Settings.find(ourQuery, { + sort: sort ? sort : { _id: 1 }, + skip: offset, + limit: count, + fields: Object.assign({ _id: 1, value: 1 }, fields), + }).fetch(); + + return API.v1.success({ + settings, + count: settings.length, + offset, + total: Settings.find(ourQuery).count(), + }); + }, +}); + +API.v1.addRoute('settings.oauth', { authRequired: false }, { + get() { + const mountOAuthServices = () => { + const oAuthServicesEnabled = ServiceConfiguration.configurations.find({}, { fields: { secret: 0 } }).fetch(); + + return oAuthServicesEnabled.map((service) => { + if (service.custom || ['saml', 'cas', 'wordpress'].includes(service.service)) { + return { ...service }; + } + + return { + _id: service._id, + name: service.service, + clientId: service.appId || service.clientId || service.consumerKey, + buttonLabelText: service.buttonLabelText || '', + buttonColor: service.buttonColor || '', + buttonLabelColor: service.buttonLabelColor || '', + custom: false, + }; + }); + }; + + return API.v1.success({ + services: mountOAuthServices(), + }); + }, +}); + +API.v1.addRoute('settings', { authRequired: true }, { + get() { + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + let ourQuery = { + hidden: { $ne: true }, + }; + + if (!hasPermission(this.userId, 'view-privileged-setting')) { + ourQuery.public = true; + } + + ourQuery = Object.assign({}, query, ourQuery); + + const settings = Settings.find(ourQuery, { + sort: sort ? sort : { _id: 1 }, + skip: offset, + limit: count, + fields: Object.assign({ _id: 1, value: 1 }, fields), + }).fetch(); + + return API.v1.success({ + settings, + count: settings.length, + offset, + total: Settings.find(ourQuery).count(), + }); + }, +}); + +API.v1.addRoute('settings/:_id', { authRequired: true }, { + get() { + if (!hasPermission(this.userId, 'view-privileged-setting')) { + return API.v1.unauthorized(); + } + + return API.v1.success(_.pick(Settings.findOneNotHiddenById(this.urlParams._id), '_id', 'value')); + }, + post() { + if (!hasPermission(this.userId, 'edit-privileged-setting')) { + return API.v1.unauthorized(); + } + + // allow special handling of particular setting types + const setting = Settings.findOneNotHiddenById(this.urlParams._id); + if (setting.type === 'action' && this.bodyParams && this.bodyParams.execute) { + // execute the configured method + Meteor.call(setting.value); + return API.v1.success(); + } + + if (setting.type === 'color' && this.bodyParams && this.bodyParams.editor && this.bodyParams.value) { + Settings.updateOptionsById(this.urlParams._id, { editor: this.bodyParams.editor }); + Settings.updateValueNotHiddenById(this.urlParams._id, this.bodyParams.value); + return API.v1.success(); + } + + check(this.bodyParams, { + value: Match.Any, + }); + if (Settings.updateValueNotHiddenById(this.urlParams._id, this.bodyParams.value)) { + return API.v1.success(); + } + + return API.v1.failure(); + }, +}); + +API.v1.addRoute('service.configurations', { authRequired: false }, { + get() { + return API.v1.success({ + configurations: ServiceConfiguration.configurations.find({}, { fields: { secret: 0 } }).fetch(), + }); + }, +}); diff --git a/app/api/server/v1/stats.js b/app/api/server/v1/stats.js new file mode 100644 index 000000000000..d745cda1ee48 --- /dev/null +++ b/app/api/server/v1/stats.js @@ -0,0 +1,47 @@ +import { Meteor } from 'meteor/meteor'; +import { hasPermission } from '../../../authorization'; +import { Statistics } from '../../../models'; +import { API } from '../api'; + +API.v1.addRoute('statistics', { authRequired: true }, { + get() { + let refresh = false; + if (typeof this.queryParams.refresh !== 'undefined' && this.queryParams.refresh === 'true') { + refresh = true; + } + + let stats; + Meteor.runAsUser(this.userId, () => { + stats = Meteor.call('getStatistics', refresh); + }); + + return API.v1.success({ + statistics: stats, + }); + }, +}); + +API.v1.addRoute('statistics.list', { authRequired: true }, { + get() { + if (!hasPermission(this.userId, 'view-statistics')) { + return API.v1.unauthorized(); + } + + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + const statistics = Statistics.find(query, { + sort: sort ? sort : { name: 1 }, + skip: offset, + limit: count, + fields, + }).fetch(); + + return API.v1.success({ + statistics, + count: statistics.length, + offset, + total: Statistics.find(query).count(), + }); + }, +}); diff --git a/app/api/server/v1/subscriptions.js b/app/api/server/v1/subscriptions.js new file mode 100644 index 000000000000..9bdfd2e6da7c --- /dev/null +++ b/app/api/server/v1/subscriptions.js @@ -0,0 +1,86 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { Subscriptions } from '../../../models'; +import { API } from '../api'; + +API.v1.addRoute('subscriptions.get', { authRequired: true }, { + get() { + const { updatedSince } = this.queryParams; + + let updatedSinceDate; + if (updatedSince) { + if (isNaN(Date.parse(updatedSince))) { + throw new Meteor.Error('error-roomId-param-invalid', 'The "lastUpdate" query parameter must be a valid date.'); + } else { + updatedSinceDate = new Date(updatedSince); + } + } + + let result; + Meteor.runAsUser(this.userId, () => result = Meteor.call('subscriptions/get', updatedSinceDate)); + + if (Array.isArray(result)) { + result = { + update: result, + remove: [], + }; + } + + return API.v1.success(result); + }, +}); + +API.v1.addRoute('subscriptions.getOne', { authRequired: true }, { + get() { + const { roomId } = this.requestParams(); + + if (!roomId) { + return API.v1.failure('The \'roomId\' param is required'); + } + + const subscription = Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId); + + return API.v1.success({ + subscription, + }); + }, +}); + +/** + This API is suppose to mark any room as read. + + Method: POST + Route: api/v1/subscriptions.read + Params: + - rid: The rid of the room to be marked as read. + */ +API.v1.addRoute('subscriptions.read', { authRequired: true }, { + post() { + check(this.bodyParams, { + rid: String, + }); + + Meteor.runAsUser(this.userId, () => + Meteor.call('readMessages', this.bodyParams.rid) + ); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('subscriptions.unread', { authRequired: true }, { + post() { + const { roomId, firstUnreadMessage } = this.bodyParams; + if (!roomId && (firstUnreadMessage && !firstUnreadMessage._id)) { + return API.v1.failure('At least one of "roomId" or "firstUnreadMessage._id" params is required'); + } + + Meteor.runAsUser(this.userId, () => + Meteor.call('unreadMessages', firstUnreadMessage, roomId) + ); + + return API.v1.success(); + }, +}); + + diff --git a/app/api/server/v1/users.js b/app/api/server/v1/users.js new file mode 100644 index 000000000000..df07b51930c0 --- /dev/null +++ b/app/api/server/v1/users.js @@ -0,0 +1,571 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; +import { TAPi18n } from 'meteor/tap:i18n'; +import { Users, Subscriptions } from '../../../models'; +import { hasPermission } from '../../../authorization'; +import { settings } from '../../../settings'; +import { getURL } from '../../../utils'; +import { + validateCustomFields, + saveUser, + saveCustomFieldsWithoutValidation, + checkUsernameAvailability, + setUserAvatar, + saveCustomFields, +} from '../../../lib'; +import { API } from '../api'; +import _ from 'underscore'; +import Busboy from 'busboy'; + +API.v1.addRoute('users.create', { authRequired: true }, { + post() { + check(this.bodyParams, { + email: String, + name: String, + password: String, + username: String, + active: Match.Maybe(Boolean), + roles: Match.Maybe(Array), + joinDefaultChannels: Match.Maybe(Boolean), + requirePasswordChange: Match.Maybe(Boolean), + sendWelcomeEmail: Match.Maybe(Boolean), + verified: Match.Maybe(Boolean), + customFields: Match.Maybe(Object), + }); + + // New change made by pull request #5152 + if (typeof this.bodyParams.joinDefaultChannels === 'undefined') { + this.bodyParams.joinDefaultChannels = true; + } + + if (this.bodyParams.customFields) { + validateCustomFields(this.bodyParams.customFields); + } + + const newUserId = saveUser(this.userId, this.bodyParams); + + if (this.bodyParams.customFields) { + saveCustomFieldsWithoutValidation(newUserId, this.bodyParams.customFields); + } + + + if (typeof this.bodyParams.active !== 'undefined') { + Meteor.runAsUser(this.userId, () => { + Meteor.call('setUserActiveStatus', newUserId, this.bodyParams.active); + }); + } + + return API.v1.success({ user: Users.findOneById(newUserId, { fields: API.v1.defaultFieldsToExclude }) }); + }, +}); + +API.v1.addRoute('users.delete', { authRequired: true }, { + post() { + if (!hasPermission(this.userId, 'delete-user')) { + return API.v1.unauthorized(); + } + + const user = this.getUserFromParams(); + + Meteor.runAsUser(this.userId, () => { + Meteor.call('deleteUser', user._id); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('users.deleteOwnAccount', { authRequired: true }, { + post() { + const { password } = this.bodyParams; + if (!password) { + return API.v1.failure('Body parameter "password" is required.'); + } + if (!settings.get('Accounts_AllowDeleteOwnAccount')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('deleteUserOwnAccount', password); + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('users.getAvatar', { authRequired: false }, { + get() { + const user = this.getUserFromParams(); + + const url = getURL(`/avatar/${ user.username }`, { cdn: false, full: true }); + this.response.setHeader('Location', url); + + return { + statusCode: 307, + body: url, + }; + }, +}); + +API.v1.addRoute('users.setActiveStatus', { authRequired: true }, { + post() { + check(this.bodyParams, { + userId: String, + activeStatus: Boolean, + }); + + if (!hasPermission(this.userId, 'edit-other-user-active-status')) { + return API.v1.unauthorized(); + } + + Meteor.runAsUser(this.userId, () => { + Meteor.call('setUserActiveStatus', this.bodyParams.userId, this.bodyParams.activeStatus); + }); + return API.v1.success({ user: Users.findOneById(this.bodyParams.userId, { fields: { active: 1 } }) }); + + }, +}); + +API.v1.addRoute('users.getPresence', { authRequired: true }, { + get() { + if (this.isUserFromParams()) { + const user = Users.findOneById(this.userId); + return API.v1.success({ + presence: user.status, + connectionStatus: user.statusConnection, + lastLogin: user.lastLogin, + }); + } + + const user = this.getUserFromParams(); + + return API.v1.success({ + presence: user.status, + }); + }, +}); + +API.v1.addRoute('users.info', { authRequired: true }, { + get() { + const { username } = this.getUserFromParams(); + const { fields } = this.parseJsonQuery(); + let user = {}; + let result; + Meteor.runAsUser(this.userId, () => { + result = Meteor.call('getFullUserData', { username, limit: 1 }); + }); + + if (!result || result.length !== 1) { + return API.v1.failure(`Failed to get the user data for the userId of "${ username }".`); + } + + user = result[0]; + if (fields.userRooms === 1 && hasPermission(this.userId, 'view-other-user-channels')) { + user.rooms = Subscriptions.findByUserId(user._id, { + fields: { + rid: 1, + name: 1, + t: 1, + roles: 1, + }, + sort: { + t: 1, + name: 1, + }, + }).fetch(); + } + + return API.v1.success({ + user, + }); + }, +}); + +API.v1.addRoute('users.list', { authRequired: true }, { + get() { + if (!hasPermission(this.userId, 'view-d-room')) { + return API.v1.unauthorized(); + } + + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + const users = Users.find(query, { + sort: sort ? sort : { username: 1 }, + skip: offset, + limit: count, + fields, + }).fetch(); + + return API.v1.success({ + users, + count: users.length, + offset, + total: Users.find(query).count(), + }); + }, +}); + +API.v1.addRoute('users.register', { authRequired: false }, { + post() { + if (this.userId) { + return API.v1.failure('Logged in users can not register again.'); + } + + // We set their username here, so require it + // The `registerUser` checks for the other requirements + check(this.bodyParams, Match.ObjectIncluding({ + username: String, + })); + + if (!checkUsernameAvailability(this.bodyParams.username)) { + return API.v1.failure('Username is already in use'); + } + + // Register the user + const userId = Meteor.call('registerUser', this.bodyParams); + + // Now set their username + Meteor.runAsUser(userId, () => Meteor.call('setUsername', this.bodyParams.username)); + + return API.v1.success({ user: Users.findOneById(userId, { fields: API.v1.defaultFieldsToExclude }) }); + }, +}); + +API.v1.addRoute('users.resetAvatar', { authRequired: true }, { + post() { + const user = this.getUserFromParams(); + + if (user._id === this.userId) { + Meteor.runAsUser(this.userId, () => Meteor.call('resetAvatar')); + } else if (hasPermission(this.userId, 'edit-other-user-info')) { + Meteor.runAsUser(user._id, () => Meteor.call('resetAvatar')); + } else { + return API.v1.unauthorized(); + } + + return API.v1.success(); + }, +}); + +API.v1.addRoute('users.setAvatar', { authRequired: true }, { + post() { + check(this.bodyParams, Match.ObjectIncluding({ + avatarUrl: Match.Maybe(String), + userId: Match.Maybe(String), + username: Match.Maybe(String), + })); + + if (!settings.get('Accounts_AllowUserAvatarChange')) { + throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed', { + method: 'users.setAvatar', + }); + } + + let user; + if (this.isUserFromParams()) { + user = Meteor.users.findOne(this.userId); + } else if (hasPermission(this.userId, 'edit-other-user-avatar')) { + user = this.getUserFromParams(); + } else { + return API.v1.unauthorized(); + } + + Meteor.runAsUser(user._id, () => { + if (this.bodyParams.avatarUrl) { + setUserAvatar(user, this.bodyParams.avatarUrl, '', 'url'); + } else { + const busboy = new Busboy({ headers: this.request.headers }); + const fields = {}; + const getUserFromFormData = (fields) => { + if (fields.userId) { + return Users.findOneById(fields.userId, { _id: 1 }); + } + if (fields.username) { + return Users.findOneByUsername(fields.username, { _id: 1 }); + } + }; + + Meteor.wrapAsync((callback) => { + busboy.on('file', Meteor.bindEnvironment((fieldname, file, filename, encoding, mimetype) => { + if (fieldname !== 'image') { + return callback(new Meteor.Error('invalid-field')); + } + const imageData = []; + file.on('data', Meteor.bindEnvironment((data) => { + imageData.push(data); + })); + + file.on('end', Meteor.bindEnvironment(() => { + const sentTheUserByFormData = fields.userId || fields.username; + if (sentTheUserByFormData) { + user = getUserFromFormData(fields); + if (!user) { + return callback(new Meteor.Error('error-invalid-user', 'The optional "userId" or "username" param provided does not match any users')); + } + const isAnotherUser = this.userId !== user._id; + if (isAnotherUser && !hasPermission(this.userId, 'edit-other-user-info')) { + return callback(new Meteor.Error('error-not-allowed', 'Not allowed')); + } + } + setUserAvatar(user, Buffer.concat(imageData), mimetype, 'rest'); + callback(); + })); + })); + busboy.on('field', (fieldname, val) => { + fields[fieldname] = val; + }); + this.request.pipe(busboy); + })(); + } + }); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('users.update', { authRequired: true }, { + post() { + check(this.bodyParams, { + userId: String, + data: Match.ObjectIncluding({ + email: Match.Maybe(String), + name: Match.Maybe(String), + password: Match.Maybe(String), + username: Match.Maybe(String), + active: Match.Maybe(Boolean), + roles: Match.Maybe(Array), + joinDefaultChannels: Match.Maybe(Boolean), + requirePasswordChange: Match.Maybe(Boolean), + sendWelcomeEmail: Match.Maybe(Boolean), + verified: Match.Maybe(Boolean), + customFields: Match.Maybe(Object), + }), + }); + + const userData = _.extend({ _id: this.bodyParams.userId }, this.bodyParams.data); + + Meteor.runAsUser(this.userId, () => saveUser(this.userId, userData)); + + if (this.bodyParams.data.customFields) { + saveCustomFields(this.bodyParams.userId, this.bodyParams.data.customFields); + } + + if (typeof this.bodyParams.data.active !== 'undefined') { + Meteor.runAsUser(this.userId, () => { + Meteor.call('setUserActiveStatus', this.bodyParams.userId, this.bodyParams.data.active); + }); + } + + return API.v1.success({ user: Users.findOneById(this.bodyParams.userId, { fields: API.v1.defaultFieldsToExclude }) }); + }, +}); + +API.v1.addRoute('users.updateOwnBasicInfo', { authRequired: true }, { + post() { + check(this.bodyParams, { + data: Match.ObjectIncluding({ + email: Match.Maybe(String), + name: Match.Maybe(String), + username: Match.Maybe(String), + currentPassword: Match.Maybe(String), + newPassword: Match.Maybe(String), + }), + customFields: Match.Maybe(Object), + }); + + const userData = { + email: this.bodyParams.data.email, + realname: this.bodyParams.data.name, + username: this.bodyParams.data.username, + newPassword: this.bodyParams.data.newPassword, + typedPassword: this.bodyParams.data.currentPassword, + }; + + Meteor.runAsUser(this.userId, () => Meteor.call('saveUserProfile', userData, this.bodyParams.customFields)); + + return API.v1.success({ user: Users.findOneById(this.userId, { fields: API.v1.defaultFieldsToExclude }) }); + }, +}); + +API.v1.addRoute('users.createToken', { authRequired: true }, { + post() { + const user = this.getUserFromParams(); + let data; + Meteor.runAsUser(this.userId, () => { + data = Meteor.call('createToken', user._id); + }); + return data ? API.v1.success({ data }) : API.v1.unauthorized(); + }, +}); + +API.v1.addRoute('users.getPreferences', { authRequired: true }, { + get() { + const user = Users.findOneById(this.userId); + if (user.settings) { + const { preferences = {} } = user.settings; + preferences.language = user.language; + + return API.v1.success({ + preferences, + }); + } else { + return API.v1.failure(TAPi18n.__('Accounts_Default_User_Preferences_not_available').toUpperCase()); + } + }, +}); + +API.v1.addRoute('users.setPreferences', { authRequired: true }, { + post() { + check(this.bodyParams, { + userId: Match.Maybe(String), + data: Match.ObjectIncluding({ + newRoomNotification: Match.Maybe(String), + newMessageNotification: Match.Maybe(String), + clockMode: Match.Maybe(Number), + useEmojis: Match.Maybe(Boolean), + convertAsciiEmoji: Match.Maybe(Boolean), + saveMobileBandwidth: Match.Maybe(Boolean), + collapseMediaByDefault: Match.Maybe(Boolean), + autoImageLoad: Match.Maybe(Boolean), + emailNotificationMode: Match.Maybe(String), + unreadAlert: Match.Maybe(Boolean), + notificationsSoundVolume: Match.Maybe(Number), + desktopNotifications: Match.Maybe(String), + mobileNotifications: Match.Maybe(String), + enableAutoAway: Match.Maybe(Boolean), + highlights: Match.Maybe(Array), + desktopNotificationDuration: Match.Maybe(Number), + messageViewMode: Match.Maybe(Number), + hideUsernames: Match.Maybe(Boolean), + hideRoles: Match.Maybe(Boolean), + hideAvatars: Match.Maybe(Boolean), + hideFlexTab: Match.Maybe(Boolean), + sendOnEnter: Match.Maybe(String), + roomCounterSidebar: Match.Maybe(Boolean), + language: Match.Maybe(String), + sidebarShowFavorites: Match.Optional(Boolean), + sidebarShowUnread: Match.Optional(Boolean), + sidebarSortby: Match.Optional(String), + sidebarViewMode: Match.Optional(String), + sidebarHideAvatar: Match.Optional(Boolean), + sidebarGroupByType: Match.Optional(Boolean), + muteFocusedConversations: Match.Optional(Boolean), + }), + }); + const userId = this.bodyParams.userId ? this.bodyParams.userId : this.userId; + const userData = { + _id: userId, + settings: { + preferences: this.bodyParams.data, + }, + }; + + if (this.bodyParams.data.language) { + const { language } = this.bodyParams.data; + delete this.bodyParams.data.language; + userData.language = language; + } + + Meteor.runAsUser(this.userId, () => saveUser(this.userId, userData)); + const user = Users.findOneById(userId, { + fields: { + 'settings.preferences': 1, + language: 1, + }, + }); + + return API.v1.success({ + user: { + _id: user._id, + settings: { + preferences: { + ...user.settings.preferences, + language: user.language, + }, + }, + }, + }); + }, +}); + +API.v1.addRoute('users.forgotPassword', { authRequired: false }, { + post() { + const { email } = this.bodyParams; + if (!email) { + return API.v1.failure('The \'email\' param is required'); + } + + const emailSent = Meteor.call('sendForgotPasswordEmail', email); + if (emailSent) { + return API.v1.success(); + } + return API.v1.failure('User not found'); + }, +}); + +API.v1.addRoute('users.getUsernameSuggestion', { authRequired: true }, { + get() { + const result = Meteor.runAsUser(this.userId, () => Meteor.call('getUsernameSuggestion')); + + return API.v1.success({ result }); + }, +}); + +API.v1.addRoute('users.generatePersonalAccessToken', { authRequired: true }, { + post() { + const { tokenName } = this.bodyParams; + if (!tokenName) { + return API.v1.failure('The \'tokenName\' param is required'); + } + const token = Meteor.runAsUser(this.userId, () => Meteor.call('personalAccessTokens:generateToken', { tokenName })); + + return API.v1.success({ token }); + }, +}); + +API.v1.addRoute('users.regeneratePersonalAccessToken', { authRequired: true }, { + post() { + const { tokenName } = this.bodyParams; + if (!tokenName) { + return API.v1.failure('The \'tokenName\' param is required'); + } + const token = Meteor.runAsUser(this.userId, () => Meteor.call('personalAccessTokens:regenerateToken', { tokenName })); + + return API.v1.success({ token }); + }, +}); + +API.v1.addRoute('users.getPersonalAccessTokens', { authRequired: true }, { + get() { + if (!hasPermission(this.userId, 'create-personal-access-tokens')) { + throw new Meteor.Error('not-authorized', 'Not Authorized'); + } + const loginTokens = Users.getLoginTokensByUserId(this.userId).fetch()[0]; + const getPersonalAccessTokens = () => loginTokens.services.resume.loginTokens + .filter((loginToken) => loginToken.type && loginToken.type === 'personalAccessToken') + .map((loginToken) => ({ + name: loginToken.name, + createdAt: loginToken.createdAt, + lastTokenPart: loginToken.lastTokenPart, + })); + + return API.v1.success({ + tokens: loginTokens ? getPersonalAccessTokens() : [], + }); + }, +}); + +API.v1.addRoute('users.removePersonalAccessToken', { authRequired: true }, { + post() { + const { tokenName } = this.bodyParams; + if (!tokenName) { + return API.v1.failure('The \'tokenName\' param is required'); + } + Meteor.runAsUser(this.userId, () => Meteor.call('personalAccessTokens:removeToken', { + tokenName, + })); + + return API.v1.success(); + }, +}); diff --git a/app/api/server/v1/video-conference.js b/app/api/server/v1/video-conference.js new file mode 100644 index 000000000000..af6d1a7813bf --- /dev/null +++ b/app/api/server/v1/video-conference.js @@ -0,0 +1,21 @@ +import { Meteor } from 'meteor/meteor'; +import { Rooms } from '../../../models'; +import { API } from '../api'; + +API.v1.addRoute('video-conference/jitsi.update-timeout', { authRequired: true }, { + post() { + const { roomId } = this.bodyParams; + if (!roomId) { + return API.v1.failure('The "roomId" parameter is required!'); + } + + const room = Rooms.findOneById(roomId); + if (!room) { + return API.v1.failure('Room does not exist!'); + } + + Meteor.runAsUser(this.userId, () => Meteor.call('jitsi:updateTimeout', roomId)); + + return API.v1.success({ jitsiTimeout: Rooms.findOneById(roomId).jitsiTimeout }); + }, +}); diff --git a/packages/rocketchat-apps/.gitignore b/app/apps/.gitignore similarity index 100% rename from packages/rocketchat-apps/.gitignore rename to app/apps/.gitignore diff --git a/packages/rocketchat-apps/README.md b/app/apps/README.md similarity index 100% rename from packages/rocketchat-apps/README.md rename to app/apps/README.md diff --git a/app/apps/assets/stylesheets/apps.css b/app/apps/assets/stylesheets/apps.css new file mode 100644 index 000000000000..b8a9652ea4e8 --- /dev/null +++ b/app/apps/assets/stylesheets/apps.css @@ -0,0 +1,285 @@ +.rc-apps-marketplace { + display: flex; + + overflow: auto; + flex-direction: column; + + height: 100vh; + + padding: 1.25rem 2rem; + + font-size: 14px; + + &.page-settings .rc-apps-container { + a { + color: var(--rc-color-button-primary); + + font-weight: 500; + } + } + + h1 { + margin-bottom: 0; + + letter-spacing: 0; + text-transform: initial; + + color: #54585e; + + font-size: 22px; + font-weight: normal; + line-height: 28px; + } + + h2 { + margin-top: 10px; + margin-bottom: 0; + + letter-spacing: 0; + text-transform: initial; + + color: var(--color-dark); + + font-size: 16px; + font-weight: 500; + line-height: 24px; + } + + h3 { + margin-bottom: 0; + + text-align: left; + letter-spacing: 0; + text-transform: initial; + + color: var(--color-gray); + + font-size: 14px; + font-weight: 500; + line-height: 20px; + } + + .rc-apps-details { + margin-bottom: 0; + padding: 0; + + &__description { + padding-bottom: 50px; + + border-bottom: 1.5px solid #efefef; + } + + &__photo { + width: 96px; + height: 96px; + margin-right: 21px; + + background-color: #f7f7f7; + } + + &__content { + padding: 0; + } + + &__col { + display: inline-block; + + margin-right: 8px; + } + } + + .rc-apps-container { + margin-top: 0; + padding-bottom: 15px; + } + + .rc-apps-container__header { + padding-top: 10px; + + border-bottom: 1.5px solid #efefef; + } + + /* + .js-install { + margin-top: 6px; + } */ + + .content { + /* display: block !important; */ + padding: 0 !important; + + > .rc-apps-container { + display: block; + overflow-y: scroll; + + padding: 0 !important; + } + + > .rc-apps-details { + display: block; + } + } + + .rc-apps-category { + margin-right: 8px; + padding: 8px; + + text-align: left; + letter-spacing: -0.17px; + text-transform: uppercase; + + color: #9da2a9; + border-radius: 2px; + background: #f3f4f5; + + font-size: 12px; + font-weight: 500; + } + + .app-enable-loading .loading-animation { + margin-left: 50px; + justify-content: left; + } + + .apps-error { + display: flex; + flex-direction: column; + + width: 100%; + height: calc(100% - 60px); + padding: 25px 40px; + + font-size: 45px; + align-items: center; + justify-content: center; + } + + .rc-table-avatar { + width: 40px; + height: 40px; + margin: 0 7px; + } + + .rc-table-info { + height: 40px; + margin: 0 7px; + } + + .rc-app-price { + position: relative; + top: -3px; + } + + .rc-table-td--medium { + width: 300px; + } + + .rc-table td { + padding: 0.5rem 0; + + padding-right: 10px; + } + + td.rc-apps-marketplace-price { + text-align: right; + + button { + font-weight: 600; + } + + .rc-icon { + color: #3582f3; + } + } + + th.rc-apps-marketplace-price { + width: 120px; + } + + &__wrap-actions { + & > .loading { + display: none; + } + + &.loading { + & > .loading { + display: block; + + font-size: 11px; + font-weight: 600; + + & > .rc-icon--loading { + animation: spin 1s linear infinite; + } + } + + & > .apps-installer { + display: none; + } + } + } + + .arrow-up { + transform: rotate(180deg); + } + + &.page-settings .content .rocket-form .section { + padding: 0 2.5em; + + border-bottom: none; + + &:hover { + background-color: var(--rc-color-primary-lightest); + } + } + + .rc-table-content { + display: flex; + overflow-x: auto; + flex-direction: column; + flex: 1 1 100%; + + height: 100vh; + + & .js-sort { + cursor: pointer; + + &.is-sorting .table-fake-th .rc-icon { + opacity: 1; + } + } + + & .table-fake-th { + &:hover .rc-icon { + opacity: 1; + } + + & .rc-icon { + transition: opacity 0.3s; + + opacity: 0; + + font-size: 1rem; + } + } + } + + @media (width <= 700px) { + .rc-table-content { + & th:not(:first-child), + & td:not(:first-child) { + display: none; + } + } + } +} + +@keyframes play90 { + 0% { + right: -798px; + } + + 100% { + right: 2px; + } +} diff --git a/packages/rocketchat-apps/client/admin/appInstall.html b/app/apps/client/admin/appInstall.html similarity index 100% rename from packages/rocketchat-apps/client/admin/appInstall.html rename to app/apps/client/admin/appInstall.html diff --git a/packages/rocketchat-apps/client/admin/appInstall.js b/app/apps/client/admin/appInstall.js similarity index 87% rename from packages/rocketchat-apps/client/admin/appInstall.js rename to app/apps/client/admin/appInstall.js index f5e15623ef51..474b5e84ec05 100644 --- a/packages/rocketchat-apps/client/admin/appInstall.js +++ b/app/apps/client/admin/appInstall.js @@ -10,6 +10,9 @@ import { ReactiveVar } from 'meteor/reactive-var'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { Template } from 'meteor/templating'; +import { Tracker } from 'meteor/tracker'; +import { APIClient } from '../../../utils'; +import { SideNav } from '../../../ui-utils/client'; Template.appInstall.helpers({ appFile() { @@ -72,9 +75,9 @@ Template.appInstall.events({ let result; if (isUpdating) { - result = await RocketChat.API.post(`apps/${ t.isUpdatingId.get() }`, { url }); + result = await APIClient.post(`apps/${ t.isUpdatingId.get() }`, { url }); } else { - result = await RocketChat.API.post('apps', { url }); + result = await APIClient.post('apps', { url }); } if (result.compilerErrors.length !== 0 || result.app.status === 'compiler_error') { @@ -115,9 +118,9 @@ Template.appInstall.events({ let result; if (isUpdating) { - result = await RocketChat.API.upload(`apps/${ t.isUpdatingId.get() }`, data); + result = await APIClient.upload(`apps/${ t.isUpdatingId.get() }`, data); } else { - result = await RocketChat.API.upload('apps', data); + result = await APIClient.upload('apps', data); } console.log('install result', result); @@ -134,3 +137,10 @@ Template.appInstall.events({ t.isInstalling.set(false); }, }); + +Template.appInstall.onRendered(() => { + Tracker.afterFlush(() => { + SideNav.setFlex('adminFlex'); + SideNav.openFlex(); + }); +}); diff --git a/app/apps/client/admin/appLogs.html b/app/apps/client/admin/appLogs.html new file mode 100644 index 000000000000..47e901331c89 --- /dev/null +++ b/app/apps/client/admin/appLogs.html @@ -0,0 +1,55 @@ + diff --git a/app/apps/client/admin/appLogs.js b/app/apps/client/admin/appLogs.js new file mode 100644 index 000000000000..090bd4aa34ad --- /dev/null +++ b/app/apps/client/admin/appLogs.js @@ -0,0 +1,115 @@ +import { ReactiveVar } from 'meteor/reactive-var'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { Template } from 'meteor/templating'; +import { TAPi18n } from 'meteor/tap:i18n'; +import { Tracker } from 'meteor/tracker'; +import { APIClient } from '../../../utils'; +import moment from 'moment'; +import hljs from 'highlight.js'; +import { SideNav } from '../../../ui-utils/client'; + +const loadData = (instance) => { + Promise.all([ + APIClient.get(`apps/${ instance.id.get() }`), + APIClient.get(`apps/${ instance.id.get() }/logs`), + ]).then((results) => { + + instance.app.set(results[0].app); + instance.logs.set(results[1].logs); + + instance.ready.set(true); + }).catch((e) => { + instance.hasError.set(true); + instance.theError.set(e.message); + }); +}; + +Template.appLogs.onCreated(function() { + const instance = this; + this.id = new ReactiveVar(FlowRouter.getParam('appId')); + this.ready = new ReactiveVar(false); + this.hasError = new ReactiveVar(false); + this.theError = new ReactiveVar(''); + this.app = new ReactiveVar({}); + this.logs = new ReactiveVar([]); + + loadData(instance); +}); + +Template.appLogs.helpers({ + isReady() { + if (Template.instance().ready) { + return Template.instance().ready.get(); + } + + return false; + }, + hasError() { + if (Template.instance().hasError) { + return Template.instance().hasError.get(); + } + + return false; + }, + theError() { + if (Template.instance().theError) { + return Template.instance().theError.get(); + } + + return ''; + }, + app() { + return Template.instance().app.get(); + }, + logs() { + return Template.instance().logs.get(); + }, + formatDate(date) { + return moment(date).format('L LTS'); + }, + jsonStringify(data) { + let value = ''; + + if (!data) { + return value; + } else if (typeof data === 'object') { + value = hljs.highlight('json', JSON.stringify(data, null, 2)).value; + } else { + value = hljs.highlight('json', data).value; + } + + return value.replace(/\\\\n/g, '
'); + }, + title() { + return TAPi18n.__('View_the_Logs_for', { name: Template.instance().app.get().name }); + }, +}); + +Template.appLogs.events({ + 'click .section-collapsed .section-title': (e) => { + $(e.currentTarget).closest('.section').removeClass('section-collapsed').addClass('section-expanded'); + $(e.currentTarget).find('.button-down').addClass('arrow-up'); + }, + + 'click .section-expanded .section-title': (e) => { + $(e.currentTarget).closest('.section').removeClass('section-expanded').addClass('section-collapsed'); + $(e.currentTarget).find('.button-down').removeClass('arrow-up'); + }, + + 'click .js-cancel': (e, t) => { + FlowRouter.go('app-manage', { appId: t.app.get().id }, { version: FlowRouter.getQueryParam('version') }); + }, + + 'click .js-refresh': (e, t) => { + t.ready.set(false); + t.logs.set([]); + loadData(t); + }, +}); + +Template.appLogs.onRendered(() => { + Tracker.afterFlush(() => { + SideNav.setFlex('adminFlex'); + SideNav.openFlex(); + }); +}); diff --git a/packages/rocketchat-apps/client/admin/appManage.html b/app/apps/client/admin/appManage.html similarity index 95% rename from packages/rocketchat-apps/client/admin/appManage.html rename to app/apps/client/admin/appManage.html index 0b08aff6667b..a1f8aa659769 100644 --- a/packages/rocketchat-apps/client/admin/appManage.html +++ b/app/apps/client/admin/appManage.html @@ -3,6 +3,13 @@ {{#with app}}
{{# header sectionName='App_Details' fixedHeight=true hideHelp=true fullpage=true}} +
+ {{#unless disabled}} + + {{/unless}} + +
+
@@ -18,7 +25,7 @@
{{#if iconFileData}} -
+
{{else}}
{{/if}} @@ -44,7 +51,15 @@

{{name}}

{{/if}} {{else}} - + {{#if hasPurchased}} + + {{else}} + {{#if $eq price 0}} + + {{else}} + + {{/if}} + {{/if}} {{/if}}
@@ -454,10 +469,6 @@

{{_ "Settings"}}

{{/each}} -
- - -
{{/if}} diff --git a/app/apps/client/admin/appManage.js b/app/apps/client/admin/appManage.js new file mode 100644 index 000000000000..e385abc66ded --- /dev/null +++ b/app/apps/client/admin/appManage.js @@ -0,0 +1,518 @@ +import { Meteor } from 'meteor/meteor'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { Template } from 'meteor/templating'; +import { TAPi18n, TAPi18next } from 'meteor/tap:i18n'; +import { Tracker } from 'meteor/tracker'; + +import { isEmail, APIClient } from '../../../utils'; +import { settings } from '../../../settings'; +import { Markdown } from '../../../markdown/client'; +import { modal } from '../../../ui-utils'; +import _ from 'underscore'; +import s from 'underscore.string'; +import toastr from 'toastr'; + +import { AppEvents } from '../communication'; +import { Utilities } from '../../lib/misc/Utilities'; +import { Apps } from '../orchestrator'; +import semver from 'semver'; +import { SideNav } from '../../../ui-utils/client'; + +function getApps(instance) { + const id = instance.id.get(); + + const appInfo = { remote: undefined, local: undefined }; + return APIClient.get(`apps/${ id }?marketplace=true&version=${ FlowRouter.getQueryParam('version') }`) + .catch((e) => { + console.log(e); + toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); + return Promise.resolve({ app: undefined }); + }) + .then((remote) => { + appInfo.remote = remote.app; + return APIClient.get(`apps/${ id }`); + }) + .then((local) => { + appInfo.local = local.app; + return Apps.getAppApis(id); + }) + .then((apis) => instance.apis.set(apis)) + .catch((e) => { + if (appInfo.remote || appInfo.local) { + return Promise.resolve(true); + } + + instance.hasError.set(true); + instance.theError.set(e.message); + }).then((goOn) => { + if (typeof goOn !== 'undefined' && !goOn) { + return; + } + + if (appInfo.remote) { + appInfo.remote.displayPrice = parseFloat(appInfo.remote.price).toFixed(2); + } + + if (appInfo.local) { + appInfo.local.installed = true; + + if (appInfo.remote) { + appInfo.local.categories = appInfo.remote.categories; + appInfo.local.isPurchased = appInfo.remote.isPurchased; + appInfo.local.price = appInfo.remote.price; + appInfo.local.displayPrice = appInfo.remote.displayPrice; + + if (semver.gt(appInfo.remote.version, appInfo.local.version) && (appInfo.remote.isPurchased || appInfo.remote.price <= 0)) { + appInfo.local.newVersion = appInfo.remote.version; + } + } + + instance.onSettingUpdated({ appId: id }); + + Apps.getWsListener().unregisterListener(AppEvents.APP_STATUS_CHANGE, instance.onStatusChanged); + Apps.getWsListener().unregisterListener(AppEvents.APP_SETTING_UPDATED, instance.onSettingUpdated); + Apps.getWsListener().registerListener(AppEvents.APP_STATUS_CHANGE, instance.onStatusChanged); + Apps.getWsListener().registerListener(AppEvents.APP_SETTING_UPDATED, instance.onSettingUpdated); + } + + instance.app.set(appInfo.local || appInfo.remote); + instance.ready.set(true); + + if (appInfo.remote && appInfo.local) { + try { + return APIClient.get(`apps/${ id }?marketplace=true&update=true&appVersion=${ FlowRouter.getQueryParam('version') }`); + } catch (e) { + toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); + } + } + + return Promise.resolve(false); + }).then((updateInfo) => { + if (!updateInfo) { + return; + } + + const update = updateInfo.app; + + if (semver.gt(update.version, appInfo.local.version) && (update.isPurchased || update.price <= 0)) { + appInfo.local.newVersion = update.version; + + instance.app.set(appInfo.local); + } + }); +} + +function installAppFromEvent(e, t) { + const el = $(e.currentTarget); + el.prop('disabled', true); + el.addClass('loading'); + + const app = t.app.get(); + + const api = app.newVersion ? `apps/${ t.id.get() }` : 'apps/'; + + APIClient.post(api, { + appId: app.id, + marketplace: true, + version: app.version, + }).then(() => getApps(t)).then(() => { + el.prop('disabled', false); + el.removeClass('loading'); + }).catch((e) => { + el.prop('disabled', false); + el.removeClass('loading'); + t.hasError.set(true); + t.theError.set((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); + }); + + // play animation + // TODO this icon and animation are not working + $(e.currentTarget).find('.rc-icon').addClass('play'); +} + +Template.appManage.onCreated(function() { + const instance = this; + this.id = new ReactiveVar(FlowRouter.getParam('appId')); + this.ready = new ReactiveVar(false); + this.hasError = new ReactiveVar(false); + this.theError = new ReactiveVar(''); + this.processingEnabled = new ReactiveVar(false); + this.app = new ReactiveVar({}); + this.appsList = new ReactiveVar([]); + this.settings = new ReactiveVar({}); + this.apis = new ReactiveVar([]); + this.loading = new ReactiveVar(false); + + const id = this.id.get(); + getApps(instance); + + this.__ = (key, options, lang_tag) => { + const appKey = Utilities.getI18nKeyForApp(key, id); + return TAPi18next.exists(`project:${ appKey }`) ? TAPi18n.__(appKey, options, lang_tag) : TAPi18n.__(key, options, lang_tag); + }; + + function _morphSettings(settings) { + Object.keys(settings).forEach((k) => { + settings[k].i18nPlaceholder = settings[k].i18nPlaceholder || ' '; + settings[k].value = settings[k].value !== undefined && settings[k].value !== null ? settings[k].value : settings[k].packageValue; + settings[k].oldValue = settings[k].value; + settings[k].hasChanged = false; + }); + + instance.settings.set(settings); + } + + instance.onStatusChanged = function _onStatusChanged({ appId, status }) { + if (appId !== id) { + return; + } + + const app = instance.app.get(); + app.status = status; + instance.app.set(app); + }; + + instance.onSettingUpdated = function _onSettingUpdated({ appId }) { + if (appId !== id) { + return; + } + + APIClient.get(`apps/${ id }/settings`).then((result) => { + _morphSettings(result.settings); + }); + }; +}); + +Template.apps.onDestroyed(function() { + const instance = this; + + Apps.getWsListener().unregisterListener(AppEvents.APP_STATUS_CHANGE, instance.onStatusChanged); + Apps.getWsListener().unregisterListener(AppEvents.APP_SETTING_UPDATED, instance.onSettingUpdated); +}); + +Template.appManage.helpers({ + isEmail, + _(key, ...args) { + const options = (args.pop()).hash; + if (!_.isEmpty(args)) { + options.sprintf = args; + } + + return Template.instance().__(key, options); + }, + languages() { + const languages = TAPi18n.getLanguages(); + + let result = Object.keys(languages).map((key) => { + const language = languages[key]; + return _.extend(language, { key }); + }); + + result = _.sortBy(result, 'key'); + result.unshift({ + name: 'Default', + en: 'Default', + key: '', + }); + return result; + }, + appLanguage(key) { + const setting = settings.get('Language'); + return setting && setting.split('-').shift().toLowerCase() === key; + }, + selectedOption(_id, val) { + const settings = Template.instance().settings.get(); + return settings[_id].value === val; + }, + getColorVariable(color) { + return color.replace(/theme-color-/, '@'); + }, + dirty() { + const t = Template.instance(); + const settings = t.settings.get(); + return Object.keys(settings).some((k) => settings[k].hasChanged); + }, + disabled() { + const t = Template.instance(); + const settings = t.settings.get(); + return !Object.keys(settings).some((k) => settings[k].hasChanged); + }, + isReady() { + if (Template.instance().ready) { + return Template.instance().ready.get(); + } + + return false; + }, + hasError() { + if (Template.instance().hasError) { + return Template.instance().hasError.get(); + } + + return false; + }, + theError() { + if (Template.instance().theError) { + return Template.instance().theError.get(); + } + + return ''; + }, + isProcessingEnabled() { + if (Template.instance().processingEnabled) { + return Template.instance().processingEnabled.get(); + } + + return false; + }, + isEnabled() { + if (!Template.instance().app) { + return false; + } + + const info = Template.instance().app.get(); + + return info.status === 'auto_enabled' || info.status === 'manually_enabled'; + }, + isInstalled() { + const instance = Template.instance(); + + return instance.app.get().installed === true; + }, + hasPurchased() { + const instance = Template.instance(); + + return instance.app.get().isPurchased === true; + }, + app() { + return Template.instance().app.get(); + }, + categories() { + return Template.instance().app.get().categories; + }, + settings() { + return Object.values(Template.instance().settings.get()); + }, + apis() { + return Template.instance().apis.get(); + }, + parseDescription(i18nDescription) { + const item = Markdown.parseMessageNotEscaped({ html: Template.instance().__(i18nDescription) }); + + item.tokens.forEach((t) => item.html = item.html.replace(t.token, t.text)); + + return item.html; + }, + saving() { + return Template.instance().loading.get(); + }, + curl(method, api) { + const example = api.examples[method] || {}; + return Utilities.curl({ + url: Meteor.absoluteUrl.defaultOptions.rootUrl + api.computedPath, + method, + params: example.params, + query: example.query, + content: example.content, + headers: example.headers, + }).split('\n'); + }, + renderMethods(methods) { + return methods.join('|').toUpperCase(); + }, +}); + +async function setActivate(actiavate, e, t) { + t.processingEnabled.set(true); + + const el = $(e.currentTarget); + el.prop('disabled', true); + + const status = actiavate ? 'manually_enabled' : 'manually_disabled'; + + try { + const result = await APIClient.post(`apps/${ t.id.get() }/status`, { status }); + const info = t.app.get(); + info.status = result.status; + t.app.set(info); + } catch (e) { + toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); + } + t.processingEnabled.set(false); + el.prop('disabled', false); +} + +Template.appManage.events({ + 'click .expand': (e) => { + $(e.currentTarget).closest('.section').removeClass('section-collapsed'); + $(e.currentTarget).closest('button').removeClass('expand').addClass('collapse').find('span').text(TAPi18n.__('Collapse')); + $('.CodeMirror').each((index, codeMirror) => codeMirror.CodeMirror.refresh()); + }, + + 'click .collapse': (e) => { + $(e.currentTarget).closest('.section').addClass('section-collapsed'); + $(e.currentTarget).closest('button').addClass('expand').removeClass('collapse').find('span').text(TAPi18n.__('Expand')); + }, + + 'click .js-cancel'() { + FlowRouter.go('/admin/apps'); + }, + + 'click .js-activate'(e, t) { + setActivate(true, e, t); + }, + + 'click .js-deactivate'(e, t) { + setActivate(false, e, t); + }, + + 'click .js-uninstall': async (e, t) => { + t.ready.set(false); + try { + await APIClient.delete(`apps/${ t.id.get() }`); + FlowRouter.go('/admin/apps'); + } catch (err) { + console.warn('Error:', err); + } finally { + t.ready.set(true); + } + }, + + 'click .js-install': async (e, t) => { + installAppFromEvent(e, t); + }, + + 'click .js-purchase': (e, t) => { + const rl = t.app.get(); + + APIClient.get(`apps?buildBuyUrl=true&appId=${ rl.id }`) + .then((data) => { + data.successCallback = async () => { + installAppFromEvent(e, t); + }; + + modal.open({ + allowOutsideClick: false, + data, + template: 'iframeModal', + }); + }) + .catch((e) => { + toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); + }); + }, + + 'click .js-update': (e, t) => { + FlowRouter.go(`/admin/app/install?isUpdatingId=${ t.id.get() }`); + }, + + 'click .js-view-logs': (e, t) => { + FlowRouter.go(`/admin/apps/${ t.id.get() }/logs`, {}, { version: FlowRouter.getQueryParam('version') }); + }, + + 'click .js-cancel-editing': async (e, t) => { + t.onSettingUpdated({ appId: t.id.get() }); + }, + + 'click .js-save': async (e, t) => { + if (t.loading.get()) { + return; + } + t.loading.set(true); + const settings = t.settings.get(); + + + try { + const toSave = []; + Object.keys(settings).forEach((k) => { + const setting = settings[k]; + if (setting.hasChanged) { + toSave.push(setting); + } + // return !!setting.hasChanged; + }); + + if (toSave.length === 0) { + throw 'Nothing to save..'; + } + const result = await APIClient.post(`apps/${ t.id.get() }/settings`, undefined, { settings: toSave }); + console.log('Updating results:', result); + result.updated.forEach((setting) => { + settings[setting.id].value = settings[setting.id].oldValue = setting.value; + }); + Object.keys(settings).forEach((k) => { + const setting = settings[k]; + setting.hasChanged = false; + }); + t.settings.set(settings); + + } catch (e) { + console.log(e); + } finally { + t.loading.set(false); + } + + }, + + 'change input[type="checkbox"]': (e, t) => { + const labelFor = $(e.currentTarget).attr('name'); + const isChecked = $(e.currentTarget).prop('checked'); + + // $(`input[name="${ labelFor }"]`).prop('checked', !isChecked); + + const setting = t.settings.get()[labelFor]; + + if (setting) { + setting.value = isChecked; + t.settings.get()[labelFor].hasChanged = setting.oldValue !== setting.value; + t.settings.set(t.settings.get()); + } + }, + + 'change .rc-select__element' : (e, t) => { + const labelFor = $(e.currentTarget).attr('name'); + const value = $(e.currentTarget).val(); + + const setting = t.settings.get()[labelFor]; + + if (setting) { + setting.value = value; + t.settings.get()[labelFor].hasChanged = setting.oldValue !== setting.value; + t.settings.set(t.settings.get()); + } + }, + + 'input input, input textarea, change input[type="color"]': _.throttle(function(e, t) { + let value = s.trim($(e.target).val()); + + switch (this.type) { + case 'int': + value = parseInt(value); + break; + case 'boolean': + value = value === '1'; + break; + case 'code': + value = $(`.code-mirror-box[data-editor-id="${ this.id }"] .CodeMirror`)[0].CodeMirror.getValue(); + } + + const setting = t.settings.get()[this.id]; + + if (setting) { + setting.value = value; + + if (setting.oldValue !== setting.value) { + t.settings.get()[this.id].hasChanged = true; + t.settings.set(t.settings.get()); + } + } + }, 500), +}); + +Template.appManage.onRendered(() => { + Tracker.afterFlush(() => { + SideNav.setFlex('adminFlex'); + SideNav.openFlex(); + }); +}); diff --git a/packages/rocketchat-apps/client/admin/appWhatIsIt.html b/app/apps/client/admin/appWhatIsIt.html similarity index 100% rename from packages/rocketchat-apps/client/admin/appWhatIsIt.html rename to app/apps/client/admin/appWhatIsIt.html diff --git a/packages/rocketchat-apps/client/admin/appWhatIsIt.js b/app/apps/client/admin/appWhatIsIt.js similarity index 76% rename from packages/rocketchat-apps/client/admin/appWhatIsIt.js rename to app/apps/client/admin/appWhatIsIt.js index 5da60b4b05b9..a916eb25819c 100644 --- a/packages/rocketchat-apps/client/admin/appWhatIsIt.js +++ b/app/apps/client/admin/appWhatIsIt.js @@ -2,6 +2,9 @@ import { Meteor } from 'meteor/meteor'; import { ReactiveVar } from 'meteor/reactive-var'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { Template } from 'meteor/templating'; +import { Tracker } from 'meteor/tracker'; +import { Apps } from '../orchestrator'; +import { SideNav } from '../../../ui-utils/client'; Template.appWhatIsIt.onCreated(function() { this.isLoading = new ReactiveVar(false); @@ -36,9 +39,16 @@ Template.appWhatIsIt.events({ return; } - window.Apps.load(true); + Apps.load(true); FlowRouter.go('/admin/apps'); }); }, }); + +Template.appWhatIsIt.onRendered(() => { + Tracker.afterFlush(() => { + SideNav.setFlex('adminFlex'); + SideNav.openFlex(); + }); +}); diff --git a/app/apps/client/admin/apps.html b/app/apps/client/admin/apps.html new file mode 100644 index 000000000000..f716707f6c16 --- /dev/null +++ b/app/apps/client/admin/apps.html @@ -0,0 +1,100 @@ + diff --git a/app/apps/client/admin/apps.js b/app/apps/client/admin/apps.js new file mode 100644 index 000000000000..f310e08022fd --- /dev/null +++ b/app/apps/client/admin/apps.js @@ -0,0 +1,205 @@ +import toastr from 'toastr'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { Template } from 'meteor/templating'; +import { Tracker } from 'meteor/tracker'; +import { settings } from '../../../settings'; +import { t, APIClient } from '../../../utils'; +import { AppEvents } from '../communication'; +import { Apps } from '../orchestrator'; +import { SideNav } from '../../../ui-utils/client'; + +const ENABLED_STATUS = ['auto_enabled', 'manually_enabled']; +const enabled = ({ status }) => ENABLED_STATUS.includes(status); + +const sortByColumn = (array, column, inverted) => + array.sort((a, b) => { + if (a.latest[column] < b.latest[column] && !inverted) { + return -1; + } + return 1; + }); + +const getInstalledApps = async (instance) => { + try { + const data = await APIClient.get('apps'); + const apps = data.apps.map((app) => ({ latest: app })); + + instance.apps.set(apps); + } catch (e) { + toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); + } + + instance.isLoading.set(false); + instance.ready.set(true); +}; + +Template.apps.onCreated(function() { + const instance = this; + this.ready = new ReactiveVar(false); + this.apps = new ReactiveVar([]); + this.categories = new ReactiveVar([]); + this.searchText = new ReactiveVar(''); + this.searchSortBy = new ReactiveVar('name'); + this.sortDirection = new ReactiveVar('asc'); + this.limit = new ReactiveVar(0); + this.page = new ReactiveVar(0); + this.end = new ReactiveVar(false); + this.isLoading = new ReactiveVar(true); + + getInstalledApps(instance); + + try { + APIClient.get('apps?categories=true').then((data) => instance.categories.set(data)); + } catch (e) { + toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); + } + + instance.onAppAdded = function _appOnAppAdded() { + // ToDo: fix this formatting data to add an app to installedApps array without to fetch all + + // fetch(`${ HOST }/v1/apps/${ appId }`).then((result) => { + // const installedApps = instance.installedApps.get(); + + // installedApps.push({ + // latest: result.app, + // }); + // instance.installedApps.set(installedApps); + // }); + }; + + instance.onAppRemoved = function _appOnAppRemoved(appId) { + const apps = instance.apps.get(); + + let index = -1; + apps.find((item, i) => { + if (item.id === appId) { + index = i; + return true; + } + return false; + }); + + apps.splice(index, 1); + instance.apps.set(apps); + }; + + Apps.getWsListener().registerListener(AppEvents.APP_ADDED, instance.onAppAdded); + Apps.getWsListener().registerListener(AppEvents.APP_REMOVED, instance.onAppAdded); +}); + +Template.apps.onDestroyed(function() { + const instance = this; + + Apps.getWsListener().unregisterListener(AppEvents.APP_ADDED, instance.onAppAdded); + Apps.getWsListener().unregisterListener(AppEvents.APP_REMOVED, instance.onAppAdded); +}); + +Template.apps.helpers({ + isReady() { + if (Template.instance().ready != null) { + return Template.instance().ready.get(); + } + + return false; + }, + apps() { + const instance = Template.instance(); + const searchText = instance.searchText.get().toLowerCase(); + const sortColumn = instance.searchSortBy.get(); + const inverted = instance.sortDirection.get() === 'desc'; + return sortByColumn(instance.apps.get().filter((app) => app.latest.name.toLowerCase().includes(searchText)), sortColumn, inverted); + }, + categories() { + return Template.instance().categories.get(); + }, + appsDevelopmentMode() { + return settings.get('Apps_Framework_Development_Mode') === true; + }, + parseStatus(status) { + return t(`App_status_${ status }`); + }, + isActive(status) { + return enabled({ status }); + }, + sortIcon(key) { + const { + sortDirection, + searchSortBy, + } = Template.instance(); + + return key === searchSortBy.get() && sortDirection.get() !== 'asc' ? 'sort-up' : 'sort-down'; + }, + searchSortBy(key) { + return Template.instance().searchSortBy.get() === key; + }, + isLoading() { + return Template.instance().isLoading.get(); + }, + onTableScroll() { + const instance = Template.instance(); + if (instance.loading || instance.end.get()) { + return; + } + return function(currentTarget) { + if (currentTarget.offsetHeight + currentTarget.scrollTop >= currentTarget.scrollHeight - 100) { + return instance.page.set(instance.page.get() + 1); + } + }; + }, + onTableResize() { + const { limit } = Template.instance(); + + return function() { + limit.set(Math.ceil((this.$('.table-scroll').height() / 40) + 5)); + }; + }, + onTableSort() { + const { end, page, sortDirection, searchSortBy } = Template.instance(); + return function(type) { + end.set(false); + page.set(0); + + if (searchSortBy.get() === type) { + sortDirection.set(sortDirection.get() === 'asc' ? 'desc' : 'asc'); + return; + } + + searchSortBy.set(type); + sortDirection.set('asc'); + }; + }, + formatCategories(categories = []) { + return categories.join(', '); + }, +}); + +Template.apps.events({ + 'click .manage'() { + const rl = this; + + if (rl && rl.latest && rl.latest.id) { + FlowRouter.go(`/admin/apps/${ rl.latest.id }?version=${ rl.latest.version }`); + } + }, + 'click [data-button="install_app"]'() { + FlowRouter.go('marketplace'); + }, + 'click [data-button="upload_app"]'() { + FlowRouter.go('app-install'); + }, + 'keyup .js-search'(e, t) { + t.searchText.set(e.currentTarget.value); + }, + 'submit .js-search-form'(e) { + e.preventDefault(); + e.stopPropagation(); + }, +}); + +Template.apps.onRendered(() => { + Tracker.afterFlush(() => { + SideNav.setFlex('adminFlex'); + SideNav.openFlex(); + }); +}); diff --git a/app/apps/client/admin/marketplace.html b/app/apps/client/admin/marketplace.html new file mode 100644 index 000000000000..417f825e418a --- /dev/null +++ b/app/apps/client/admin/marketplace.html @@ -0,0 +1,130 @@ + diff --git a/app/apps/client/admin/marketplace.js b/app/apps/client/admin/marketplace.js new file mode 100644 index 000000000000..4ce551706819 --- /dev/null +++ b/app/apps/client/admin/marketplace.js @@ -0,0 +1,347 @@ +import toastr from 'toastr'; +import { Meteor } from 'meteor/meteor'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { Template } from 'meteor/templating'; +import { Tracker } from 'meteor/tracker'; +import { settings } from '../../../settings'; +import { t, APIClient } from '../../../utils'; +import { modal } from '../../../ui-utils'; +import { AppEvents } from '../communication'; +import { Apps } from '../orchestrator'; +import { SideNav } from '../../../ui-utils/client'; + +const ENABLED_STATUS = ['auto_enabled', 'manually_enabled']; +const enabled = ({ status }) => ENABLED_STATUS.includes(status); + +const sortByColumn = (array, column, inverted) => + array.sort((a, b) => { + if (a.latest[column] < b.latest[column] && !inverted) { + return -1; + } + return 1; + }); + +const tagAlreadyInstalledApps = (installedApps, apps) => { + const installedIds = installedApps.map((app) => app.latest.id); + + const tagged = apps.map((app) => + ({ + price: app.price, + isPurchased: app.isPurchased, + latest: { + ...app.latest, + _installed: installedIds.includes(app.latest.id), + }, + }) + ); + + return tagged; +}; + +const getApps = async (instance) => { + instance.isLoading.set(true); + + try { + const data = await APIClient.get('apps?marketplace=true'); + const tagged = tagAlreadyInstalledApps(instance.installedApps.get(), data); + + instance.apps.set(tagged); + } catch (e) { + toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); + } + + instance.isLoading.set(false); + instance.ready.set(true); +}; + +const getInstalledApps = async (instance) => { + try { + const data = await APIClient.get('apps'); + const apps = data.apps.map((app) => ({ latest: app })); + instance.installedApps.set(apps); + } catch (e) { + toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); + } +}; + +const getCloudLoggedIn = async (instance) => { + Meteor.call('cloud:checkUserLoggedIn', (error, result) => { + if (error) { + console.warn(error); + return; + } + + instance.cloudLoggedIn.set(result); + }); +}; + +Template.marketplace.onCreated(function() { + const instance = this; + this.ready = new ReactiveVar(false); + this.apps = new ReactiveVar([]); + this.installedApps = new ReactiveVar([]); + this.categories = new ReactiveVar([]); + this.searchText = new ReactiveVar(''); + this.searchSortBy = new ReactiveVar('name'); + this.sortDirection = new ReactiveVar('asc'); + this.limit = new ReactiveVar(0); + this.page = new ReactiveVar(0); + this.end = new ReactiveVar(false); + this.isLoading = new ReactiveVar(true); + this.cloudLoggedIn = new ReactiveVar(false); + + getInstalledApps(instance); + getApps(instance); + + try { + APIClient.get('apps?categories=true').then((data) => instance.categories.set(data)); + } catch (e) { + toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); + } + + instance.onAppAdded = function _appOnAppAdded() { + // ToDo: fix this formatting data to add an app to installedApps array without to fetch all + + // fetch(`${ HOST }/v1/apps/${ appId }`).then((result) => { + // const installedApps = instance.installedApps.get(); + + // installedApps.push({ + // latest: result.app, + // }); + // instance.installedApps.set(installedApps); + // }); + }; + + getCloudLoggedIn(instance); + + instance.onAppRemoved = function _appOnAppRemoved(appId) { + const apps = instance.apps.get(); + + let index = -1; + apps.find((item, i) => { + if (item.id === appId) { + index = i; + return true; + } + return false; + }); + + apps.splice(index, 1); + instance.apps.set(apps); + }; + + Apps.getWsListener().registerListener(AppEvents.APP_ADDED, instance.onAppAdded); + Apps.getWsListener().registerListener(AppEvents.APP_REMOVED, instance.onAppAdded); +}); + +Template.marketplace.onDestroyed(function() { + const instance = this; + + Apps.getWsListener().unregisterListener(AppEvents.APP_ADDED, instance.onAppAdded); + Apps.getWsListener().unregisterListener(AppEvents.APP_REMOVED, instance.onAppAdded); +}); + +Template.marketplace.helpers({ + isReady() { + if (Template.instance().ready != null) { + return Template.instance().ready.get(); + } + + return false; + }, + apps() { + const instance = Template.instance(); + const searchText = instance.searchText.get().toLowerCase(); + const sortColumn = instance.searchSortBy.get(); + const inverted = instance.sortDirection.get() === 'desc'; + return sortByColumn(instance.apps.get().filter((app) => app.latest.name.toLowerCase().includes(searchText)), sortColumn, inverted); + }, + categories() { + return Template.instance().categories.get(); + }, + appsDevelopmentMode() { + return settings.get('Apps_Framework_Development_Mode') === true; + }, + cloudLoggedIn() { + return Template.instance().cloudLoggedIn.get(); + }, + parseStatus(status) { + return t(`App_status_${ status }`); + }, + isActive(status) { + return enabled({ status }); + }, + sortIcon(key) { + const { + sortDirection, + searchSortBy, + } = Template.instance(); + + return key === searchSortBy.get() && sortDirection.get() !== 'asc' ? 'sort-up' : 'sort-down'; + }, + searchSortBy(key) { + return Template.instance().searchSortBy.get() === key; + }, + isLoading() { + return Template.instance().isLoading.get(); + }, + onTableScroll() { + const instance = Template.instance(); + if (instance.loading || instance.end.get()) { + return; + } + return function(currentTarget) { + if (currentTarget.offsetHeight + currentTarget.scrollTop >= currentTarget.scrollHeight - 100) { + return instance.page.set(instance.page.get() + 1); + } + }; + }, + onTableResize() { + const { limit } = Template.instance(); + + return function() { + limit.set(Math.ceil((this.$('.table-scroll').height() / 40) + 5)); + }; + }, + onTableSort() { + const { end, page, sortDirection, searchSortBy } = Template.instance(); + return function(type) { + end.set(false); + page.set(0); + + if (searchSortBy.get() === type) { + sortDirection.set(sortDirection.get() === 'asc' ? 'desc' : 'asc'); + return; + } + + searchSortBy.set(type); + sortDirection.set('asc'); + }; + }, + renderDownloadButton(latest) { + return latest._installed === false; + }, + formatPrice(price) { + return `$${ Number.parseFloat(price).toFixed(2) }`; + }, + formatCategories(categories = []) { + return categories.join(', '); + }, +}); + +Template.marketplace.events({ + 'click .manage'() { + const rl = this; + + if (rl && rl.latest && rl.latest.id) { + FlowRouter.go(`/admin/apps/${ rl.latest.id }?version=${ rl.latest.version }`); + } + }, + 'click [data-button="install"]'() { + FlowRouter.go('/admin/app/install'); + }, + 'click [data-button="login"]'() { + FlowRouter.go('/admin/cloud'); + }, + 'click .js-install'(e, template) { + e.stopPropagation(); + const elm = e.currentTarget.parentElement; + + elm.classList.add('loading'); + + APIClient.post('apps/', { + appId: this.latest.id, + marketplace: true, + version: this.latest.version, + }) + .then(async () => { + await Promise.all([ + getInstalledApps(template), + getApps(template), + ]); + elm.classList.remove('loading'); + }) + .catch((e) => { + toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); + elm.classList.remove('loading'); + }); + }, + 'click .js-purchase'(e, template) { + e.stopPropagation(); + + const rl = this; + + if (!template.cloudLoggedIn.get()) { + modal.open({ + title: t('Apps_Marketplace_Login_Required_Title'), + text: t('Apps_Marketplace_Login_Required_Description'), + type: 'info', + showCancelButton: true, + confirmButtonColor: '#DD6B55', + confirmButtonText: t('Login'), + cancelButtonText: t('Cancel'), + closeOnConfirm: true, + html: false, + }, function(confirmed) { + if (confirmed) { + FlowRouter.go('/admin/cloud'); + } + return; + }); + return; + } + + // play animation + const elm = e.currentTarget.parentElement; + + APIClient.get(`apps?buildBuyUrl=true&appId=${ rl.latest.id }`) + .then((data) => { + modal.open({ + allowOutsideClick: false, + data, + template: 'iframeModal', + }, () => { + elm.classList.add('loading'); + APIClient.post('apps/', { + appId: this.latest.id, + marketplace: true, + version: this.latest.version, + }) + .then(async () => { + await Promise.all([ + getInstalledApps(template), + getApps(template), + ]); + elm.classList.remove('loading'); + }) + .catch((e) => { + toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); + elm.classList.remove('loading'); + }); + }); + }) + .catch((e) => { + const errMsg = (e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message; + toastr.error(errMsg); + + if (errMsg === 'Unauthorized') { + getCloudLoggedIn(template); + } + }); + }, + 'keyup .js-search'(e, t) { + t.searchText.set(e.currentTarget.value); + }, + 'submit .js-search-form'(e) { + e.preventDefault(); + e.stopPropagation(); + }, +}); + +Template.marketplace.onRendered(() => { + Tracker.afterFlush(() => { + SideNav.setFlex('adminFlex'); + SideNav.openFlex(); + }); +}); diff --git a/app/apps/client/admin/modalTemplates/iframeModal.html b/app/apps/client/admin/modalTemplates/iframeModal.html new file mode 100644 index 000000000000..6902d2cd0db6 --- /dev/null +++ b/app/apps/client/admin/modalTemplates/iframeModal.html @@ -0,0 +1,7 @@ + diff --git a/app/apps/client/admin/modalTemplates/iframeModal.js b/app/apps/client/admin/modalTemplates/iframeModal.js new file mode 100644 index 000000000000..be84a750ebe1 --- /dev/null +++ b/app/apps/client/admin/modalTemplates/iframeModal.js @@ -0,0 +1,48 @@ +import { Template } from 'meteor/templating'; +import { modal } from '../../../../ui-utils'; + +Template.iframeModal.onCreated(function() { + const instance = this; + + instance.iframeMsgListener = function _iframeMsgListener(e) { + let data; + try { + data = JSON.parse(e.data); + } catch (e) { + return; + } + + if (data.result) { + if (typeof instance.data.successCallback === 'function') { + instance.data.successCallback().then(() => modal.confirm(data)); + } else { + modal.confirm(data); + } + } else { + modal.cancel(); + } + }; + + window.addEventListener('message', instance.iframeMsgListener); +}); + +Template.iframeModal.onRendered(function() { + const iframe = this.firstNode.querySelector('iframe'); + const loading = this.firstNode.querySelector('.loading'); + iframe.addEventListener('load', () => { + iframe.style.display = 'block'; + loading.style.display = 'none'; + }); +}); + +Template.iframeModal.onDestroyed(function() { + const instance = this; + + window.removeEventListener('message', instance.iframeMsgListener); +}); + +Template.iframeModal.helpers({ + data() { + return Template.instance().data; + }, +}); diff --git a/packages/rocketchat-apps/client/communication/index.js b/app/apps/client/communication/index.js similarity index 100% rename from packages/rocketchat-apps/client/communication/index.js rename to app/apps/client/communication/index.js diff --git a/packages/rocketchat-apps/client/communication/websockets.js b/app/apps/client/communication/websockets.js similarity index 82% rename from packages/rocketchat-apps/client/communication/websockets.js rename to app/apps/client/communication/websockets.js index a43c8312c62a..905c369650c8 100644 --- a/packages/rocketchat-apps/client/communication/websockets.js +++ b/app/apps/client/communication/websockets.js @@ -1,4 +1,6 @@ import { Meteor } from 'meteor/meteor'; +import { slashCommands, APIClient } from '../../../utils'; +import { CachedCollectionManager } from '../../../ui-cached-collection'; export const AppEvents = Object.freeze({ APP_ADDED: 'app/added', @@ -17,7 +19,7 @@ export class AppWebsocketReceiver { this.orch = orch; this.streamer = new Meteor.Streamer('apps'); - RocketChat.CachedCollectionManager.onLogin(() => { + CachedCollectionManager.onLogin(() => { this.listenStreamerEvents(); }); @@ -49,7 +51,7 @@ export class AppWebsocketReceiver { } onAppAdded(appId) { - RocketChat.API.get(`apps/${ appId }/languages`).then((result) => { + APIClient.get(`apps/${ appId }/languages`).then((result) => { this.orch.parseAndLoadLanguages(result.languages, appId); }); @@ -73,18 +75,18 @@ export class AppWebsocketReceiver { } onCommandAdded(command) { - RocketChat.API.v1.get('commands.get', { command }).then((result) => { - RocketChat.slashCommands.commands[command] = result.command; + APIClient.v1.get('commands.get', { command }).then((result) => { + slashCommands.commands[command] = result.command; }); } onCommandDisabled(command) { - delete RocketChat.slashCommands.commands[command]; + delete slashCommands.commands[command]; } onCommandUpdated(command) { - RocketChat.API.v1.get('commands.get', { command }).then((result) => { - RocketChat.slashCommands.commands[command] = result.command; + APIClient.v1.get('commands.get', { command }).then((result) => { + slashCommands.commands[command] = result.command; }); } } diff --git a/app/apps/client/index.js b/app/apps/client/index.js new file mode 100644 index 000000000000..f98839294a81 --- /dev/null +++ b/app/apps/client/index.js @@ -0,0 +1,16 @@ +export { Apps } from './orchestrator'; + +import './admin/modalTemplates/iframeModal.html'; +import './admin/modalTemplates/iframeModal'; +import './admin/marketplace.html'; +import './admin/marketplace'; +import './admin/apps.html'; +import './admin/apps'; +import './admin/appInstall.html'; +import './admin/appInstall'; +import './admin/appLogs.html'; +import './admin/appLogs'; +import './admin/appManage.html'; +import './admin/appManage'; +import './admin/appWhatIsIt.html'; +import './admin/appWhatIsIt'; diff --git a/app/apps/client/orchestrator.js b/app/apps/client/orchestrator.js new file mode 100644 index 000000000000..96962bbd0e0a --- /dev/null +++ b/app/apps/client/orchestrator.js @@ -0,0 +1,189 @@ +import { Meteor } from 'meteor/meteor'; +import { AppWebsocketReceiver } from './communication'; +import { Utilities } from '../lib/misc/Utilities'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { BlazeLayout } from 'meteor/kadira:blaze-layout'; +import { TAPi18next } from 'meteor/tap:i18n'; +import { APIClient } from '../../utils'; +import { AdminBox } from '../../ui-utils'; +import { CachedCollectionManager } from '../../ui-cached-collection'; +import { hasAtLeastOnePermission } from '../../authorization'; + +export let Apps; + +class AppClientOrchestrator { + constructor() { + this._isLoaded = false; + this._isEnabled = false; + this._loadingResolve; + this._refreshLoading(); + } + + isLoaded() { + return this._isLoaded; + } + + isEnabled() { + return this._isEnabled; + } + + getLoadingPromise() { + if (this._isLoaded) { + return Promise.resolve(this._isEnabled); + } + + return this._loadingPromise; + } + + load(isEnabled) { + console.log('Loading:', isEnabled); + this._isEnabled = isEnabled; + + // It was already loaded, so let's load it again + if (this._isLoaded) { + this._refreshLoading(); + } else { + this.ws = new AppWebsocketReceiver(this); + this._addAdminMenuOption(); + } + + Meteor.defer(() => { + this._loadLanguages().then(() => { + this._loadingResolve(this._isEnabled); + this._isLoaded = true; + }); + }); + } + + getWsListener() { + return this.ws; + } + + _refreshLoading() { + this._loadingPromise = new Promise((resolve) => { + this._loadingResolve = resolve; + }); + } + + _addAdminMenuOption() { + AdminBox.addOption({ + icon: 'cube', + href: 'apps', + i18nLabel: 'Apps', + permissionGranted() { + return hasAtLeastOnePermission(['manage-apps']); + }, + }); + + AdminBox.addOption({ + icon: 'cube', + href: 'marketplace', + i18nLabel: 'Marketplace', + permissionGranted() { + return hasAtLeastOnePermission(['manage-apps']); + }, + }); + } + + _loadLanguages() { + return APIClient.get('apps/languages').then((info) => { + info.apps.forEach((rlInfo) => this.parseAndLoadLanguages(rlInfo.languages, rlInfo.id)); + }); + } + + parseAndLoadLanguages(languages, id) { + Object.entries(languages).forEach(([language, translations]) => { + try { + translations = Object.entries(translations).reduce((newTranslations, [key, value]) => { + newTranslations[Utilities.getI18nKeyForApp(key, id)] = value; + return newTranslations; + }, {}); + + TAPi18next.addResourceBundle(language, 'project', translations); + } catch (e) { + // Failed to parse the json + } + }); + } + + async getAppApis(appId) { + const result = await APIClient.get(`apps/${ appId }/apis`); + return result.apis; + } +} + +Meteor.startup(function _rlClientOrch() { + Apps = new AppClientOrchestrator(); + + CachedCollectionManager.onLogin(() => { + Meteor.call('apps/is-enabled', (error, isEnabled) => { + Apps.load(isEnabled); + }); + }); +}); + +const appsRouteAction = function _theRealAction(whichCenter) { + Meteor.defer(() => Apps.getLoadingPromise().then((isEnabled) => { + if (isEnabled) { + BlazeLayout.render('main', { center: whichCenter, old: true }); // TODO remove old + } else { + FlowRouter.go('app-what-is-it'); + } + })); +}; + +// Bah, this has to be done *before* `Meteor.startup` +FlowRouter.route('/admin/marketplace', { + name: 'marketplace', + action() { + appsRouteAction('marketplace'); + }, +}); + +FlowRouter.route('/admin/marketplace/:itemId', { + name: 'app-manage', + action() { + appsRouteAction('appManage'); + }, +}); + +FlowRouter.route('/admin/apps', { + name: 'apps', + action() { + appsRouteAction('apps'); + }, +}); + +FlowRouter.route('/admin/app/install', { + name: 'app-install', + action() { + appsRouteAction('appInstall'); + }, +}); + +FlowRouter.route('/admin/apps/:appId', { + name: 'app-manage', + action() { + appsRouteAction('appManage'); + }, +}); + +FlowRouter.route('/admin/apps/:appId/logs', { + name: 'app-logs', + action() { + appsRouteAction('appLogs'); + }, +}); + +FlowRouter.route('/admin/app/what-is-it', { + name: 'app-what-is-it', + action() { + Meteor.defer(() => Apps.getLoadingPromise().then((isEnabled) => { + if (isEnabled) { + FlowRouter.go('apps'); + } else { + BlazeLayout.render('main', { center: 'appWhatIsIt' }); + } + })); + }, +}); diff --git a/packages/rocketchat-apps/lib/misc/Utilities.js b/app/apps/lib/misc/Utilities.js similarity index 100% rename from packages/rocketchat-apps/lib/misc/Utilities.js rename to app/apps/lib/misc/Utilities.js diff --git a/app/apps/lib/misc/transformMappedData.js b/app/apps/lib/misc/transformMappedData.js new file mode 100644 index 000000000000..0bba2c646081 --- /dev/null +++ b/app/apps/lib/misc/transformMappedData.js @@ -0,0 +1,85 @@ +import cloneDeep from 'lodash.clonedeep'; + +/** + * Transforms a `data` source object to another object, + * essentially applying a to -> from mapping provided by + * `map`. It does not mutate the `data` object. + * + * It also inserts in the `transformedObject` a new property + * called `_unmappedProperties_` which contains properties from + * the original `data` that have not been mapped to its transformed + * counterpart. E.g.: + * + * ```javascript + * const data = { _id: 'abcde123456', size: 10 }; + * const map = { id: '_id' } + * + * transformMappedData(data, map); + * // { id: 'abcde123456', _unmappedProperties_: { size: 10 } } + * ``` + * + * In order to compute the unmapped properties, this function will + * ignore any property on `data` that has been named on the "from" part + * of the `map`, and will consider properties not mentioned as unmapped. + * + * You can also define the "from" part as a function, so you can derive a + * new value for your property from the original `data`. This function will + * receive a copy of the original `data` for it to calculate the value + * for its "to" field. Please note that in this case `transformMappedData` + * will not be able to determine the source field from your map, so it won't + * ignore any field you've used to derive your new value. For that, you're + * going to need to delete the value from the received parameter. E.g: + * + * ```javascript + * const data = { _id: 'abcde123456', size: 10 }; + * + * // It will look like the `size` property is not mapped + * const map = { + * id: '_id', + * newSize: (data) => data.size + 10 + * }; + * + * transformMappedData(data, map); + * // { id: 'abcde123456', newSize: 20, _unmappedProperties_: { size: 10 } } + * + * // You need to explicitly remove it from the original `data` + * const map = { + * id: '_id', + * newSize: (data) => { + * const result = data.size + 10; + * delete data.size; + * return result; + * } + * }; + * + * transformMappedData(data, map); + * // { id: 'abcde123456', newSize: 20, _unmappedProperties_: {} } + * ``` + * + * @param Object data The data to be transformed + * @param Object map The map with transformations to be applied + * + * @returns Object The data after transformations have been applied + */ + +export const transformMappedData = (data, map) => { + const originalData = cloneDeep(data); + const transformedData = {}; + + Object.entries(map).forEach(([to, from]) => { + if (typeof from === 'function') { + const result = from(originalData); + + if (typeof result !== 'undefined') { + transformedData[to] = result; + } + } else if (typeof from === 'string' && typeof originalData[from] !== 'undefined') { + transformedData[to] = originalData[from]; + delete originalData[from]; + } + }); + + transformedData._unmappedProperties_ = originalData; + + return transformedData; +}; diff --git a/packages/rocketchat-apps/server/bridges/activation.js b/app/apps/server/bridges/activation.js similarity index 100% rename from packages/rocketchat-apps/server/bridges/activation.js rename to app/apps/server/bridges/activation.js diff --git a/app/apps/server/bridges/api.js b/app/apps/server/bridges/api.js new file mode 100644 index 000000000000..47801c7d644a --- /dev/null +++ b/app/apps/server/bridges/api.js @@ -0,0 +1,114 @@ +import { Meteor } from 'meteor/meteor'; +import express from 'express'; +import { WebApp } from 'meteor/webapp'; + +const apiServer = express(); + +apiServer.disable('x-powered-by'); + +WebApp.connectHandlers.use(apiServer); + +export class AppApisBridge { + constructor(orch) { + this.orch = orch; + this.appRouters = new Map(); + + // apiServer.use('/api/apps', (req, res, next) => { + // this.orch.debugLog({ + // method: req.method.toLowerCase(), + // url: req.url, + // query: req.query, + // body: req.body, + // }); + // next(); + // }); + + apiServer.use('/api/apps/private/:appId/:hash', (req, res) => { + const notFound = () => res.send(404); + + const router = this.appRouters.get(req.params.appId); + + if (router) { + req._privateHash = req.params.hash; + return router(req, res, notFound); + } + + notFound(); + }); + + apiServer.use('/api/apps/public/:appId', (req, res) => { + const notFound = () => res.send(404); + + const router = this.appRouters.get(req.params.appId); + + if (router) { + return router(req, res, notFound); + } + + notFound(); + }); + } + + registerApi({ api, computedPath, endpoint }, appId) { + this.orch.debugLog(`The App ${ appId } is registering the api: "${ endpoint.path }" (${ computedPath })`); + + this._verifyApi(api, endpoint); + + if (!this.appRouters.get(appId)) { + this.appRouters.set(appId, express.Router()); // eslint-disable-line + } + + const router = this.appRouters.get(appId); + + const method = api.method || 'all'; + + let routePath = endpoint.path.trim(); + if (!routePath.startsWith('/')) { + routePath = `/${ routePath }`; + } + + router[method](routePath, Meteor.bindEnvironment(this._appApiExecutor(api, endpoint, appId))); + } + + unregisterApis(appId) { + this.orch.debugLog(`The App ${ appId } is unregistering all apis`); + + if (this.appRouters.get(appId)) { + this.appRouters.delete(appId); + } + } + + _verifyApi(api, endpoint) { + if (typeof api !== 'object') { + throw new Error('Invalid Api parameter provided, it must be a valid IApi object.'); + } + + if (typeof endpoint.path !== 'string') { + throw new Error('Invalid Api parameter provided, it must be a valid IApi object.'); + } + } + + _appApiExecutor(api, endpoint, appId) { + return (req, res) => { + const request = { + method: req.method.toLowerCase(), + headers: req.headers, + query: req.query || {}, + params: req.params || {}, + content: req.body, + privateHash: req._privateHash, + }; + + this.orch.getManager().getApiManager().executeApi(appId, endpoint.path, request) + .then(({ status, headers = {}, content }) => { + res.set(headers); + res.status(status); + res.send(content); + }) + .catch((reason) => { + // Should we handle this as an error? + res.status(500).send(reason.message); + }); + }; + } +} diff --git a/packages/rocketchat-apps/server/bridges/bridges.js b/app/apps/server/bridges/bridges.js similarity index 97% rename from packages/rocketchat-apps/server/bridges/bridges.js rename to app/apps/server/bridges/bridges.js index f79a3b54cb21..5781842fb766 100644 --- a/packages/rocketchat-apps/server/bridges/bridges.js +++ b/app/apps/server/bridges/bridges.js @@ -23,7 +23,7 @@ export class RealAppBridges extends AppBridges { this._apiBridge = new AppApisBridge(orch); this._detBridge = new AppDetailChangesBridge(orch); this._envBridge = new AppEnvironmentalVariableBridge(orch); - this._httpBridge = new AppHttpBridge(); + this._httpBridge = new AppHttpBridge(orch); this._lisnBridge = new AppListenerBridge(orch); this._msgBridge = new AppMessageBridge(orch); this._persistBridge = new AppPersistenceBridge(orch); diff --git a/app/apps/server/bridges/commands.js b/app/apps/server/bridges/commands.js new file mode 100644 index 000000000000..a4895106dfd8 --- /dev/null +++ b/app/apps/server/bridges/commands.js @@ -0,0 +1,172 @@ +import { Meteor } from 'meteor/meteor'; +import { slashCommands } from '../../../utils'; +import { SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands'; +import { Utilities } from '../../lib/misc/Utilities'; + +export class AppCommandsBridge { + constructor(orch) { + this.orch = orch; + this.disabledCommands = new Map(); + } + + doesCommandExist(command, appId) { + this.orch.debugLog(`The App ${ appId } is checking if "${ command }" command exists.`); + + if (typeof command !== 'string' || command.length === 0) { + return false; + } + + const cmd = command.toLowerCase(); + return typeof slashCommands.commands[cmd] === 'object' || this.disabledCommands.has(cmd); + } + + enableCommand(command, appId) { + this.orch.debugLog(`The App ${ appId } is attempting to enable the command: "${ command }"`); + + if (typeof command !== 'string' || command.trim().length === 0) { + throw new Error('Invalid command parameter provided, must be a string.'); + } + + const cmd = command.toLowerCase(); + if (!this.disabledCommands.has(cmd)) { + throw new Error(`The command is not currently disabled: "${ cmd }"`); + } + + slashCommands.commands[cmd] = this.disabledCommands.get(cmd); + this.disabledCommands.delete(cmd); + + this.orch.getNotifier().commandUpdated(cmd); + } + + disableCommand(command, appId) { + this.orch.debugLog(`The App ${ appId } is attempting to disable the command: "${ command }"`); + + if (typeof command !== 'string' || command.trim().length === 0) { + throw new Error('Invalid command parameter provided, must be a string.'); + } + + const cmd = command.toLowerCase(); + if (this.disabledCommands.has(cmd)) { + // The command is already disabled, no need to disable it yet again + return; + } + + if (typeof slashCommands.commands[cmd] === 'undefined') { + throw new Error(`Command does not exist in the system currently: "${ cmd }"`); + } + + this.disabledCommands.set(cmd, slashCommands.commands[cmd]); + delete slashCommands.commands[cmd]; + + this.orch.getNotifier().commandDisabled(cmd); + } + + // command: { command, paramsExample, i18nDescription, executor: function } + modifyCommand(command, appId) { + this.orch.debugLog(`The App ${ appId } is attempting to modify the command: "${ command }"`); + + this._verifyCommand(command); + + const cmd = command.toLowerCase(); + if (typeof slashCommands.commands[cmd] === 'undefined') { + throw new Error(`Command does not exist in the system currently (or it is disabled): "${ cmd }"`); + } + + const item = slashCommands.commands[cmd]; + item.params = command.paramsExample ? command.paramsExample : item.params; + item.description = command.i18nDescription ? command.i18nDescription : item.params; + item.callback = this._appCommandExecutor.bind(this); + item.providesPreview = command.providesPreview; + item.previewer = command.previewer ? this._appCommandPreviewer.bind(this) : item.previewer; + item.previewCallback = command.executePreviewItem ? this._appCommandPreviewExecutor.bind(this) : item.previewCallback; + + slashCommands.commands[cmd] = item; + this.orch.getNotifier().commandUpdated(cmd); + } + + registerCommand(command, appId) { + this.orch.debugLog(`The App ${ appId } is registering the command: "${ command.command }"`); + + this._verifyCommand(command); + + const item = { + command: command.command.toLowerCase(), + params: Utilities.getI18nKeyForApp(command.i18nParamsExample, appId), + description: Utilities.getI18nKeyForApp(command.i18nDescription, appId), + callback: this._appCommandExecutor.bind(this), + providesPreview: command.providesPreview, + previewer: !command.previewer ? undefined : this._appCommandPreviewer.bind(this), + previewCallback: !command.executePreviewItem ? undefined : this._appCommandPreviewExecutor.bind(this), + }; + + slashCommands.commands[command.command.toLowerCase()] = item; + this.orch.getNotifier().commandAdded(command.command.toLowerCase()); + } + + unregisterCommand(command, appId) { + this.orch.debugLog(`The App ${ appId } is unregistering the command: "${ command }"`); + + if (typeof command !== 'string' || command.trim().length === 0) { + throw new Error('Invalid command parameter provided, must be a string.'); + } + + const cmd = command.toLowerCase(); + this.disabledCommands.delete(cmd); + delete slashCommands.commands[cmd]; + + this.orch.getNotifier().commandRemoved(cmd); + } + + _verifyCommand(command) { + if (typeof command !== 'object') { + throw new Error('Invalid Slash Command parameter provided, it must be a valid ISlashCommand object.'); + } + + if (typeof command.command !== 'string') { + throw new Error('Invalid Slash Command parameter provided, it must be a valid ISlashCommand object.'); + } + + if (command.i18nParamsExample && typeof command.i18nParamsExample !== 'string') { + throw new Error('Invalid Slash Command parameter provided, it must be a valid ISlashCommand object.'); + } + + if (command.i18nDescription && typeof command.i18nDescription !== 'string') { + throw new Error('Invalid Slash Command parameter provided, it must be a valid ISlashCommand object.'); + } + + if (typeof command.providesPreview !== 'boolean') { + throw new Error('Invalid Slash Command parameter provided, it must be a valid ISlashCommand object.'); + } + + if (typeof command.executor !== 'function') { + throw new Error('Invalid Slash Command parameter provided, it must be a valid ISlashCommand object.'); + } + } + + _appCommandExecutor(command, parameters, message) { + const user = this.orch.getConverters().get('users').convertById(Meteor.userId()); + const room = this.orch.getConverters().get('rooms').convertById(message.rid); + const params = parameters.length === 0 || parameters === ' ' ? [] : parameters.split(' '); + + const context = new SlashCommandContext(Object.freeze(user), Object.freeze(room), Object.freeze(params)); + Promise.await(this.orch.getManager().getCommandManager().executeCommand(command, context)); + } + + _appCommandPreviewer(command, parameters, message) { + const user = this.orch.getConverters().get('users').convertById(Meteor.userId()); + const room = this.orch.getConverters().get('rooms').convertById(message.rid); + const params = parameters.length === 0 || parameters === ' ' ? [] : parameters.split(' '); + + const context = new SlashCommandContext(Object.freeze(user), Object.freeze(room), Object.freeze(params)); + return Promise.await(this.orch.getManager().getCommandManager().getPreviews(command, context)); + } + + _appCommandPreviewExecutor(command, parameters, message, preview) { + const user = this.orch.getConverters().get('users').convertById(Meteor.userId()); + const room = this.orch.getConverters().get('rooms').convertById(message.rid); + const params = parameters.length === 0 || parameters === ' ' ? [] : parameters.split(' '); + + const context = new SlashCommandContext(Object.freeze(user), Object.freeze(room), Object.freeze(params)); + Promise.await(this.orch.getManager().getCommandManager().executePreview(command, preview, context)); + } +} diff --git a/packages/rocketchat-apps/server/bridges/details.js b/app/apps/server/bridges/details.js similarity index 100% rename from packages/rocketchat-apps/server/bridges/details.js rename to app/apps/server/bridges/details.js diff --git a/app/apps/server/bridges/environmental.js b/app/apps/server/bridges/environmental.js new file mode 100644 index 000000000000..ae8879a36bbf --- /dev/null +++ b/app/apps/server/bridges/environmental.js @@ -0,0 +1,32 @@ +export class AppEnvironmentalVariableBridge { + constructor(orch) { + this.orch = orch; + this.allowed = ['NODE_ENV', 'ROOT_URL', 'INSTANCE_IP']; + } + + async getValueByName(envVarName, appId) { + this.orch.debugLog(`The App ${ appId } is getting the environmental variable value ${ envVarName }.`); + + if (!(await this.isReadable(envVarName, appId))) { + throw new Error(`The environmental variable "${ envVarName }" is not readable.`); + } + + return process.env[envVarName]; + } + + async isReadable(envVarName, appId) { + this.orch.debugLog(`The App ${ appId } is checking if the environmental variable is readable ${ envVarName }.`); + + return this.allowed.includes(envVarName.toUpperCase()); + } + + async isSet(envVarName, appId) { + this.orch.debugLog(`The App ${ appId } is checking if the environmental variable is set ${ envVarName }.`); + + if (!(await this.isReadable(envVarName, appId))) { + throw new Error(`The environmental variable "${ envVarName }" is not readable.`); + } + + return typeof process.env[envVarName] !== 'undefined'; + } +} diff --git a/app/apps/server/bridges/http.js b/app/apps/server/bridges/http.js new file mode 100644 index 000000000000..743343fa12c9 --- /dev/null +++ b/app/apps/server/bridges/http.js @@ -0,0 +1,21 @@ +import { HTTP } from 'meteor/http'; + +export class AppHttpBridge { + constructor(orch) { + this.orch = orch; + } + + async call(info) { + if (!info.request.content && typeof info.request.data === 'object') { + info.request.content = JSON.stringify(info.request.data); + } + + this.orch.debugLog(`The App ${ info.appId } is requesting from the outter webs:`, info); + + try { + return HTTP.call(info.method, info.url, info.request); + } catch (e) { + return e.response; + } + } +} diff --git a/packages/rocketchat-apps/server/bridges/index.js b/app/apps/server/bridges/index.js similarity index 100% rename from packages/rocketchat-apps/server/bridges/index.js rename to app/apps/server/bridges/index.js diff --git a/app/apps/server/bridges/internal.js b/app/apps/server/bridges/internal.js new file mode 100644 index 000000000000..2a41c5829897 --- /dev/null +++ b/app/apps/server/bridges/internal.js @@ -0,0 +1,21 @@ +import { Subscriptions } from '../../../models'; + +export class AppInternalBridge { + constructor(orch) { + this.orch = orch; + } + + getUsernamesOfRoomById(roomId) { + const records = Subscriptions.findByRoomIdWhenUsernameExists(roomId, { + fields: { + 'u.username': 1, + }, + }).fetch(); + + if (!records || records.length === 0) { + return []; + } + + return records.map((s) => s.u.username); + } +} diff --git a/packages/rocketchat-apps/server/bridges/listeners.js b/app/apps/server/bridges/listeners.js similarity index 82% rename from packages/rocketchat-apps/server/bridges/listeners.js rename to app/apps/server/bridges/listeners.js index d62edfcf0f07..d8a08d0a9444 100644 --- a/packages/rocketchat-apps/server/bridges/listeners.js +++ b/app/apps/server/bridges/listeners.js @@ -15,8 +15,8 @@ export class AppListenerBridge { // try { // } catch (e) { - // console.log(`${ e.name }: ${ e.message }`); - // console.log(e.stack); + // this.orch.debugLog(`${ e.name }: ${ e.message }`); + // this.orch.debugLog(e.stack); // } } @@ -32,8 +32,8 @@ export class AppListenerBridge { // try { // } catch (e) { - // console.log(`${ e.name }: ${ e.message }`); - // console.log(e.stack); + // this.orch.debugLog(`${ e.name }: ${ e.message }`); + // this.orch.debugLog(e.stack); // } } } diff --git a/app/apps/server/bridges/messages.js b/app/apps/server/bridges/messages.js new file mode 100644 index 000000000000..5ba76526a84a --- /dev/null +++ b/app/apps/server/bridges/messages.js @@ -0,0 +1,83 @@ +import { Meteor } from 'meteor/meteor'; +import { Random } from 'meteor/random'; +import { Messages, Users, Subscriptions } from '../../../models'; +import { Notifications } from '../../../notifications'; +import { updateMessage } from '../../../lib/server/functions/updateMessage'; + +export class AppMessageBridge { + constructor(orch) { + this.orch = orch; + } + + async create(message, appId) { + this.orch.debugLog(`The App ${ appId } is creating a new message.`); + + let msg = this.orch.getConverters().get('messages').convertAppMessage(message); + + Meteor.runAsUser(msg.u._id, () => { + msg = Meteor.call('sendMessage', msg); + }); + + return msg._id; + } + + async getById(messageId, appId) { + this.orch.debugLog(`The App ${ appId } is getting the message: "${ messageId }"`); + + return this.orch.getConverters().get('messages').convertById(messageId); + } + + async update(message, appId) { + this.orch.debugLog(`The App ${ appId } is updating a message.`); + + if (!message.editor) { + throw new Error('Invalid editor assigned to the message for the update.'); + } + + if (!message.id || !Messages.findOneById(message.id)) { + throw new Error('A message must exist to update.'); + } + + const msg = this.orch.getConverters().get('messages').convertAppMessage(message); + const editor = Users.findOneById(message.editor.id); + + updateMessage(msg, editor); + } + + async notifyUser(user, message, appId) { + this.orch.debugLog(`The App ${ appId } is notifying a user.`); + + const msg = this.orch.getConverters().get('messages').convertAppMessage(message); + + Notifications.notifyUser(user.id, 'message', Object.assign(msg, { + _id: Random.id(), + ts: new Date(), + u: undefined, + editor: undefined, + })); + } + + async notifyRoom(room, message, appId) { + this.orch.debugLog(`The App ${ appId } is notifying a room's users.`); + + if (room) { + const msg = this.orch.getConverters().get('messages').convertAppMessage(message); + const rmsg = Object.assign(msg, { + _id: Random.id(), + rid: room.id, + ts: new Date(), + u: undefined, + editor: undefined, + }); + + const users = Subscriptions.findByRoomIdWhenUserIdExists(room._id, { fields: { 'u._id': 1 } }) + .fetch() + .map((s) => s.u._id); + Users.findByIds(users, { fields: { _id: 1 } }) + .fetch() + .forEach(({ _id }) => + Notifications.notifyUser(_id, 'message', rmsg) + ); + } + } +} diff --git a/app/apps/server/bridges/persistence.js b/app/apps/server/bridges/persistence.js new file mode 100644 index 000000000000..47a6f2639e23 --- /dev/null +++ b/app/apps/server/bridges/persistence.js @@ -0,0 +1,110 @@ +export class AppPersistenceBridge { + constructor(orch) { + this.orch = orch; + } + + async purge(appId) { + this.orch.debugLog(`The App's persistent storage is being purged: ${ appId }`); + + this.orch.getPersistenceModel().remove({ appId }); + } + + async create(data, appId) { + this.orch.debugLog(`The App ${ appId } is storing a new object in their persistence.`, data); + + if (typeof data !== 'object') { + throw new Error('Attempted to store an invalid data type, it must be an object.'); + } + + return this.orch.getPersistenceModel().insert({ appId, data }); + } + + async createWithAssociations(data, associations, appId) { + this.orch.debugLog(`The App ${ appId } is storing a new object in their persistence that is associated with some models.`, data, associations); + + if (typeof data !== 'object') { + throw new Error('Attempted to store an invalid data type, it must be an object.'); + } + + return this.orch.getPersistenceModel().insert({ appId, associations, data }); + } + + async readById(id, appId) { + this.orch.debugLog(`The App ${ appId } is reading their data in their persistence with the id: "${ id }"`); + + const record = this.orch.getPersistenceModel().findOneById(id); + + return record.data; + } + + async readByAssociations(associations, appId) { + this.orch.debugLog(`The App ${ appId } is searching for records that are associated with the following:`, associations); + + const records = this.orch.getPersistenceModel().find({ + appId, + associations: { $all: associations }, + }).fetch(); + + return Array.isArray(records) ? records.map((r) => r.data) : []; + } + + async remove(id, appId) { + this.orch.debugLog(`The App ${ appId } is removing one of their records by the id: "${ id }"`); + + const record = this.orch.getPersistenceModel().findOne({ _id: id, appId }); + + if (!record) { + return undefined; + } + + this.orch.getPersistenceModel().remove({ _id: id, appId }); + + return record.data; + } + + async removeByAssociations(associations, appId) { + this.orch.debugLog(`The App ${ appId } is removing records with the following associations:`, associations); + + const query = { + appId, + associations: { + $all: associations, + }, + }; + + const records = this.orch.getPersistenceModel().find(query).fetch(); + + if (!records) { + return undefined; + } + + this.orch.getPersistenceModel().remove(query); + + return Array.isArray(records) ? records.map((r) => r.data) : []; + } + + async update(id, data, upsert, appId) { + this.orch.debugLog(`The App ${ appId } is updating the record "${ id }" to:`, data); + + if (typeof data !== 'object') { + throw new Error('Attempted to store an invalid data type, it must be an object.'); + } + + throw new Error('Not implemented.'); + } + + async updateByAssociations(associations, data, upsert, appId) { + this.orch.debugLog(`The App ${ appId } is updating the record with association to data as follows:`, associations, data); + + if (typeof data !== 'object') { + throw new Error('Attempted to store an invalid data type, it must be an object.'); + } + + const query = { + appId, + associations, + }; + + return this.orch.getPersistenceModel().upsert(query, { $set: { data } }, { upsert }); + } +} diff --git a/app/apps/server/bridges/rooms.js b/app/apps/server/bridges/rooms.js new file mode 100644 index 000000000000..4d9f4e0d163c --- /dev/null +++ b/app/apps/server/bridges/rooms.js @@ -0,0 +1,123 @@ +import { Meteor } from 'meteor/meteor'; +import { Rooms, Subscriptions, Users } from '../../../models'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; +import { addUserToRoom } from '../../../lib/server/functions/addUserToRoom'; + +export class AppRoomBridge { + constructor(orch) { + this.orch = orch; + } + + async create(room, members, appId) { + this.orch.debugLog(`The App ${ appId } is creating a new room.`, room); + + const rcRoom = this.orch.getConverters().get('rooms').convertAppRoom(room); + let method; + + switch (room.type) { + case RoomType.CHANNEL: + method = 'createChannel'; + break; + case RoomType.PRIVATE_GROUP: + method = 'createPrivateGroup'; + break; + case RoomType.DIRECT_MESSAGE: + method = 'createDirectMessage'; + break; + default: + throw new Error('Only channels, private groups and direct messages can be created.'); + } + + let rid; + Meteor.runAsUser(room.creator.id, () => { + const extraData = Object.assign({}, rcRoom); + delete extraData.name; + delete extraData.t; + delete extraData.ro; + delete extraData.customFields; + let info; + if (room.type === RoomType.DIRECT_MESSAGE) { + members.splice(members.indexOf(room.creator.username), 1); + info = Meteor.call(method, members[0]); + } else { + info = Meteor.call(method, rcRoom.name, members, rcRoom.ro, rcRoom.customFields, extraData); + } + rid = info.rid; + }); + + return rid; + } + + async getById(roomId, appId) { + this.orch.debugLog(`The App ${ appId } is getting the roomById: "${ roomId }"`); + + return this.orch.getConverters().get('rooms').convertById(roomId); + } + + async getByName(roomName, appId) { + this.orch.debugLog(`The App ${ appId } is getting the roomByName: "${ roomName }"`); + + return this.orch.getConverters().get('rooms').convertByName(roomName); + } + + async getCreatorById(roomId, appId) { + this.orch.debugLog(`The App ${ appId } is getting the room's creator by id: "${ roomId }"`); + + const room = Rooms.findOneById(roomId); + + if (!room || !room.u || !room.u._id) { + return undefined; + } + + return this.orch.getConverters().get('users').convertById(room.u._id); + } + + async getCreatorByName(roomName, appId) { + this.orch.debugLog(`The App ${ appId } is getting the room's creator by name: "${ roomName }"`); + + const room = Rooms.findOneByName(roomName); + + if (!room || !room.u || !room.u._id) { + return undefined; + } + + return this.orch.getConverters().get('users').convertById(room.u._id); + } + + async getMembers(roomId, appId) { + this.orch.debugLog(`The App ${ appId } is getting the room's members by room id: "${ roomId }"`); + const subscriptions = await Subscriptions.findByRoomId(roomId); + return subscriptions.map((sub) => this.orch.getConverters().get('users').convertById(sub.u && sub.u._id)); + } + + async getDirectByUsernames(usernames, appId) { + this.orch.debugLog(`The App ${ appId } is getting direct room by usernames: "${ usernames }"`); + const room = await Rooms.findDirectRoomContainingAllUsernames(usernames); + if (!room) { + return undefined; + } + return this.orch.getConverters().get('rooms').convertRoom(room); + } + + async update(room, members = [], appId) { + this.orch.debugLog(`The App ${ appId } is updating a room.`); + + if (!room.id || !Rooms.findOneById(room.id)) { + throw new Error('A room must exist to update.'); + } + + const rm = this.orch.getConverters().get('rooms').convertAppRoom(room); + + Rooms.update(rm._id, rm); + + for (const username of members) { + const member = Users.findOneByUsername(username); + + if (!member) { + continue; + } + + addUserToRoom(rm._id, member); + } + } +} diff --git a/app/apps/server/bridges/settings.js b/app/apps/server/bridges/settings.js new file mode 100644 index 000000000000..745010344c3b --- /dev/null +++ b/app/apps/server/bridges/settings.js @@ -0,0 +1,74 @@ +import { Settings } from '../../../models'; + +export class AppSettingBridge { + constructor(orch) { + this.orch = orch; + this.allowedGroups = []; + this.disallowedSettings = [ + 'Accounts_RegistrationForm_SecretURL', 'CROWD_APP_USERNAME', 'CROWD_APP_PASSWORD', 'Direct_Reply_Username', + 'Direct_Reply_Password', 'SMTP_Username', 'SMTP_Password', 'FileUpload_S3_AWSAccessKeyId', 'FileUpload_S3_AWSSecretAccessKey', + 'FileUpload_S3_BucketURL', 'FileUpload_GoogleStorage_Bucket', 'FileUpload_GoogleStorage_AccessId', + 'FileUpload_GoogleStorage_Secret', 'GoogleVision_ServiceAccount', 'Allow_Invalid_SelfSigned_Certs', 'GoogleTagManager_id', + 'Bugsnag_api_key', 'LDAP_CA_Cert', 'LDAP_Reject_Unauthorized', 'LDAP_Domain_Search_User', 'LDAP_Domain_Search_Password', + 'Livechat_secret_token', 'Livechat_Knowledge_Apiai_Key', 'AutoTranslate_GoogleAPIKey', 'MapView_GMapsAPIKey', + 'Meta_fb_app_id', 'Meta_google-site-verification', 'Meta_msvalidate01', 'Accounts_OAuth_Dolphin_secret', + 'Accounts_OAuth_Drupal_secret', 'Accounts_OAuth_Facebook_secret', 'Accounts_OAuth_Github_secret', 'API_GitHub_Enterprise_URL', + 'Accounts_OAuth_GitHub_Enterprise_secret', 'API_Gitlab_URL', 'Accounts_OAuth_Gitlab_secret', 'Accounts_OAuth_Google_secret', + 'Accounts_OAuth_Linkedin_secret', 'Accounts_OAuth_Meteor_secret', 'Accounts_OAuth_Twitter_secret', 'API_Wordpress_URL', + 'Accounts_OAuth_Wordpress_secret', 'Push_apn_passphrase', 'Push_apn_key', 'Push_apn_cert', 'Push_apn_dev_passphrase', + 'Push_apn_dev_key', 'Push_apn_dev_cert', 'Push_gcm_api_key', 'Push_gcm_project_number', 'SAML_Custom_Default_cert', + 'SAML_Custom_Default_private_key', 'SlackBridge_APIToken', 'Smarsh_Email', 'SMS_Twilio_Account_SID', 'SMS_Twilio_authToken', + 'SMS_Voxtelesys_authToken', 'SMS_Voxtelesys_URL', + ]; + } + + async getAll(appId) { + this.orch.debugLog(`The App ${ appId } is getting all the settings.`); + + return Settings.find({ _id: { $nin: this.disallowedSettings } }) + .fetch() + .map((s) => this.orch.getConverters().get('settings').convertToApp(s)); + } + + async getOneById(id, appId) { + this.orch.debugLog(`The App ${ appId } is getting the setting by id ${ id }.`); + + if (!this.isReadableById(id, appId)) { + throw new Error(`The setting "${ id }" is not readable.`); + } + + return this.orch.getConverters().get('settings').convertById(id); + } + + async hideGroup(name, appId) { + this.orch.debugLog(`The App ${ appId } is hidding the group ${ name }.`); + + throw new Error('Method not implemented.'); + } + + async hideSetting(id, appId) { + this.orch.debugLog(`The App ${ appId } is hidding the setting ${ id }.`); + + if (!this.isReadableById(id, appId)) { + throw new Error(`The setting "${ id }" is not readable.`); + } + + throw new Error('Method not implemented.'); + } + + async isReadableById(id, appId) { + this.orch.debugLog(`The App ${ appId } is checking if they can read the setting ${ id }.`); + + return !this.disallowedSettings.includes(id); + } + + async updateOne(setting, appId) { + this.orch.debugLog(`The App ${ appId } is updating the setting ${ setting.id } .`); + + if (!this.isReadableById(setting.id, appId)) { + throw new Error(`The setting "${ setting.id }" is not readable.`); + } + + throw new Error('Method not implemented.'); + } +} diff --git a/app/apps/server/bridges/users.js b/app/apps/server/bridges/users.js new file mode 100644 index 000000000000..dae08b2d02d9 --- /dev/null +++ b/app/apps/server/bridges/users.js @@ -0,0 +1,17 @@ +export class AppUserBridge { + constructor(orch) { + this.orch = orch; + } + + async getById(userId, appId) { + this.orch.debugLog(`The App ${ appId } is getting the userId: "${ userId }"`); + + return this.orch.getConverters().get('users').convertById(userId); + } + + async getByUsername(username, appId) { + this.orch.debugLog(`The App ${ appId } is getting the username: "${ username }"`); + + return this.orch.getConverters().get('users').convertByUsername(username); + } +} diff --git a/packages/rocketchat-apps/server/communication/index.js b/app/apps/server/communication/index.js similarity index 100% rename from packages/rocketchat-apps/server/communication/index.js rename to app/apps/server/communication/index.js diff --git a/app/apps/server/communication/methods.js b/app/apps/server/communication/methods.js new file mode 100644 index 000000000000..986f13717fc6 --- /dev/null +++ b/app/apps/server/communication/methods.js @@ -0,0 +1,93 @@ +import { Meteor } from 'meteor/meteor'; +import { settings } from '../../../settings'; +import { hasPermission } from '../../../authorization'; + +const waitToLoad = function(orch) { + return new Promise((resolve) => { + let id = setInterval(() => { + if (orch.isEnabled() && orch.isLoaded()) { + clearInterval(id); + id = -1; + resolve(); + } + }, 100); + }); +}; + +const waitToUnload = function(orch) { + return new Promise((resolve) => { + let id = setInterval(() => { + if (!orch.isEnabled() && !orch.isLoaded()) { + clearInterval(id); + id = -1; + resolve(); + } + }, 100); + }); +}; + +export class AppMethods { + constructor(orch) { + this._orch = orch; + + this._addMethods(); + } + + isEnabled() { + return typeof this._orch !== 'undefined' && this._orch.isEnabled(); + } + + isLoaded() { + return typeof this._orch !== 'undefined' && this._orch.isEnabled() && this._orch.isLoaded(); + } + + _addMethods() { + const instance = this; + + Meteor.methods({ + 'apps/is-enabled'() { + return instance.isEnabled(); + }, + + 'apps/is-loaded'() { + return instance.isLoaded(); + }, + + 'apps/go-enable'() { + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'apps/go-enable', + }); + } + + if (!hasPermission(Meteor.userId(), 'manage-apps')) { + throw new Meteor.Error('error-action-not-allowed', 'Not allowed', { + method: 'apps/go-enable', + }); + } + + settings.set('Apps_Framework_enabled', true); + + Promise.await(waitToLoad(instance._orch)); + }, + + 'apps/go-disable'() { + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'apps/go-enable', + }); + } + + if (!hasPermission(Meteor.userId(), 'manage-apps')) { + throw new Meteor.Error('error-action-not-allowed', 'Not allowed', { + method: 'apps/go-enable', + }); + } + + settings.set('Apps_Framework_enabled', false); + + Promise.await(waitToUnload(instance._orch)); + }, + }); + } +} diff --git a/app/apps/server/communication/rest.js b/app/apps/server/communication/rest.js new file mode 100644 index 000000000000..b6124ccf621c --- /dev/null +++ b/app/apps/server/communication/rest.js @@ -0,0 +1,504 @@ +import { Meteor } from 'meteor/meteor'; +import { HTTP } from 'meteor/http'; +import { API } from '../../../api/server'; +import Busboy from 'busboy'; + +import { getWorkspaceAccessToken, getUserCloudAccessToken } from '../../../cloud/server'; +import { settings } from '../../../settings'; +import { Info } from '../../../utils'; + +export class AppsRestApi { + constructor(orch, manager) { + this._orch = orch; + this._manager = manager; + this.loadAPI(); + } + + _handleFile(request, fileField) { + const busboy = new Busboy({ headers: request.headers }); + + return Meteor.wrapAsync((callback) => { + busboy.on('file', Meteor.bindEnvironment((fieldname, file) => { + if (fieldname !== fileField) { + return callback(new Meteor.Error('invalid-field', `Expected the field "${ fileField }" but got "${ fieldname }" instead.`)); + } + + const fileData = []; + file.on('data', Meteor.bindEnvironment((data) => { + fileData.push(data); + })); + + file.on('end', Meteor.bindEnvironment(() => callback(undefined, Buffer.concat(fileData)))); + })); + + request.pipe(busboy); + })(); + } + + async loadAPI() { + this.api = new API.ApiClass({ + version: 'apps', + useDefaultAuth: true, + prettyJson: false, + enableCors: false, + auth: API.getUserAuth(), + }); + this.addManagementRoutes(); + } + + addManagementRoutes() { + const orchestrator = this._orch; + const manager = this._manager; + const fileHandler = this._handleFile; + + this.api.addRoute('', { authRequired: true, permissionsRequired: ['manage-apps'] }, { + get() { + const baseUrl = orchestrator.getMarketplaceUrl(); + + // Gets the Apps from the marketplace + if (this.queryParams.marketplace) { + const headers = {}; + const token = getWorkspaceAccessToken(); + if (token) { + headers.Authorization = `Bearer ${ token }`; + } + + const result = HTTP.get(`${ baseUrl }/v1/apps?version=${ Info.marketplaceApiVersion }`, { + headers, + }); + + if (result.statusCode !== 200) { + return API.v1.failure(); + } + + return API.v1.success(result.data); + } + + if (this.queryParams.categories) { + const headers = {}; + const token = getWorkspaceAccessToken(); + if (token) { + headers.Authorization = `Bearer ${ token }`; + } + + const result = HTTP.get(`${ baseUrl }/v1/categories`, { + headers, + }); + + if (result.statusCode !== 200) { + return API.v1.failure(); + } + + return API.v1.success(result.data); + } + + if (this.queryParams.buildBuyUrl && this.queryParams.appId) { + const workspaceId = settings.get('Cloud_Workspace_Id'); + + const token = getUserCloudAccessToken(this.getLoggedInUser()._id, true, 'marketplace:purchase', false); + if (!token) { + return API.v1.failure({ error: 'Unauthorized' }); + } + + return API.v1.success({ url: `${ baseUrl }/apps/${ this.queryParams.appId }/buy?workspaceId=${ workspaceId }&token=${ token }` }); + } + + const apps = manager.get().map((prl) => { + const info = prl.getInfo(); + info.languages = prl.getStorageItem().languageContent; + info.status = prl.getStatus(); + + return info; + }); + + return API.v1.success({ apps }); + }, + post() { + let buff; + + if (this.bodyParams.url) { + if (settings.get('Apps_Framework_Development_Mode') !== true) { + return API.v1.failure({ error: 'Installation from url is disabled.' }); + } + + const result = HTTP.call('GET', this.bodyParams.url, { npmRequestOptions: { encoding: 'binary' } }); + + if (result.statusCode !== 200 || !result.headers['content-type'] || result.headers['content-type'] !== 'application/zip') { + return API.v1.failure({ error: 'Invalid url. It doesn\'t exist or is not "application/zip".' }); + } + + buff = Buffer.from(result.content, 'binary'); + } else if (this.bodyParams.appId && this.bodyParams.marketplace && this.bodyParams.version) { + const baseUrl = orchestrator.getMarketplaceUrl(); + + const headers = {}; + const token = getWorkspaceAccessToken(true, 'marketplace:download', false); + + const result = HTTP.get(`${ baseUrl }/v1/apps/${ this.bodyParams.appId }/download/${ this.bodyParams.version }?token=${ token }`, { + headers, + npmRequestOptions: { encoding: 'binary' }, + }); + + if (result.statusCode !== 200) { + return API.v1.failure(); + } + + if (!result.headers['content-type'] || result.headers['content-type'] !== 'application/zip') { + return API.v1.failure({ error: 'Invalid url. It doesn\'t exist or is not "application/zip".' }); + } + + buff = Buffer.from(result.content, 'binary'); + } else { + if (settings.get('Apps_Framework_Development_Mode') !== true) { + return API.v1.failure({ error: 'Direct installation of an App is disabled.' }); + } + + buff = fileHandler(this.request, 'app'); + } + + if (!buff) { + return API.v1.failure({ error: 'Failed to get a file to install for the App. ' }); + } + + const aff = Promise.await(manager.add(buff.toString('base64'), false)); + const info = aff.getAppInfo(); + + // If there are compiler errors, there won't be an App to get the status of + if (aff.getApp()) { + info.status = aff.getApp().getStatus(); + } else { + info.status = 'compiler_error'; + } + + return API.v1.success({ + app: info, + implemented: aff.getImplementedInferfaces(), + compilerErrors: aff.getCompilerErrors(), + }); + }, + }); + + this.api.addRoute('languages', { authRequired: false }, { + get() { + const apps = manager.get().map((prl) => ({ + id: prl.getID(), + languages: prl.getStorageItem().languageContent, + })); + + return API.v1.success({ apps }); + }, + }); + + this.api.addRoute(':id', { authRequired: true, permissionsRequired: ['manage-apps'] }, { + get() { + if (this.queryParams.marketplace && this.queryParams.version) { + const baseUrl = orchestrator.getMarketplaceUrl(); + + const headers = {}; + const token = getWorkspaceAccessToken(); + if (token) { + headers.Authorization = `Bearer ${ token }`; + } + + const result = HTTP.get(`${ baseUrl }/v1/apps/${ this.urlParams.id }?appVersion=${ this.queryParams.version }`, { + headers, + }); + + if (result.statusCode !== 200 || result.data.length === 0) { + return API.v1.failure(); + } + + return API.v1.success({ app: result.data[0] }); + } + + if (this.queryParams.marketplace && this.queryParams.update && this.queryParams.appVersion) { + const baseUrl = orchestrator.getMarketplaceUrl(); + + const headers = {}; + const token = getWorkspaceAccessToken(); + if (token) { + headers.Authorization = `Bearer ${ token }`; + } + + const result = HTTP.get(`${ baseUrl }/v1/apps/${ this.urlParams.id }/latest?frameworkVersion=${ Info.marketplaceApiVersion }`, { + headers, + }); + + if (result.statusCode !== 200 || result.data.length === 0) { + return API.v1.failure(); + } + + return API.v1.success({ app: result.data }); + } + + const prl = manager.getOneById(this.urlParams.id); + + if (prl) { + const info = prl.getInfo(); + info.status = prl.getStatus(); + + return API.v1.success({ app: info }); + } else { + return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`); + } + }, + post() { + // TODO: Verify permissions + + let buff; + + if (this.bodyParams.url) { + if (settings.get('Apps_Framework_Development_Mode') !== true) { + return API.v1.failure({ error: 'Updating an App from a url is disabled.' }); + } + + const result = HTTP.call('GET', this.bodyParams.url, { npmRequestOptions: { encoding: 'binary' } }); + + if (result.statusCode !== 200 || !result.headers['content-type'] || result.headers['content-type'] !== 'application/zip') { + return API.v1.failure({ error: 'Invalid url. It doesn\'t exist or is not "application/zip".' }); + } + + buff = Buffer.from(result.content, 'binary'); + } else if (this.bodyParams.appId && this.bodyParams.marketplace && this.bodyParams.version) { + const baseUrl = orchestrator.getMarketplaceUrl(); + + const headers = {}; + const token = getWorkspaceAccessToken(); + if (token) { + headers.Authorization = `Bearer ${ token }`; + } + + const result = HTTP.get(`${ baseUrl }/v1/apps/${ this.bodyParams.appId }/download/${ this.bodyParams.version }`, { + headers, + npmRequestOptions: { encoding: 'binary' }, + }); + + if (result.statusCode !== 200) { + return API.v1.failure(); + } + + if (!result.headers['content-type'] || result.headers['content-type'] !== 'application/zip') { + return API.v1.failure({ error: 'Invalid url. It doesn\'t exist or is not "application/zip".' }); + } + + buff = Buffer.from(result.content, 'binary'); + } else { + if (settings.get('Apps_Framework_Development_Mode') !== true) { + return API.v1.failure({ error: 'Direct updating of an App is disabled.' }); + } + + buff = fileHandler(this.request, 'app'); + } + + if (!buff) { + return API.v1.failure({ error: 'Failed to get a file to install for the App. ' }); + } + + const aff = Promise.await(manager.update(buff.toString('base64'))); + const info = aff.getAppInfo(); + + // Should the updated version have compiler errors, no App will be returned + if (aff.getApp()) { + info.status = aff.getApp().getStatus(); + } else { + info.status = 'compiler_error'; + } + + return API.v1.success({ + app: info, + implemented: aff.getImplementedInferfaces(), + compilerErrors: aff.getCompilerErrors(), + }); + }, + delete() { + const prl = manager.getOneById(this.urlParams.id); + + if (prl) { + Promise.await(manager.remove(prl.getID())); + + const info = prl.getInfo(); + info.status = prl.getStatus(); + + return API.v1.success({ app: info }); + } else { + return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`); + } + }, + }); + + this.api.addRoute(':id/icon', { authRequired: true, permissionsRequired: ['manage-apps'] }, { + get() { + const prl = manager.getOneById(this.urlParams.id); + + if (prl) { + const info = prl.getInfo(); + + return API.v1.success({ iconFileContent: info.iconFileContent }); + } else { + return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`); + } + }, + }); + + this.api.addRoute(':id/languages', { authRequired: false }, { + get() { + const prl = manager.getOneById(this.urlParams.id); + + if (prl) { + const languages = prl.getStorageItem().languageContent || {}; + + return API.v1.success({ languages }); + } else { + return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`); + } + }, + }); + + this.api.addRoute(':id/logs', { authRequired: true, permissionsRequired: ['manage-apps'] }, { + get() { + const prl = manager.getOneById(this.urlParams.id); + + if (prl) { + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + const ourQuery = Object.assign({}, query, { appId: prl.getID() }); + const options = { + sort: sort ? sort : { _updatedAt: -1 }, + skip: offset, + limit: count, + fields, + }; + + const logs = Promise.await(orchestrator.getLogStorage().find(ourQuery, options)); + + return API.v1.success({ logs }); + } else { + return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`); + } + }, + }); + + this.api.addRoute(':id/settings', { authRequired: true, permissionsRequired: ['manage-apps'] }, { + get() { + const prl = manager.getOneById(this.urlParams.id); + + if (prl) { + const settings = Object.assign({}, prl.getStorageItem().settings); + + Object.keys(settings).forEach((k) => { + if (settings[k].hidden) { + delete settings[k]; + } + }); + + return API.v1.success({ settings }); + } else { + return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`); + } + }, + post() { + if (!this.bodyParams || !this.bodyParams.settings) { + return API.v1.failure('The settings to update must be present.'); + } + + const prl = manager.getOneById(this.urlParams.id); + + if (!prl) { + return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`); + } + + const { settings } = prl.getStorageItem(); + + const updated = []; + this.bodyParams.settings.forEach((s) => { + if (settings[s.id]) { + Promise.await(manager.getSettingsManager().updateAppSetting(this.urlParams.id, s)); + // Updating? + updated.push(s); + } + }); + + return API.v1.success({ updated }); + }, + }); + + this.api.addRoute(':id/settings/:settingId', { authRequired: true, permissionsRequired: ['manage-apps'] }, { + get() { + try { + const setting = manager.getSettingsManager().getAppSetting(this.urlParams.id, this.urlParams.settingId); + + API.v1.success({ setting }); + } catch (e) { + if (e.message.includes('No setting found')) { + return API.v1.notFound(`No Setting found on the App by the id of: "${ this.urlParams.settingId }"`); + } else if (e.message.includes('No App found')) { + return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`); + } else { + return API.v1.failure(e.message); + } + } + }, + post() { + if (!this.bodyParams.setting) { + return API.v1.failure('Setting to update to must be present on the posted body.'); + } + + try { + Promise.await(manager.getSettingsManager().updateAppSetting(this.urlParams.id, this.bodyParams.setting)); + + return API.v1.success(); + } catch (e) { + if (e.message.includes('No setting found')) { + return API.v1.notFound(`No Setting found on the App by the id of: "${ this.urlParams.settingId }"`); + } else if (e.message.includes('No App found')) { + return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`); + } else { + return API.v1.failure(e.message); + } + } + }, + }); + + this.api.addRoute(':id/apis', { authRequired: true, permissionsRequired: ['manage-apps'] }, { + get() { + const prl = manager.getOneById(this.urlParams.id); + + if (prl) { + return API.v1.success({ + apis: manager.apiManager.listApis(this.urlParams.id), + }); + } else { + return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`); + } + }, + }); + + this.api.addRoute(':id/status', { authRequired: true, permissionsRequired: ['manage-apps'] }, { + get() { + const prl = manager.getOneById(this.urlParams.id); + + if (prl) { + return API.v1.success({ status: prl.getStatus() }); + } else { + return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`); + } + }, + post() { + if (!this.bodyParams.status || typeof this.bodyParams.status !== 'string') { + return API.v1.failure('Invalid status provided, it must be "status" field and a string.'); + } + + const prl = manager.getOneById(this.urlParams.id); + + if (prl) { + const result = Promise.await(manager.changeStatus(prl.getID(), this.bodyParams.status)); + + return API.v1.success({ status: result.getStatus() }); + } else { + return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`); + } + }, + }); + } +} diff --git a/packages/rocketchat-apps/server/communication/websockets.js b/app/apps/server/communication/websockets.js similarity index 100% rename from packages/rocketchat-apps/server/communication/websockets.js rename to app/apps/server/communication/websockets.js diff --git a/packages/rocketchat-apps/server/converters/index.js b/app/apps/server/converters/index.js similarity index 100% rename from packages/rocketchat-apps/server/converters/index.js rename to app/apps/server/converters/index.js diff --git a/app/apps/server/converters/messages.js b/app/apps/server/converters/messages.js new file mode 100644 index 000000000000..b3851d0e3bd8 --- /dev/null +++ b/app/apps/server/converters/messages.js @@ -0,0 +1,237 @@ +import { Random } from 'meteor/random'; +import { Messages, Rooms, Users } from '../../../models'; +import { transformMappedData } from '../../lib/misc/transformMappedData'; + +export class AppMessagesConverter { + constructor(orch) { + this.orch = orch; + } + + convertById(msgId) { + const msg = Messages.findOneById(msgId); + + return this.convertMessage(msg); + } + + convertMessage(msgObj) { + if (!msgObj) { + return undefined; + } + + const map = { + id: '_id', + reactions: 'reactions', + parseUrls: 'parseUrls', + text: 'msg', + createdAt: 'ts', + updatedAt: '_updatedAt', + editedAt: 'editedAt', + emoji: 'emoji', + avatarUrl: 'avatar', + alias: 'alias', + customFields: 'customFields', + groupable: 'groupable', + room: (message) => { + const result = this.orch.getConverters().get('rooms').convertById(message.rid); + delete message.rid; + return result; + }, + editor: (message) => { + const { editedBy } = message; + delete message.editedBy; + + if (!editedBy) { + return undefined; + } + + return this.orch.getConverters().get('users').convertById(editedBy._id); + }, + attachments: (message) => { + const result = this._convertAttachmentsToApp(message.attachments); + delete message.attachments; + return result; + }, + sender: (message) => { + if (!message.u || !message.u._id) { + return undefined; + } + + let user = this.orch.getConverters().get('users').convertById(message.u._id); + + // When the sender of the message is a Guest (livechat) and not a user + if (!user) { + user = this.orch.getConverters().get('users').convertToApp(message.u); + } + + delete message.u; + + return user; + }, + }; + + return transformMappedData(msgObj, map); + } + + convertAppMessage(message) { + if (!message) { + return undefined; + } + + const room = Rooms.findOneById(message.room.id); + + if (!room) { + throw new Error('Invalid room provided on the message.'); + } + + let u; + if (message.sender && message.sender.id) { + const user = Users.findOneById(message.sender.id); + + if (user) { + u = { + _id: user._id, + username: user.username, + name: user.name, + }; + } else { + u = { + _id: message.sender.id, + username: message.sender.username, + name: message.sender.name, + }; + } + } + + let editedBy; + if (message.editor) { + const editor = Users.findOneById(message.editor.id); + editedBy = { + _id: editor._id, + username: editor.username, + }; + } + + const attachments = this._convertAppAttachments(message.attachments); + + const newMessage = { + _id: message.id || Random.id(), + rid: room._id, + u, + msg: message.text, + ts: message.createdAt || new Date(), + _updatedAt: message.updatedAt || new Date(), + editedBy, + editedAt: message.editedAt, + emoji: message.emoji, + avatar: message.avatarUrl, + alias: message.alias, + customFields: message.customFields, + groupable: message.groupable, + attachments, + reactions: message.reactions, + parseUrls: message.parseUrls, + }; + + return Object.assign(newMessage, message._unmappedProperties_); + } + + _convertAppAttachments(attachments) { + if (typeof attachments === 'undefined' || !Array.isArray(attachments)) { + return undefined; + } + + return attachments.map((attachment) => Object.assign({ + collapsed: attachment.collapsed, + color: attachment.color, + text: attachment.text, + ts: attachment.timestamp ? attachment.timestamp.toJSON() : attachment.timestamp, + message_link: attachment.timestampLink, + thumb_url: attachment.thumbnailUrl, + author_name: attachment.author ? attachment.author.name : undefined, + author_link: attachment.author ? attachment.author.link : undefined, + author_icon: attachment.author ? attachment.author.icon : undefined, + title: attachment.title ? attachment.title.value : undefined, + title_link: attachment.title ? attachment.title.link : undefined, + title_link_download: attachment.title ? attachment.title.displayDownloadLink : undefined, + image_dimensions: attachment.imageDimensions, + image_preview: attachment.imagePreview, + image_url: attachment.imageUrl, + image_type: attachment.imageType, + image_size: attachment.imageSize, + audio_url: attachment.audioUrl, + audio_type: attachment.audioType, + audio_size: attachment.audioSize, + video_url: attachment.videoUrl, + video_type: attachment.videoType, + video_size: attachment.videoSize, + fields: attachment.fields, + button_alignment: attachment.actionButtonsAlignment, + actions: attachment.actions, + type: attachment.type, + description: attachment.description, + }, attachment._unmappedProperties_)); + } + + _convertAttachmentsToApp(attachments) { + if (typeof attachments === 'undefined' || !Array.isArray(attachments)) { + return undefined; + } + + const map = { + collapsed: 'collapsed', + color: 'color', + text: 'text', + timestampLink: 'message_link', + thumbnailUrl: 'thumb_url', + imageDimensions: 'image_dimensions', + imagePreview: 'image_preview', + imageUrl: 'image_url', + imageType: 'image_type', + imageSize: 'image_size', + audioUrl: 'audio_url', + audioType: 'audio_type', + audioSize: 'audio_size', + videoUrl: 'video_url', + videoType: 'video_type', + videoSize: 'video_size', + fields: 'fields', + actionButtonsAlignment: 'button_alignment', + actions: 'actions', + type: 'type', + description: 'description', + author: (attachment) => { + const { + author_name: name, + author_link: link, + author_icon: icon, + } = attachment; + + delete attachment.author_name; + delete attachment.author_link; + delete attachment.author_icon; + + return { name, link, icon }; + }, + title: (attachment) => { + const { + title: value, + title_link: link, + title_link_download: displayDownloadLink, + } = attachment; + + delete attachment.title; + delete attachment.title_link; + delete attachment.title_link_download; + + return { value, link, displayDownloadLink }; + }, + timestamp: (attachment) => { + const result = new Date(attachment.ts); + delete attachment.ts; + return result; + }, + }; + + return attachments.map((attachment) => transformMappedData(attachment, map)); + } +} diff --git a/app/apps/server/converters/rooms.js b/app/apps/server/converters/rooms.js new file mode 100644 index 000000000000..e889d75d32c4 --- /dev/null +++ b/app/apps/server/converters/rooms.js @@ -0,0 +1,94 @@ +import { Rooms, Users } from '../../../models'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; + +export class AppRoomsConverter { + constructor(orch) { + this.orch = orch; + } + + convertById(roomId) { + const room = Rooms.findOneById(roomId); + + return this.convertRoom(room); + } + + convertByName(roomName) { + const room = Rooms.findOneByName(roomName); + + return this.convertRoom(room); + } + + convertAppRoom(room) { + if (!room) { + return undefined; + } + + let u; + if (room.creator) { + const creator = Users.findOneById(room.creator.id); + u = { + _id: creator._id, + username: creator.username, + }; + } + + return { + _id: room.id, + fname: room.displayName, + name: room.slugifiedName, + t: room.type, + u, + members: room.members, + default: typeof room.isDefault === 'undefined' ? false : room.isDefault, + ro: typeof room.isReadOnly === 'undefined' ? false : room.isReadOnly, + sysMes: typeof room.displaySystemMessages === 'undefined' ? true : room.displaySystemMessages, + msgs: room.messageCount || 0, + ts: room.createdAt, + _updatedAt: room.updatedAt, + lm: room.lastModifiedAt, + }; + } + + convertRoom(room) { + if (!room) { + return undefined; + } + + let creator; + if (room.u) { + creator = this.orch.getConverters().get('users').convertById(room.u._id); + } + + return { + id: room._id, + displayName: room.fname, + slugifiedName: room.name, + type: this._convertTypeToApp(room.t), + creator, + members: room.members, + isDefault: typeof room.default === 'undefined' ? false : room.default, + isReadOnly: typeof room.ro === 'undefined' ? false : room.ro, + displaySystemMessages: typeof room.sysMes === 'undefined' ? true : room.sysMes, + messageCount: room.msgs, + createdAt: room.ts, + updatedAt: room._updatedAt, + lastModifiedAt: room.lm, + customFields: {}, + }; + } + + _convertTypeToApp(typeChar) { + switch (typeChar) { + case 'c': + return RoomType.CHANNEL; + case 'p': + return RoomType.PRIVATE_GROUP; + case 'd': + return RoomType.DIRECT_MESSAGE; + case 'l': + return RoomType.LIVE_CHAT; + default: + return typeChar; + } + } +} diff --git a/app/apps/server/converters/settings.js b/app/apps/server/converters/settings.js new file mode 100644 index 000000000000..f8c77203ff8a --- /dev/null +++ b/app/apps/server/converters/settings.js @@ -0,0 +1,52 @@ +import { Settings } from '../../../models'; +import { SettingType } from '@rocket.chat/apps-engine/definition/settings'; + +export class AppSettingsConverter { + constructor(orch) { + this.orch = orch; + } + + convertById(settingId) { + const setting = Settings.findOneNotHiddenById(settingId); + + return this.convertToApp(setting); + } + + convertToApp(setting) { + return { + id: setting._id, + type: this._convertTypeToApp(setting.type), + packageValue: setting.packageValue, + values: setting.values, + value: setting.value, + public: setting.public, + hidden: setting.hidden, + group: setting.group, + i18nLabel: setting.i18nLabel, + i18nDescription: setting.i18nDescription, + createdAt: setting.ts, + updatedAt: setting._updatedAt, + }; + } + + _convertTypeToApp(type) { + switch (type) { + case 'boolean': + return SettingType.BOOLEAN; + case 'code': + return SettingType.CODE; + case 'color': + return SettingType.COLOR; + case 'font': + return SettingType.FONT; + case 'int': + return SettingType.NUMBER; + case 'select': + return SettingType.SELECT; + case 'string': + return SettingType.STRING; + default: + return type; + } + } +} diff --git a/app/apps/server/converters/users.js b/app/apps/server/converters/users.js new file mode 100644 index 000000000000..803d40f9973a --- /dev/null +++ b/app/apps/server/converters/users.js @@ -0,0 +1,79 @@ +import { Users } from '../../../models'; +import { UserStatusConnection, UserType } from '@rocket.chat/apps-engine/definition/users'; + +export class AppUsersConverter { + constructor(orch) { + this.orch = orch; + } + + convertById(userId) { + const user = Users.findOneById(userId); + + return this.convertToApp(user); + } + + convertByUsername(username) { + const user = Users.findOneByUsername(username); + + return this.convertToApp(user); + } + + convertToApp(user) { + if (!user) { + return undefined; + } + + const type = this._convertUserTypeToEnum(user.type); + const statusConnection = this._convertStatusConnectionToEnum(user.username, user._id, user.statusConnection); + + return { + id: user._id, + username: user.username, + emails: user.emails, + type, + isEnabled: user.active, + name: user.name, + roles: user.roles, + status: user.status, + statusConnection, + utcOffset: user.utcOffset, + createdAt: user.createdAt, + updatedAt: user._updatedAt, + lastLoginAt: user.lastLogin, + }; + } + + _convertUserTypeToEnum(type) { + switch (type) { + case 'user': + return UserType.USER; + case 'bot': + return UserType.BOT; + case '': + case undefined: + return UserType.UNKNOWN; + default: + console.warn(`A new user type has been added that the Apps don't know about? "${ type }"`); + return type.toUpperCase(); + } + } + + _convertStatusConnectionToEnum(username, userId, status) { + switch (status) { + case 'offline': + return UserStatusConnection.OFFLINE; + case 'online': + return UserStatusConnection.ONLINE; + case 'away': + return UserStatusConnection.AWAY; + case 'busy': + return UserStatusConnection.BUSY; + case undefined: + // This is needed for Livechat guests and Rocket.Cat user. + return UserStatusConnection.UNDEFINED; + default: + console.warn(`The user ${ username } (${ userId }) does not have a valid status (offline, online, away, or busy). It is currently: "${ status }"`); + return !status ? UserStatusConnection.OFFLINE : status.toUpperCase(); + } + } +} diff --git a/app/apps/server/index.js b/app/apps/server/index.js new file mode 100644 index 000000000000..0d8b925c2207 --- /dev/null +++ b/app/apps/server/index.js @@ -0,0 +1 @@ +export { Apps } from './orchestrator'; diff --git a/app/apps/server/orchestrator.js b/app/apps/server/orchestrator.js new file mode 100644 index 000000000000..b186e7ea4b9a --- /dev/null +++ b/app/apps/server/orchestrator.js @@ -0,0 +1,160 @@ +import { Meteor } from 'meteor/meteor'; +import { Permissions, AppsLogsModel, AppsModel, AppsPersistenceModel } from '../../models'; +import { settings } from '../../settings'; +import { RealAppBridges } from './bridges'; +import { AppMethods, AppsRestApi, AppServerNotifier } from './communication'; +import { AppMessagesConverter, AppRoomsConverter, AppSettingsConverter, AppUsersConverter } from './converters'; +import { AppRealStorage, AppRealLogsStorage } from './storage'; +import { AppManager } from '@rocket.chat/apps-engine/server/AppManager'; + +export let Apps; + +class AppServerOrchestrator { + constructor() { + if (Permissions) { + Permissions.createOrUpdate('manage-apps', ['admin']); + } + + this._marketplaceUrl = 'https://marketplace.rocket.chat'; + + this._model = new AppsModel(); + this._logModel = new AppsLogsModel(); + this._persistModel = new AppsPersistenceModel(); + this._storage = new AppRealStorage(this._model); + this._logStorage = new AppRealLogsStorage(this._logModel); + + this._converters = new Map(); + this._converters.set('messages', new AppMessagesConverter(this)); + this._converters.set('rooms', new AppRoomsConverter(this)); + this._converters.set('settings', new AppSettingsConverter(this)); + this._converters.set('users', new AppUsersConverter(this)); + + this._bridges = new RealAppBridges(this); + + this._manager = new AppManager(this._storage, this._logStorage, this._bridges); + + this._communicators = new Map(); + this._communicators.set('methods', new AppMethods(this)); + this._communicators.set('notifier', new AppServerNotifier(this)); + this._communicators.set('restapi', new AppsRestApi(this, this._manager)); + } + + getModel() { + return this._model; + } + + getPersistenceModel() { + return this._persistModel; + } + + getStorage() { + return this._storage; + } + + getLogStorage() { + return this._logStorage; + } + + getConverters() { + return this._converters; + } + + getBridges() { + return this._bridges; + } + + getNotifier() { + return this._communicators.get('notifier'); + } + + getManager() { + return this._manager; + } + + isEnabled() { + return settings.get('Apps_Framework_enabled'); + } + + isLoaded() { + return this.getManager().areAppsLoaded(); + } + + isDebugging() { + return settings.get('Apps_Framework_Development_Mode'); + } + + debugLog() { + if (this.isDebugging()) { + // eslint-disable-next-line + console.log(...arguments); + } + } + + getMarketplaceUrl() { + return this._marketplaceUrl; + } + + load() { + // Don't try to load it again if it has + // already been loaded + if (this.isLoaded()) { + return; + } + + this._manager.load() + .then((affs) => console.log(`Loaded the Apps Framework and loaded a total of ${ affs.length } Apps!`)) + .catch((err) => console.warn('Failed to load the Apps Framework and Apps!', err)); + } + + unload() { + // Don't try to unload it if it's already been + // unlaoded or wasn't unloaded to start with + if (!this.isLoaded()) { + return; + } + + this._manager.unload() + .then(() => console.log('Unloaded the Apps Framework.')) + .catch((err) => console.warn('Failed to unload the Apps Framework!', err)); + } +} + +settings.addGroup('General', function() { + this.section('Apps', function() { + this.add('Apps_Framework_enabled', true, { + type: 'boolean', + hidden: false, + }); + + this.add('Apps_Framework_Development_Mode', false, { + type: 'boolean', + enableQuery: { + _id: 'Apps_Framework_enabled', + value: true, + }, + public: true, + hidden: false, + }); + }); +}); + +settings.get('Apps_Framework_enabled', (key, isEnabled) => { + // In case this gets called before `Meteor.startup` + if (!Apps) { + return; + } + + if (isEnabled) { + Apps.load(); + } else { + Apps.unload(); + } +}); + +Meteor.startup(function _appServerOrchestrator() { + Apps = new AppServerOrchestrator(); + + if (Apps.isEnabled()) { + Apps.load(); + } +}); diff --git a/app/apps/server/storage/index.js b/app/apps/server/storage/index.js new file mode 100644 index 000000000000..fd1680c1c9c3 --- /dev/null +++ b/app/apps/server/storage/index.js @@ -0,0 +1,4 @@ +import { AppRealLogsStorage } from './logs-storage'; +import { AppRealStorage } from './storage'; + +export { AppRealLogsStorage, AppRealStorage }; diff --git a/packages/rocketchat-apps/server/storage/logs-storage.js b/app/apps/server/storage/logs-storage.js similarity index 100% rename from packages/rocketchat-apps/server/storage/logs-storage.js rename to app/apps/server/storage/logs-storage.js diff --git a/packages/rocketchat-apps/server/storage/storage.js b/app/apps/server/storage/storage.js similarity index 100% rename from packages/rocketchat-apps/server/storage/storage.js rename to app/apps/server/storage/storage.js diff --git a/app/apps/server/tests/messages.tests.js b/app/apps/server/tests/messages.tests.js new file mode 100644 index 000000000000..6373f8a7f5c9 --- /dev/null +++ b/app/apps/server/tests/messages.tests.js @@ -0,0 +1,154 @@ +/* eslint-env mocha */ +import 'babel-polyfill'; +import mock from 'mock-require'; +import chai from 'chai'; + +import { AppServerOrchestratorMock } from './mocks/orchestrator.mock'; +import { appMessageMock, appMessageInvalidRoomMock } from './mocks/data/messages.data'; +import { MessagesMock } from './mocks/models/Messages.mock'; +import { RoomsMock } from './mocks/models/Rooms.mock'; +import { UsersMock } from './mocks/models/Users.mock'; + +chai.use(require('chai-datetime')); +const { expect } = chai; + +mock('../../../models', './mocks/models'); +mock('meteor/random', { + id: () => 1, +}); + +const { AppMessagesConverter } = require('../converters/messages'); + +describe('The AppMessagesConverter instance', function() { + let messagesConverter; + let messagesMock; + + before(function() { + const orchestrator = new AppServerOrchestratorMock(); + + const usersConverter = orchestrator.getConverters().get('users'); + + usersConverter.convertById = function convertUserByIdStub(id) { + return UsersMock.convertedData[id]; + }; + + usersConverter.convertToApp = function convertUserToAppStub(user) { + return { + id: user._id, + username: user.username, + name: user.name, + }; + }; + + orchestrator.getConverters().get('rooms').convertById = function convertRoomByIdStub(id) { + return RoomsMock.convertedData[id]; + }; + + messagesConverter = new AppMessagesConverter(orchestrator); + messagesMock = new MessagesMock(); + }); + + const createdAt = new Date('2019-03-30T01:22:08.389Z'); + const updatedAt = new Date('2019-03-30T01:22:08.412Z'); + + describe('when converting a message from Rocket.Chat to the Engine schema', function() { + + it('should return `undefined` when `msgObj` is falsy', function() { + const appMessage = messagesConverter.convertMessage(undefined); + + expect(appMessage).to.be.undefined; + }); + + it('should return a proper schema', function() { + const appMessage = messagesConverter.convertMessage(messagesMock.findOneById('SimpleMessageMock')); + + expect(appMessage).to.have.property('id', 'SimpleMessageMock'); + expect(appMessage).to.have.property('createdAt').which.equalTime(createdAt); + expect(appMessage).to.have.property('updatedAt').which.equalTime(updatedAt); + expect(appMessage).to.have.property('groupable', false); + expect(appMessage).to.have.property('sender').which.includes({ id: 'rocket.cat' }); + expect(appMessage).to.have.property('room').which.includes({ id: 'GENERAL' }); + + expect(appMessage).not.to.have.property('editor'); + expect(appMessage).not.to.have.property('attachments'); + expect(appMessage).not.to.have.property('reactions'); + expect(appMessage).not.to.have.property('avatarUrl'); + expect(appMessage).not.to.have.property('alias'); + expect(appMessage).not.to.have.property('customFields'); + expect(appMessage).not.to.have.property('emoji'); + }); + + it('should not mutate the original message object', function() { + const rocketchatMessageMock = messagesMock.findOneById('SimpleMessageMock'); + + messagesConverter.convertMessage(rocketchatMessageMock); + + expect(rocketchatMessageMock).to.deep.equal({ + _id : 'SimpleMessageMock', + t : 'uj', + rid : 'GENERAL', + ts : new Date('2019-03-30T01:22:08.389Z'), + msg : 'rocket.cat', + u : { + _id : 'rocket.cat', + username : 'rocket.cat', + }, + groupable : false, + _updatedAt : new Date('2019-03-30T01:22:08.412Z'), + }); + }); + + it('should add an `_unmappedProperties_` field to the converted message which contains the `t` property of the message', function() { + const appMessage = messagesConverter.convertMessage(messagesMock.findOneById('SimpleMessageMock')); + + expect(appMessage) + .to.have.property('_unmappedProperties_') + .which.has.property('t', 'uj'); + }); + + it('should return basic sender info when it\'s not a Rocket.Chat user (e.g. Livechat Guest)', function() { + const appMessage = messagesConverter.convertMessage(messagesMock.findOneById('LivechatGuestMessageMock')); + + expect(appMessage).to.have.property('sender').which.includes({ + id : 'guest1234', + username: 'guest1234', + name : 'Livechat Guest', + }); + }); + }); + + describe('when converting a message from the Engine schema back to Rocket.Chat', function() { + + it('should return `undefined` when `message` is falsy', function() { + const rocketchatMessage = messagesConverter.convertAppMessage(undefined); + + expect(rocketchatMessage).to.be.undefined; + }); + + it('should return a proper schema', function() { + const rocketchatMessage = messagesConverter.convertAppMessage(appMessageMock); + + expect(rocketchatMessage).to.have.property('_id', 'appMessageMock'); + expect(rocketchatMessage).to.have.property('rid', 'GENERAL'); + expect(rocketchatMessage).to.have.property('groupable', false); + expect(rocketchatMessage).to.have.property('ts').which.equalTime(createdAt); + expect(rocketchatMessage).to.have.property('_updatedAt').which.equalTime(updatedAt); + expect(rocketchatMessage).to.have.property('u').which.includes({ + _id: 'rocket.cat', + username: 'rocket.cat', + name: 'Rocket.Cat', + }); + }); + + it('should merge `_unmappedProperties_` into the returned message', function() { + const rocketchatMessage = messagesConverter.convertAppMessage(appMessageMock); + + expect(rocketchatMessage).not.to.have.property('_unmappedProperties_'); + expect(rocketchatMessage).to.have.property('t', 'uj'); + }); + + it('should throw if message has an invalid room', function() { + expect(() => messagesConverter.convertAppMessage(appMessageInvalidRoomMock)).to.throw(Error, 'Invalid room provided on the message.'); + }); + }); +}); diff --git a/app/apps/server/tests/mocks/data/messages.data.js b/app/apps/server/tests/mocks/data/messages.data.js new file mode 100644 index 000000000000..736c03f1e8c2 --- /dev/null +++ b/app/apps/server/tests/mocks/data/messages.data.js @@ -0,0 +1,116 @@ +export const appMessageMock = { + id: 'appMessageMock', + text: 'rocket.cat', + createdAt: new Date('2019-03-30T01:22:08.389Z'), + updatedAt: new Date('2019-03-30T01:22:08.412Z'), + groupable: false, + room: { + id: 'GENERAL', + displayName: 'general', + slugifiedName: 'general', + type: 'c', + creator: { + username: 'rocket.cat', + emails: [ + { + address: 'rocketcat@rocket.chat', + verified: true, + }, + ], + type: 'bot', + isEnabled: true, + name: 'Rocket.Cat', + roles: [ + 'bot', + ], + status: 'online', + statusConnection: 'online', + utcOffset: 0, + createdAt: new Date('2019-04-13T01:33:14.191Z'), + updatedAt: new Date('2019-04-13T01:33:14.191Z'), + }, + }, + sender: { + id: 'rocket.cat', + username: 'rocket.cat', + emails: [ + { + address: 'rocketcat@rocket.chat', + verified: true, + }, + ], + type: 'bot', + isEnabled: true, + name: 'Rocket.Cat', + roles: [ + 'bot', + ], + status: 'online', + statusConnection: 'online', + utcOffset: 0, + createdAt: new Date('2019-04-13T01:33:14.191Z'), + updatedAt: new Date('2019-04-13T01:33:14.191Z'), + }, + _unmappedProperties_: { + t: 'uj', + }, +}; + +export const appMessageInvalidRoomMock = { + id: 'appMessageInvalidRoomMock', + text: 'rocket.cat', + createdAt: new Date('2019-03-30T01:22:08.389Z'), + updatedAt: new Date('2019-03-30T01:22:08.412Z'), + groupable: false, + room: { + id: 'INVALID IDENTIFICATION', + displayName: 'Mocked Room', + slugifiedName: 'mocked-room', + type: 'c', + creator: { + username: 'rocket.cat', + emails: [ + { + address: 'rocketcat@rocket.chat', + verified: true, + }, + ], + type: 'bot', + isEnabled: true, + name: 'Rocket.Cat', + roles: [ + 'bot', + ], + status: 'online', + statusConnection: 'online', + utcOffset: 0, + createdAt: new Date('2019-04-13T01:33:14.191Z'), + updatedAt: new Date('2019-04-13T01:33:14.191Z'), + }, + }, + sender: { + id: 'rocket.cat', + username: 'rocket.cat', + emails: [ + { + address: 'rocketcat@rocket.chat', + verified: true, + }, + ], + type: 'bot', + isEnabled: true, + name: 'Rocket.Cat', + roles: [ + 'bot', + ], + status: 'online', + statusConnection: 'online', + utcOffset: 0, + createdAt: new Date('2019-04-13T01:33:14.191Z'), + updatedAt: new Date('2019-04-13T01:33:14.191Z'), + }, + _unmappedProperties_: { + t: 'uj', + }, +}; + diff --git a/app/apps/server/tests/mocks/models/BaseModel.mock.js b/app/apps/server/tests/mocks/models/BaseModel.mock.js new file mode 100644 index 000000000000..920db0d95fdc --- /dev/null +++ b/app/apps/server/tests/mocks/models/BaseModel.mock.js @@ -0,0 +1,5 @@ +export class BaseModelMock { + findOneById(id) { + return this.data[id]; + } +} diff --git a/app/apps/server/tests/mocks/models/Messages.mock.js b/app/apps/server/tests/mocks/models/Messages.mock.js new file mode 100644 index 000000000000..bcb84a92da77 --- /dev/null +++ b/app/apps/server/tests/mocks/models/Messages.mock.js @@ -0,0 +1,36 @@ +import { BaseModelMock } from './BaseModel.mock'; + +export class MessagesMock extends BaseModelMock { + data = { + SimpleMessageMock: { + _id : 'SimpleMessageMock', + t : 'uj', + rid : 'GENERAL', + ts : new Date('2019-03-30T01:22:08.389Z'), + msg : 'rocket.cat', + u : { + _id : 'rocket.cat', + username : 'rocket.cat', + }, + groupable : false, + _updatedAt : new Date('2019-03-30T01:22:08.412Z'), + }, + + LivechatGuestMessageMock: { + _id : 'LivechatGuestMessageMock', + rid : 'LivechatRoom', + msg : 'Help wanted', + token : 'guest-token', + alias : 'Livechat Guest', + ts : new Date('2019-04-06T03:57:28.263Z'), + u : { + _id : 'guest1234', + username: 'guest1234', + name : 'Livechat Guest', + }, + _updatedAt : new Date('2019-04-06T03:57:28.278Z'), + mentions : [], + channels : [], + }, + } +} diff --git a/app/apps/server/tests/mocks/models/Rooms.mock.js b/app/apps/server/tests/mocks/models/Rooms.mock.js new file mode 100644 index 000000000000..a56d7777a28d --- /dev/null +++ b/app/apps/server/tests/mocks/models/Rooms.mock.js @@ -0,0 +1,127 @@ +import { BaseModelMock } from './BaseModel.mock'; + +export class RoomsMock extends BaseModelMock { + data = { + GENERAL: { + _id: 'GENERAL', + ts: new Date('2019-03-27T20:51:36.808Z'), + t: 'c', + name: 'general', + usernames: [], + msgs: 31, + usersCount: 3, + default: true, + _updatedAt: new Date('2019-04-10T17:44:34.931Z'), + lastMessage: { + _id: 1, + t: 'uj', + rid: 'GENERAL', + ts: new Date('2019-03-30T01:22:08.389Z'), + msg: 'rocket.cat', + u: { + _id: 'rocket.cat', + username: 'rocket.cat', + }, + groupable: false, + _updatedAt: new Date('2019-03-30T01:22:08.412Z'), + }, + lm: new Date('2019-04-10T17:44:34.873Z'), + }, + + LivechatRoom: { + _id: 'LivechatRoom', + msgs: 41, + usersCount: 1, + lm: new Date('2019-04-07T23:45:25.407Z'), + fname: 'Livechat Guest', + t: 'l', + ts: new Date('2019-04-06T03:56:17.040Z'), + v: { + _id: 'yDLaWs5Rzf5mzQsmB', + username: 'guest-4', + token: 'tkps932ccsl6me7intd3', + status: 'away', + }, + servedBy: { + _id: 'rocket.cat', + username: 'rocket.cat', + ts: new Date('2019-04-06T03:56:17.040Z'), + }, + cl: false, + open: true, + _updatedAt: new Date('2019-04-07T23:45:25.469Z'), + lastMessage: { + _id: 'zgEMhaMLCyDPu7xMn', + rid: 'JceP6CZrpcA4j3NNe', + msg: 'a', + ts: new Date('2019-04-07T23:45:25.407Z'), + u: { + _id: '3Wz2wANqwrd7Hu5Fo', + username: 'dgubert', + name: 'Douglas Gubert', + }, + _updatedAt: new Date('2019-04-07T23:45:25.433Z'), + mentions: [], + channels: [], + }, + metrics: { + v: { + lq: new Date('2019-04-06T03:57:28.263Z'), + }, + reaction: { + fd: new Date('2019-04-06T03:57:17.083Z'), + ft: 60.043, + tt: 52144.278, + }, + response: { + avg: 26072.0655, + fd: new Date('2019-04-06T03:57:17.083Z'), + ft: 59.896, + total: 2, + tt: 52144.131, + }, + servedBy: { + lr: new Date('2019-04-06T18:25:32.394Z'), + }, + }, + responseBy: { + _id: 'rocket.cat', + username: 'rocket.cat', + }, + }, + } + + static convertedData = { + GENERAL: { + id: 'GENERAL', + slugifiedName: 'general', + displayName: undefined, + creator: undefined, + createdAt: new Date('2019-03-27T20:51:36.808Z'), + type: 'c', + messageCount: 31, + displaySystemMessages: true, + isReadOnly: false, + isDefault: true, + updatedAt: new Date('2019-04-10T17:44:34.931Z'), + lastModifiedAt: new Date('2019-04-10T17:44:34.873Z'), + customFields: {}, + }, + + LivechatRoom: { + id: 'LivechatRoom', + slugifiedName: undefined, + displayName: 'Livechat Guest', + creator: undefined, + createdAt: new Date('2019-04-06T03:56:17.040Z'), + type: 'l', // Apps-Engine defines the wrong type for livechat rooms + messageCount: 41, + displaySystemMessages: true, + isReadOnly: false, + isDefault: false, + updatedAt: new Date('2019-04-07T23:45:25.469Z'), + lastModifiedAt: new Date('2019-04-07T23:45:25.407Z'), + customFields: {}, + }, + } +} diff --git a/app/apps/server/tests/mocks/models/Users.mock.js b/app/apps/server/tests/mocks/models/Users.mock.js new file mode 100644 index 000000000000..1622c5398cc0 --- /dev/null +++ b/app/apps/server/tests/mocks/models/Users.mock.js @@ -0,0 +1,43 @@ +import { BaseModelMock } from './BaseModel.mock'; + +export class UsersMock extends BaseModelMock { + data = { + 'rocket.cat': { + _id : 'rocket.cat', + createdAt : new Date('2019-03-27T20:51:36.821Z'), + avatarOrigin : 'local', + name : 'Rocket.Cat', + username : 'rocket.cat', + status : 'online', + statusDefault : 'online', + utcOffset : 0, + active : true, + type : 'bot', + _updatedAt : new Date('2019-03-30T01:11:50.496Z'), + roles : [ + 'bot', + ], + }, + } + + static convertedData = { + 'rocket.cat': { + id: 'rocket.cat', + username: 'rocket.cat', + emails: [{ + address: 'rocketcat@rocket.chat', + verified: true, + }], + type: 'bot', + isEnabled: true, + name: 'Rocket.Cat', + roles: ['bot'], + status: 'online', + statusConnection: 'online', + utcOffset: 0, + createdAt: new Date(), + updatedAt: new Date(), + lastLoginAt: undefined, + }, + } +} diff --git a/app/apps/server/tests/mocks/models/index.js b/app/apps/server/tests/mocks/models/index.js new file mode 100644 index 000000000000..725705014607 --- /dev/null +++ b/app/apps/server/tests/mocks/models/index.js @@ -0,0 +1,7 @@ +import { MessagesMock } from './Messages.mock'; +import { RoomsMock } from './Rooms.mock'; +import { UsersMock } from './Users.mock'; + +export const Messages = new MessagesMock(); +export const Rooms = new RoomsMock(); +export const Users = new UsersMock(); diff --git a/app/apps/server/tests/mocks/orchestrator.mock.js b/app/apps/server/tests/mocks/orchestrator.mock.js new file mode 100644 index 000000000000..a0ec3410f025 --- /dev/null +++ b/app/apps/server/tests/mocks/orchestrator.mock.js @@ -0,0 +1,106 @@ +export class AppServerOrchestratorMock { + constructor() { + + this._marketplaceUrl = 'https://marketplace.rocket.chat'; + + this._model = {}; + this._logModel = {}; + this._persistModel = {}; + this._storage = {}; + this._logStorage = {}; + + this._converters = new Map(); + this._converters.set('messages', {}); + this._converters.set('rooms', {}); + this._converters.set('settings', {}); + this._converters.set('users', {}); + + this._bridges = {}; + + this._manager = {}; + + this._communicators = new Map(); + this._communicators.set('methods', {}); + this._communicators.set('notifier', {}); + this._communicators.set('restapi', {}); + } + + getModel() { + return this._model; + } + + getPersistenceModel() { + return this._persistModel; + } + + getStorage() { + return this._storage; + } + + getLogStorage() { + return this._logStorage; + } + + getConverters() { + return this._converters; + } + + getBridges() { + return this._bridges; + } + + getNotifier() { + return this._communicators.get('notifier'); + } + + getManager() { + return this._manager; + } + + isEnabled() { + return true; + } + + isLoaded() { + return this.getManager().areAppsLoaded(); + } + + isDebugging() { + return true; + } + + debugLog() { + if (this.isDebugging()) { + // eslint-disable-next-line + console.log(...arguments); + } + } + + getMarketplaceUrl() { + return this._marketplaceUrl; + } + + load() { + // Don't try to load it again if it has + // already been loaded + if (this.isLoaded()) { + return; + } + + this._manager.load() + .then((affs) => console.log(`Loaded the Apps Framework and loaded a total of ${ affs.length } Apps!`)) + .catch((err) => console.warn('Failed to load the Apps Framework and Apps!', err)); + } + + unload() { + // Don't try to unload it if it's already been + // unlaoded or wasn't unloaded to start with + if (!this.isLoaded()) { + return; + } + + this._manager.unload() + .then(() => console.log('Unloaded the Apps Framework.')) + .catch((err) => console.warn('Failed to unload the Apps Framework!', err)); + } +} diff --git a/app/assets/index.js b/app/assets/index.js new file mode 100644 index 000000000000..ca39cd0df4b1 --- /dev/null +++ b/app/assets/index.js @@ -0,0 +1 @@ +export * from './server/index'; diff --git a/app/assets/server/assets.js b/app/assets/server/assets.js new file mode 100644 index 000000000000..4c8767587360 --- /dev/null +++ b/app/assets/server/assets.js @@ -0,0 +1,529 @@ +import { Meteor } from 'meteor/meteor'; +import { WebApp, WebAppInternals } from 'meteor/webapp'; +import { settings } from '../../settings'; +import { Settings } from '../../models'; +import { getURL } from '../../utils/lib/getURL'; +import { mime } from '../../utils/lib/mimeTypes'; +import { hasPermission } from '../../authorization'; +import { RocketChatFile } from '../../file'; +import { WebAppHashing } from 'meteor/webapp-hashing'; + +import _ from 'underscore'; +import sizeOf from 'image-size'; +import crypto from 'crypto'; +import sharp from 'sharp'; + +const RocketChatAssetsInstance = new RocketChatFile.GridFS({ + name: 'assets', +}); + +const assets = { + logo: { + label: 'logo (svg, png, jpg)', + defaultUrl: 'images/logo/logo.svg', + constraints: { + type: 'image', + extensions: ['svg', 'png', 'jpg', 'jpeg'], + width: undefined, + height: undefined, + }, + wizard: { + step: 3, + order: 2, + }, + }, + background: { + label: 'login background (svg, png, jpg)', + defaultUrl: undefined, + constraints: { + type: 'image', + extensions: ['svg', 'png', 'jpg', 'jpeg'], + width: undefined, + height: undefined, + }, + }, + favicon_ico: { + label: 'favicon (ico)', + defaultUrl: 'favicon.ico', + constraints: { + type: 'image', + extensions: ['ico'], + width: undefined, + height: undefined, + }, + }, + favicon: { + label: 'favicon (svg)', + defaultUrl: 'images/logo/icon.svg', + constraints: { + type: 'image', + extensions: ['svg'], + width: undefined, + height: undefined, + }, + }, + favicon_16: { + label: 'favicon 16x16 (png)', + defaultUrl: 'images/logo/favicon-16x16.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 16, + height: 16, + }, + }, + favicon_32: { + label: 'favicon 32x32 (png)', + defaultUrl: 'images/logo/favicon-32x32.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 32, + height: 32, + }, + }, + favicon_192: { + label: 'android-chrome 192x192 (png)', + defaultUrl: 'images/logo/android-chrome-192x192.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 192, + height: 192, + }, + }, + favicon_512: { + label: 'android-chrome 512x512 (png)', + defaultUrl: 'images/logo/android-chrome-512x512.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 512, + height: 512, + }, + }, + touchicon_180: { + label: 'apple-touch-icon 180x180 (png)', + defaultUrl: 'images/logo/apple-touch-icon.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 180, + height: 180, + }, + }, + touchicon_180_pre: { + label: 'apple-touch-icon-precomposed 180x180 (png)', + defaultUrl: 'images/logo/apple-touch-icon-precomposed.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 180, + height: 180, + }, + }, + tile_70: { + label: 'mstile 70x70 (png)', + defaultUrl: 'images/logo/mstile-70x70.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 70, + height: 70, + }, + }, + tile_144: { + label: 'mstile 144x144 (png)', + defaultUrl: 'images/logo/mstile-144x144.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 144, + height: 144, + }, + }, + tile_150: { + label: 'mstile 150x150 (png)', + defaultUrl: 'images/logo/mstile-150x150.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 150, + height: 150, + }, + }, + tile_310_square: { + label: 'mstile 310x310 (png)', + defaultUrl: 'images/logo/mstile-310x310.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 310, + height: 310, + }, + }, + tile_310_wide: { + label: 'mstile 310x150 (png)', + defaultUrl: 'images/logo/mstile-310x150.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 310, + height: 150, + }, + }, + safari_pinned: { + label: 'safari pinned tab (svg)', + defaultUrl: 'images/logo/safari-pinned-tab.svg', + constraints: { + type: 'image', + extensions: ['svg'], + width: undefined, + height: undefined, + }, + }, +}; + +export const RocketChatAssets = new (class { + get mime() { + return mime; + } + + get assets() { + return assets; + } + + setAsset(binaryContent, contentType, asset) { + if (!assets[asset]) { + throw new Meteor.Error('error-invalid-asset', 'Invalid asset', { + function: 'RocketChat.Assets.setAsset', + }); + } + + const extension = mime.extension(contentType); + if (assets[asset].constraints.extensions.includes(extension) === false) { + throw new Meteor.Error(contentType, `Invalid file type: ${ contentType }`, { + function: 'RocketChat.Assets.setAsset', + errorTitle: 'error-invalid-file-type', + }); + } + + const file = new Buffer(binaryContent, 'binary'); + if (assets[asset].constraints.width || assets[asset].constraints.height) { + const dimensions = sizeOf(file); + if (assets[asset].constraints.width && assets[asset].constraints.width !== dimensions.width) { + throw new Meteor.Error('error-invalid-file-width', 'Invalid file width', { + function: 'Invalid file width', + }); + } + if (assets[asset].constraints.height && assets[asset].constraints.height !== dimensions.height) { + throw new Meteor.Error('error-invalid-file-height'); + } + } + + const rs = RocketChatFile.bufferToStream(file); + RocketChatAssetsInstance.deleteFile(asset); + + const ws = RocketChatAssetsInstance.createWriteStream(asset, contentType); + ws.on('end', Meteor.bindEnvironment(function() { + return Meteor.setTimeout(function() { + const key = `Assets_${ asset }`; + const value = { + url: `assets/${ asset }.${ extension }`, + defaultUrl: assets[asset].defaultUrl, + }; + + settings.updateById(key, value); + return RocketChatAssets.processAsset(key, value); + }, 200); + })); + + rs.pipe(ws); + } + + unsetAsset(asset) { + if (!assets[asset]) { + throw new Meteor.Error('error-invalid-asset', 'Invalid asset', { + function: 'RocketChat.Assets.unsetAsset', + }); + } + + RocketChatAssetsInstance.deleteFile(asset); + const key = `Assets_${ asset }`; + const value = { + defaultUrl: assets[asset].defaultUrl, + }; + + settings.updateById(key, value); + RocketChatAssets.processAsset(key, value); + } + + refreshClients() { + return process.emit('message', { + refresh: 'client', + }); + } + + processAsset(settingKey, settingValue) { + if (settingKey.indexOf('Assets_') !== 0) { + return; + } + + const assetKey = settingKey.replace(/^Assets_/, ''); + const assetValue = assets[assetKey]; + + if (!assetValue) { + return; + } + + if (!settingValue || !settingValue.url) { + assetValue.cache = undefined; + return; + } + + const file = RocketChatAssetsInstance.getFileSync(assetKey); + if (!file) { + assetValue.cache = undefined; + return; + } + + const hash = crypto.createHash('sha1').update(file.buffer).digest('hex'); + const extension = settingValue.url.split('.').pop(); + + return assetValue.cache = { + path: `assets/${ assetKey }.${ extension }`, + cacheable: false, + sourceMapUrl: undefined, + where: 'client', + type: 'asset', + content: file.buffer, + extension, + url: `/assets/${ assetKey }.${ extension }?${ hash }`, + size: file.length, + uploadDate: file.uploadDate, + contentType: file.contentType, + hash, + }; + } + + getURL(assetName, options = { cdn: false, full: true }) { + const asset = settings.get(assetName); + const url = asset.url || asset.defaultUrl; + + return getURL(url, options); + } +}); + +settings.addGroup('Assets'); + +settings.add('Assets_SvgFavicon_Enable', true, { + type: 'boolean', + group: 'Assets', + i18nLabel: 'Enable_Svg_Favicon', +}); + +function addAssetToSetting(asset, value) { + const key = `Assets_${ asset }`; + + settings.add(key, { + defaultUrl: value.defaultUrl, + }, { + type: 'asset', + group: 'Assets', + fileConstraints: value.constraints, + i18nLabel: value.label, + asset, + public: true, + wizard: value.wizard, + }); + + const currentValue = settings.get(key); + + if (typeof currentValue === 'object' && currentValue.defaultUrl !== assets[asset].defaultUrl) { + currentValue.defaultUrl = assets[asset].defaultUrl; + settings.updateById(key, currentValue); + } +} + +for (const key of Object.keys(assets)) { + const value = assets[key]; + addAssetToSetting(key, value); +} + +Settings.find().observe({ + added(record) { + return RocketChatAssets.processAsset(record._id, record.value); + }, + + changed(record) { + return RocketChatAssets.processAsset(record._id, record.value); + }, + + removed(record) { + return RocketChatAssets.processAsset(record._id, undefined); + }, +}); + +Meteor.startup(function() { + return Meteor.setTimeout(function() { + return process.emit('message', { + refresh: 'client', + }); + }, 200); +}); + +const { calculateClientHash } = WebAppHashing; + +WebAppHashing.calculateClientHash = function(manifest, includeFilter, runtimeConfigOverride) { + for (const key of Object.keys(assets)) { + const value = assets[key]; + if (!value.cache && !value.defaultUrl) { + continue; + } + + let cache = {}; + if (value.cache) { + cache = { + path: value.cache.path, + cacheable: value.cache.cacheable, + sourceMapUrl: value.cache.sourceMapUrl, + where: value.cache.where, + type: value.cache.type, + url: value.cache.url, + size: value.cache.size, + hash: value.cache.hash, + }; + } else { + const extension = value.defaultUrl.split('.').pop(); + cache = { + path: `assets/${ key }.${ extension }`, + cacheable: false, + sourceMapUrl: undefined, + where: 'client', + type: 'asset', + url: `/assets/${ key }.${ extension }?v3`, + hash: 'v3', + }; + } + + const manifestItem = _.findWhere(manifest, { + path: key, + }); + + if (manifestItem) { + const index = manifest.indexOf(manifestItem); + manifest[index] = cache; + } else { + manifest.push(cache); + } + } + + return calculateClientHash.call(this, manifest, includeFilter, runtimeConfigOverride); +}; + +Meteor.methods({ + refreshClients() { + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'refreshClients', + }); + } + + const _hasPermission = hasPermission(Meteor.userId(), 'manage-assets'); + if (!_hasPermission) { + throw new Meteor.Error('error-action-not-allowed', 'Managing assets not allowed', { + method: 'refreshClients', + action: 'Managing_assets', + }); + } + + return RocketChatAssets.refreshClients(); + }, + + unsetAsset(asset) { + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'unsetAsset', + }); + } + + const _hasPermission = hasPermission(Meteor.userId(), 'manage-assets'); + if (!_hasPermission) { + throw new Meteor.Error('error-action-not-allowed', 'Managing assets not allowed', { + method: 'unsetAsset', + action: 'Managing_assets', + }); + } + + return RocketChatAssets.unsetAsset(asset); + }, + + setAsset(binaryContent, contentType, asset) { + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'setAsset', + }); + } + + const _hasPermission = hasPermission(Meteor.userId(), 'manage-assets'); + if (!_hasPermission) { + throw new Meteor.Error('error-action-not-allowed', 'Managing assets not allowed', { + method: 'setAsset', + action: 'Managing_assets', + }); + } + + RocketChatAssets.setAsset(binaryContent, contentType, asset); + }, +}); + +WebApp.connectHandlers.use('/assets/', Meteor.bindEnvironment(function(req, res, next) { + const params = { + asset: decodeURIComponent(req.url.replace(/^\//, '').replace(/\?.*$/, '')).replace(/\.[^.]*$/, ''), + }; + + const file = assets[params.asset] && assets[params.asset].cache; + + const format = req.url.replace(/.*\.([a-z]+)$/, '$1'); + + if (!file) { + const defaultUrl = assets[params.asset] && assets[params.asset].defaultUrl; + if (defaultUrl) { + const assetUrl = format && ['png', 'svg'].includes(format) ? defaultUrl.replace(/(svg|png)$/, format) : defaultUrl; + req.url = `/${ assetUrl }`; + WebAppInternals.staticFilesMiddleware(WebAppInternals.staticFiles, req, res, next); + } else { + res.writeHead(404); + res.end(); + } + + return; + } + + const reqModifiedHeader = req.headers['if-modified-since']; + if (reqModifiedHeader) { + if (reqModifiedHeader === (file.uploadDate && file.uploadDate.toUTCString())) { + res.setHeader('Last-Modified', reqModifiedHeader); + res.writeHead(304); + res.end(); + return; + } + } + + res.setHeader('Cache-Control', 'public, max-age=0'); + res.setHeader('Expires', '-1'); + + if (format && format !== file.extension && ['png', 'jpg', 'jpeg'].includes(format)) { + res.setHeader('Content-Type', `image/${ format }`); + sharp(file.content) + .toFormat(format) + .pipe(res); + return; + } + + res.setHeader('Last-Modified', (file.uploadDate && file.uploadDate.toUTCString()) || new Date().toUTCString()); + res.setHeader('Content-Type', file.contentType); + res.setHeader('Content-Length', file.size); + res.writeHead(200); + res.end(file.content); +})); diff --git a/app/assets/server/index.js b/app/assets/server/index.js new file mode 100644 index 000000000000..8e5b30ff6b6f --- /dev/null +++ b/app/assets/server/index.js @@ -0,0 +1 @@ +export { RocketChatAssets } from './assets'; diff --git a/packages/rocketchat-authorization/README.md b/app/authorization/README.md similarity index 100% rename from packages/rocketchat-authorization/README.md rename to app/authorization/README.md diff --git a/app/authorization/client/hasPermission.js b/app/authorization/client/hasPermission.js new file mode 100644 index 000000000000..33cee33364fe --- /dev/null +++ b/app/authorization/client/hasPermission.js @@ -0,0 +1,57 @@ +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; +import * as Models from '../../models'; +import { ChatPermissions } from './lib/ChatPermissions'; + +function atLeastOne(permissions = [], scope) { + return permissions.some((permissionId) => { + const permission = ChatPermissions.findOne(permissionId, { fields: { roles: 1 } }); + const roles = (permission && permission.roles) || []; + + return roles.some((roleName) => { + const role = Models.Roles.findOne(roleName, { fields: { scope: 1 } }); + const roleScope = role && role.scope; + const model = Models[roleScope]; + + return model && model.isUserInRole && model.isUserInRole(Meteor.userId(), roleName, scope); + }); + }); +} + +function all(permissions = [], scope) { + return permissions.every((permissionId) => { + const permission = ChatPermissions.findOne(permissionId, { fields: { roles: 1 } }); + const roles = (permission && permission.roles) || []; + + return roles.some((roleName) => { + const role = Models.Roles.findOne(roleName, { fields: { scope: 1 } }); + const roleScope = role && role.scope; + const model = Models[roleScope]; + + return model && model.isUserInRole && model.isUserInRole(Meteor.userId(), roleName, scope); + }); + }); +} + +function _hasPermission(permissions, scope, strategy) { + const userId = Meteor.userId(); + if (!userId) { + return false; + } + + if (!Models.AuthzCachedCollection.ready.get()) { + return false; + } + + permissions = [].concat(permissions); + return strategy(permissions, scope); +} + +Template.registerHelper('hasPermission', function(permission, scope) { + return _hasPermission(permission, scope, atLeastOne); +}); + +export const hasAllPermission = (permissions, scope) => _hasPermission(permissions, scope, all); +export const hasAtLeastOnePermission = (permissions, scope) => _hasPermission(permissions, scope, atLeastOne); +export const hasPermission = hasAllPermission; + diff --git a/app/authorization/client/hasRole.js b/app/authorization/client/hasRole.js new file mode 100644 index 000000000000..710ae1803cae --- /dev/null +++ b/app/authorization/client/hasRole.js @@ -0,0 +1,6 @@ +import { Roles } from '../../models'; + +export const hasRole = (userId, roleNames, scope) => { + roleNames = [].concat(roleNames); + return Roles.isUserInRoles(userId, roleNames, scope); +}; diff --git a/packages/rocketchat-authorization/client/index.js b/app/authorization/client/index.js similarity index 100% rename from packages/rocketchat-authorization/client/index.js rename to app/authorization/client/index.js diff --git a/app/authorization/client/lib/ChatPermissions.js b/app/authorization/client/lib/ChatPermissions.js new file mode 100644 index 000000000000..faafd72fa5e9 --- /dev/null +++ b/app/authorization/client/lib/ChatPermissions.js @@ -0,0 +1,3 @@ +import { AuthzCachedCollection } from '../../../models'; + +export const ChatPermissions = AuthzCachedCollection.collection; diff --git a/packages/rocketchat-authorization/client/requiresPermission.html b/app/authorization/client/requiresPermission.html similarity index 100% rename from packages/rocketchat-authorization/client/requiresPermission.html rename to app/authorization/client/requiresPermission.html diff --git a/app/authorization/client/route.js b/app/authorization/client/route.js new file mode 100644 index 000000000000..48a54e147773 --- /dev/null +++ b/app/authorization/client/route.js @@ -0,0 +1,35 @@ +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { BlazeLayout } from 'meteor/kadira:blaze-layout'; +import { t } from '../../utils/client'; + +FlowRouter.route('/admin/permissions', { + name: 'admin-permissions', + action(/* params*/) { + return BlazeLayout.render('main', { + center: 'permissions', + pageTitle: t('Permissions'), + }); + }, +}); + +FlowRouter.route('/admin/permissions/:name?/edit', { + name: 'admin-permissions-edit', + action(/* params*/) { + return BlazeLayout.render('main', { + center: 'pageContainer', + pageTitle: t('Role_Editing'), + pageTemplate: 'permissionsRole', + }); + }, +}); + +FlowRouter.route('/admin/permissions/new', { + name: 'admin-permissions-new', + action(/* params*/) { + return BlazeLayout.render('main', { + center: 'pageContainer', + pageTitle: t('Role_Editing'), + pageTemplate: 'permissionsRole', + }); + }, +}); diff --git a/app/authorization/client/startup.js b/app/authorization/client/startup.js new file mode 100644 index 000000000000..f7ca7b7a8892 --- /dev/null +++ b/app/authorization/client/startup.js @@ -0,0 +1,18 @@ +import { Meteor } from 'meteor/meteor'; +import { CachedCollectionManager } from '../../ui-cached-collection'; +import { hasAllPermission } from './hasPermission'; +import { AdminBox } from '../../ui-utils/client/lib/AdminBox'; + +Meteor.startup(() => { + + CachedCollectionManager.onLogin(() => Meteor.subscribe('roles')); + + AdminBox.addOption({ + href: 'admin-permissions', + i18nLabel: 'Permissions', + icon: 'lock', + permissionGranted() { + return hasAllPermission('access-permissions'); + }, + }); +}); diff --git a/app/authorization/client/stylesheets/permissions.css b/app/authorization/client/stylesheets/permissions.css new file mode 100644 index 000000000000..aaca6008a0cf --- /dev/null +++ b/app/authorization/client/stylesheets/permissions.css @@ -0,0 +1,74 @@ +.permissions-manager { + .permission-grid { + overflow-x: scroll; + + table-layout: fixed; + + border-collapse: collapse; + + .id-styler { + color: #7f7f7f; + + font-size: smaller; + } + + .role-name { + width: 70px; + height: 80px; + + text-align: left; + + vertical-align: middle; + + border-right: 1px solid black; + border-left: 1px solid black; + } + + .role-name-edit-icon { + width: 70px; + height: 70px; + + text-align: center; + + vertical-align: middle; + + border-right: 1px solid black; + border-left: 1px solid black; + } + + .rotator { + transform: rotate(-90deg); + } + + .admin-table-row { + height: 50px; + } + + td { + overflow: hidden; + } + + .permission-name { + width: 25%; + padding-left: 14px; + + vertical-align: middle; + } + + .admin-table-row .permission-name { + border-top: 1px solid black; + border-bottom: 1px solid black; + } + + .permission-checkbox { + text-align: center; + vertical-align: middle; + + border: 1px solid black; + } + + .icon-edit { + font-size: 1.5em; + } + } +} diff --git a/app/authorization/client/usersNameChanged.js b/app/authorization/client/usersNameChanged.js new file mode 100644 index 000000000000..2409ab5ccc30 --- /dev/null +++ b/app/authorization/client/usersNameChanged.js @@ -0,0 +1,17 @@ +import { Meteor } from 'meteor/meteor'; +import { Notifications } from '../../notifications'; +import { RoomRoles } from '../../models'; + +Meteor.startup(function() { + Notifications.onLogged('Users:NameChanged', function({ _id, name }) { + RoomRoles.update({ + 'u._id': _id, + }, { + $set: { + 'u.name': name, + }, + }, { + multi: true, + }); + }); +}); diff --git a/app/authorization/client/views/permissions.html b/app/authorization/client/views/permissions.html new file mode 100644 index 000000000000..281f3d584c4e --- /dev/null +++ b/app/authorization/client/views/permissions.html @@ -0,0 +1,62 @@ + diff --git a/app/authorization/client/views/permissions.js b/app/authorization/client/views/permissions.js new file mode 100644 index 000000000000..2d34fad1698f --- /dev/null +++ b/app/authorization/client/views/permissions.js @@ -0,0 +1,89 @@ +import { Meteor } from 'meteor/meteor'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { Tracker } from 'meteor/tracker'; +import { Template } from 'meteor/templating'; +import { Roles } from '../../../models'; +import { ChatPermissions } from '../lib/ChatPermissions'; +import { hasAllPermission } from '../hasPermission'; +import { SideNav } from '../../../ui-utils/client/lib/SideNav'; + +Template.permissions.helpers({ + role() { + return Template.instance().roles.get(); + }, + + permission() { + return ChatPermissions.find({}, { + sort: { + _id: 1, + }, + }); + }, + + granted(roles) { + if (roles) { + if (roles.indexOf(this._id) !== -1) { + return 'checked'; + } + } + }, + + permissionName() { + return `${ this._id }`; + }, + + permissionDescription() { + return `${ this._id }_description`; + }, + + hasPermission() { + return hasAllPermission('access-permissions'); + }, +}); + +Template.permissions.events({ + 'click .role-permission'(e, instance) { + const permission = e.currentTarget.getAttribute('data-permission'); + const role = e.currentTarget.getAttribute('data-role'); + + if (instance.permissionByRole[permission].indexOf(role) === -1) { + return Meteor.call('authorization:addPermissionToRole', permission, role); + } else { + return Meteor.call('authorization:removeRoleFromPermission', permission, role); + } + }, +}); + +Template.permissions.onCreated(function() { + this.roles = new ReactiveVar([]); + this.permissionByRole = {}; + this.actions = { + added: {}, + removed: {}, + }; + + Tracker.autorun(() => { + this.roles.set(Roles.find().fetch()); + }); + + Tracker.autorun(() => { + ChatPermissions.find().observeChanges({ + added: (id, fields) => { + this.permissionByRole[id] = fields.roles; + }, + changed: (id, fields) => { + this.permissionByRole[id] = fields.roles; + }, + removed: (id) => { + delete this.permissionByRole[id]; + }, + }); + }); +}); + +Template.permissions.onRendered(() => { + Tracker.afterFlush(() => { + SideNav.setFlex('adminFlex'); + SideNav.openFlex(); + }); +}); diff --git a/packages/rocketchat-authorization/client/views/permissionsRole.html b/app/authorization/client/views/permissionsRole.html similarity index 100% rename from packages/rocketchat-authorization/client/views/permissionsRole.html rename to app/authorization/client/views/permissionsRole.html diff --git a/packages/rocketchat-authorization/client/views/permissionsRole.js b/app/authorization/client/views/permissionsRole.js similarity index 90% rename from packages/rocketchat-authorization/client/views/permissionsRole.js rename to app/authorization/client/views/permissionsRole.js index 64a08018d7ce..1a7af89bd2ca 100644 --- a/packages/rocketchat-authorization/client/views/permissionsRole.js +++ b/app/authorization/client/views/permissionsRole.js @@ -2,12 +2,14 @@ import { Meteor } from 'meteor/meteor'; import { ReactiveVar } from 'meteor/reactive-var'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { Template } from 'meteor/templating'; -import { t, handleError } from 'meteor/rocketchat:utils'; -import { Roles } from 'meteor/rocketchat:models'; +import { handleError } from '../../../utils/client/lib/handleError'; +import { t } from '../../../utils/lib/tapi18n'; +import { Tracker } from 'meteor/tracker'; +import { Roles } from '../../../models'; import { hasAllPermission } from '../hasPermission'; +import { modal } from '../../../ui-utils/client/lib/modal'; import toastr from 'toastr'; - -let _modal; +import { SideNav } from '../../../ui-utils/client/lib/SideNav'; Template.permissionsRole.helpers({ role() { @@ -113,13 +115,10 @@ Template.permissionsRole.helpers({ Template.permissionsRole.events({ async 'click .remove-user'(e, instance) { - if (!_modal) { - const { modal } = await import('meteor/rocketchat:ui-utils'); - _modal = modal; - } e.preventDefault(); - _modal.open({ + modal.open({ title: t('Are_you_sure'), + text: t('The_user_s_will_be_removed_from_role_s', this.username, FlowRouter.getParam('name')), type: 'warning', showCancelButton: true, confirmButtonColor: '#DD6B55', @@ -133,7 +132,7 @@ Template.permissionsRole.events({ return handleError(error); } - _modal.open({ + modal.open({ title: t('Removed'), text: t('User_removed'), type: 'success', @@ -250,3 +249,10 @@ Template.permissionsRole.onCreated(function() { })); }); }); + +Template.permissionsRole.onRendered(() => { + Tracker.afterFlush(() => { + SideNav.setFlex('adminFlex'); + SideNav.openFlex(); + }); +}); diff --git a/app/authorization/index.js b/app/authorization/index.js new file mode 100644 index 000000000000..a67eca871efb --- /dev/null +++ b/app/authorization/index.js @@ -0,0 +1,8 @@ +import { Meteor } from 'meteor/meteor'; + +if (Meteor.isClient) { + module.exports = require('./client/index.js'); +} +if (Meteor.isServer) { + module.exports = require('./server/index.js'); +} diff --git a/packages/rocketchat-authorization/server/functions/addUserRoles.js b/app/authorization/server/functions/addUserRoles.js similarity index 92% rename from packages/rocketchat-authorization/server/functions/addUserRoles.js rename to app/authorization/server/functions/addUserRoles.js index 784c10813404..49962c406329 100644 --- a/packages/rocketchat-authorization/server/functions/addUserRoles.js +++ b/app/authorization/server/functions/addUserRoles.js @@ -1,5 +1,5 @@ import { Meteor } from 'meteor/meteor'; -import { Users, Roles } from 'meteor/rocketchat:models'; +import { Users, Roles } from '../../../models'; import { getRoles } from './getRoles'; import _ from 'underscore'; diff --git a/packages/rocketchat-authorization/server/functions/canAccessRoom.js b/app/authorization/server/functions/canAccessRoom.js similarity index 86% rename from packages/rocketchat-authorization/server/functions/canAccessRoom.js rename to app/authorization/server/functions/canAccessRoom.js index 454aa8bcd2c5..246fbada4234 100644 --- a/packages/rocketchat-authorization/server/functions/canAccessRoom.js +++ b/app/authorization/server/functions/canAccessRoom.js @@ -1,5 +1,5 @@ -import { settings } from 'meteor/rocketchat:settings'; -import { Subscriptions } from 'meteor/rocketchat:models'; +import { settings } from '../../../settings'; +import Subscriptions from '../../../models/server/models/Subscriptions'; import { hasPermission } from './hasPermission'; export const roomAccessValidators = [ diff --git a/app/authorization/server/functions/canSendMessage.js b/app/authorization/server/functions/canSendMessage.js new file mode 100644 index 000000000000..5bff36f09e40 --- /dev/null +++ b/app/authorization/server/functions/canSendMessage.js @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; +import { Rooms, Subscriptions } from '../../../models'; +import { canAccessRoom } from './canAccessRoom'; + +export const canSendMessage = (rid, { uid, username }, extraData) => { + const room = Rooms.findOneById(rid); + + if (!canAccessRoom.call(this, room, { _id: uid, username }, extraData)) { + throw new Meteor.Error('error-not-allowed'); + } + + const subscription = Subscriptions.findOneByRoomIdAndUserId(rid, uid); + if (subscription && (subscription.blocked || subscription.blocker)) { + throw new Meteor.Error('room_is_blocked'); + } + + if ((room.muted || []).includes(username)) { + throw new Meteor.Error('You_have_been_muted'); + } + + return room; +}; diff --git a/app/authorization/server/functions/getRoles.js b/app/authorization/server/functions/getRoles.js new file mode 100644 index 000000000000..9d20c72d29a9 --- /dev/null +++ b/app/authorization/server/functions/getRoles.js @@ -0,0 +1,3 @@ +import { Roles } from '../../../models'; + +export const getRoles = () => Roles.find().fetch(); diff --git a/app/authorization/server/functions/getUsersInRole.js b/app/authorization/server/functions/getUsersInRole.js new file mode 100644 index 000000000000..255a19485970 --- /dev/null +++ b/app/authorization/server/functions/getUsersInRole.js @@ -0,0 +1,4 @@ +import { Roles } from '../../../models'; + +export const getUsersInRole = (roleName, scope, options) => Roles.findUsersInRole(roleName, scope, options); + diff --git a/app/authorization/server/functions/hasPermission.js b/app/authorization/server/functions/hasPermission.js new file mode 100644 index 000000000000..90226dd84982 --- /dev/null +++ b/app/authorization/server/functions/hasPermission.js @@ -0,0 +1,35 @@ +import Roles from '../../../models/server/models/Roles'; +import Permissions from '../../../models/server/models/Permissions'; + +function atLeastOne(userId, permissions = [], scope) { + return permissions.some((permissionId) => { + const permission = Permissions.findOne(permissionId); + return Roles.isUserInRoles(userId, permission.roles, scope); + }); +} + +function all(userId, permissions = [], scope) { + return permissions.every((permissionId) => { + const permission = Permissions.findOne(permissionId); + return Roles.isUserInRoles(userId, permission.roles, scope); + }); +} + +function _hasPermission(userId, permissions, scope, strategy) { + if (!userId) { + return false; + } + return strategy(userId, [].concat(permissions), scope); +} + +export const hasAllPermission = (userId, permissions, scope) => _hasPermission(userId, permissions, scope, all); + +export const hasPermission = (userId, permissionId, scope) => { + if (!userId) { + return false; + } + const permission = Permissions.findOne(permissionId); + return Roles.isUserInRoles(userId, permission.roles, scope); +}; + +export const hasAtLeastOnePermission = (userId, permissions, scope) => _hasPermission(userId, permissions, scope, atLeastOne); diff --git a/app/authorization/server/functions/hasRole.js b/app/authorization/server/functions/hasRole.js new file mode 100644 index 000000000000..879aeaa01241 --- /dev/null +++ b/app/authorization/server/functions/hasRole.js @@ -0,0 +1,6 @@ +import { Roles } from '../../../models'; + +export const hasRole = (userId, roleNames, scope) => { + roleNames = [].concat(roleNames); + return Roles.isUserInRoles(userId, roleNames, scope); +}; diff --git a/packages/rocketchat-authorization/server/functions/removeUserFromRoles.js b/app/authorization/server/functions/removeUserFromRoles.js similarity index 93% rename from packages/rocketchat-authorization/server/functions/removeUserFromRoles.js rename to app/authorization/server/functions/removeUserFromRoles.js index f351441c7be7..8f2b207e6d1d 100644 --- a/packages/rocketchat-authorization/server/functions/removeUserFromRoles.js +++ b/app/authorization/server/functions/removeUserFromRoles.js @@ -1,5 +1,5 @@ import { Meteor } from 'meteor/meteor'; -import { Users, Roles } from 'meteor/rocketchat:models'; +import { Users, Roles } from '../../../models'; import { getRoles } from './getRoles'; import _ from 'underscore'; diff --git a/app/authorization/server/index.js b/app/authorization/server/index.js new file mode 100644 index 000000000000..a4d74beba167 --- /dev/null +++ b/app/authorization/server/index.js @@ -0,0 +1,33 @@ +import { addUserRoles } from './functions/addUserRoles'; +import { addRoomAccessValidator, canAccessRoom, roomAccessValidators } from './functions/canAccessRoom'; +import { canSendMessage } from './functions/canSendMessage'; +import { getRoles } from './functions/getRoles'; +import { getUsersInRole } from './functions/getUsersInRole'; +import { hasAllPermission, hasAtLeastOnePermission, hasPermission } from './functions/hasPermission'; +import { hasRole } from './functions/hasRole'; +import { removeUserFromRoles } from './functions/removeUserFromRoles'; +import './methods/addPermissionToRole'; +import './methods/addUserToRole'; +import './methods/deleteRole'; +import './methods/removeRoleFromPermission'; +import './methods/removeUserFromRole'; +import './methods/saveRole'; +import './publications/permissions'; +import './publications/roles'; +import './publications/usersInRole'; +import './startup'; + +export { + getRoles, + getUsersInRole, + hasAllPermission, + hasAtLeastOnePermission, + hasPermission, + hasRole, + removeUserFromRoles, + canAccessRoom, + canSendMessage, + addRoomAccessValidator, + roomAccessValidators, + addUserRoles, +}; diff --git a/packages/rocketchat-authorization/server/methods/addPermissionToRole.js b/app/authorization/server/methods/addPermissionToRole.js similarity index 89% rename from packages/rocketchat-authorization/server/methods/addPermissionToRole.js rename to app/authorization/server/methods/addPermissionToRole.js index 312e6b5376e2..7ec25d35d684 100644 --- a/packages/rocketchat-authorization/server/methods/addPermissionToRole.js +++ b/app/authorization/server/methods/addPermissionToRole.js @@ -1,5 +1,5 @@ import { Meteor } from 'meteor/meteor'; -import { Permissions } from 'meteor/rocketchat:models'; +import { Permissions } from '../../../models'; import { hasPermission } from '../functions/hasPermission'; Meteor.methods({ diff --git a/packages/rocketchat-authorization/server/methods/addUserToRole.js b/app/authorization/server/methods/addUserToRole.js similarity index 89% rename from packages/rocketchat-authorization/server/methods/addUserToRole.js rename to app/authorization/server/methods/addUserToRole.js index ce2693965761..e1cba8a9839c 100644 --- a/packages/rocketchat-authorization/server/methods/addUserToRole.js +++ b/app/authorization/server/methods/addUserToRole.js @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; -import { Users, Roles } from 'meteor/rocketchat:models'; -import { settings } from 'meteor/rocketchat:settings'; -import { Notifications } from 'meteor/rocketchat:notifications'; +import { Users, Roles } from '../../../models'; +import { settings } from '../../../settings'; +import { Notifications } from '../../../notifications'; import { hasPermission } from '../functions/hasPermission'; import _ from 'underscore'; diff --git a/packages/rocketchat-authorization/server/methods/deleteRole.js b/app/authorization/server/methods/deleteRole.js similarity index 95% rename from packages/rocketchat-authorization/server/methods/deleteRole.js rename to app/authorization/server/methods/deleteRole.js index fa82a0c580bd..aa34b9b76f14 100644 --- a/packages/rocketchat-authorization/server/methods/deleteRole.js +++ b/app/authorization/server/methods/deleteRole.js @@ -1,5 +1,5 @@ import { Meteor } from 'meteor/meteor'; -import * as Models from 'meteor/rocketchat:models'; +import * as Models from '../../../models'; import { hasPermission } from '../functions/hasPermission'; Meteor.methods({ diff --git a/packages/rocketchat-authorization/server/methods/removeRoleFromPermission.js b/app/authorization/server/methods/removeRoleFromPermission.js similarity index 90% rename from packages/rocketchat-authorization/server/methods/removeRoleFromPermission.js rename to app/authorization/server/methods/removeRoleFromPermission.js index f68c62d9a5b2..0f83861be2a9 100644 --- a/packages/rocketchat-authorization/server/methods/removeRoleFromPermission.js +++ b/app/authorization/server/methods/removeRoleFromPermission.js @@ -1,5 +1,5 @@ import { Meteor } from 'meteor/meteor'; -import { Permissions } from 'meteor/rocketchat:models'; +import { Permissions } from '../../../models'; import { hasPermission } from '../functions/hasPermission'; Meteor.methods({ diff --git a/packages/rocketchat-authorization/server/methods/removeUserFromRole.js b/app/authorization/server/methods/removeUserFromRole.js similarity index 91% rename from packages/rocketchat-authorization/server/methods/removeUserFromRole.js rename to app/authorization/server/methods/removeUserFromRole.js index 012db582bbcb..241f97ff9355 100644 --- a/packages/rocketchat-authorization/server/methods/removeUserFromRole.js +++ b/app/authorization/server/methods/removeUserFromRole.js @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; -import { Roles } from 'meteor/rocketchat:models'; -import { settings } from 'meteor/rocketchat:settings'; -import { Notifications } from 'meteor/rocketchat:notifications'; +import { Roles } from '../../../models'; +import { settings } from '../../../settings'; +import { Notifications } from '../../../notifications'; import { hasPermission } from '../functions/hasPermission'; import _ from 'underscore'; diff --git a/packages/rocketchat-authorization/server/methods/saveRole.js b/app/authorization/server/methods/saveRole.js similarity index 85% rename from packages/rocketchat-authorization/server/methods/saveRole.js rename to app/authorization/server/methods/saveRole.js index 7cf2cf659d91..4d7271932a33 100644 --- a/packages/rocketchat-authorization/server/methods/saveRole.js +++ b/app/authorization/server/methods/saveRole.js @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; -import { Roles } from 'meteor/rocketchat:models'; -import { settings } from 'meteor/rocketchat:settings'; -import { Notifications } from 'meteor/rocketchat:notifications'; +import { Roles } from '../../../models'; +import { settings } from '../../../settings'; +import { Notifications } from '../../../notifications'; import { hasPermission } from '../functions/hasPermission'; Meteor.methods({ diff --git a/app/authorization/server/publications/permissions.js b/app/authorization/server/publications/permissions.js new file mode 100644 index 000000000000..e98d745d58ae --- /dev/null +++ b/app/authorization/server/publications/permissions.js @@ -0,0 +1,37 @@ +import { Meteor } from 'meteor/meteor'; +import Permissions from '../../../models/server/models/Permissions'; +import { Notifications } from '../../../notifications'; + +Meteor.methods({ + 'permissions/get'(updatedAt) { + this.unblock(); + // TODO: should we return this for non logged users? + // TODO: we could cache this collection + + const records = Permissions.find().fetch(); + + if (updatedAt instanceof Date) { + return { + update: records.filter((record) => record._updatedAt > updatedAt), + remove: Permissions.trashFindDeletedAfter(updatedAt, {}, { fields: { _id: 1, _deletedAt: 1 } }).fetch(), + }; + } + + return records; + }, +}); + +Permissions.on('change', ({ clientAction, id, data }) => { + switch (clientAction) { + case 'updated': + case 'inserted': + data = data || Permissions.findOneById(id); + break; + + case 'removed': + data = { _id: id }; + break; + } + + Notifications.notifyLoggedInThisInstance('permissions-changed', clientAction, data); +}); diff --git a/app/authorization/server/publications/roles.js b/app/authorization/server/publications/roles.js new file mode 100644 index 000000000000..868174a7c11f --- /dev/null +++ b/app/authorization/server/publications/roles.js @@ -0,0 +1,10 @@ +import { Meteor } from 'meteor/meteor'; +import { Roles } from '../../../models'; + +Meteor.publish('roles', function() { + if (!this.userId) { + return this.ready(); + } + + return Roles.find(); +}); diff --git a/packages/rocketchat-authorization/server/publications/usersInRole.js b/app/authorization/server/publications/usersInRole.js similarity index 100% rename from packages/rocketchat-authorization/server/publications/usersInRole.js rename to app/authorization/server/publications/usersInRole.js diff --git a/app/authorization/server/startup.js b/app/authorization/server/startup.js new file mode 100644 index 000000000000..0ec0476ec9de --- /dev/null +++ b/app/authorization/server/startup.js @@ -0,0 +1,103 @@ +/* eslint no-multi-spaces: 0 */ +import { Meteor } from 'meteor/meteor'; +import { Roles, Permissions } from '../../models'; + +Meteor.startup(function() { + // Note: + // 1.if we need to create a role that can only edit channel message, but not edit group message + // then we can define edit--message instead of edit-message + // 2. admin, moderator, and user roles should not be deleted as they are referened in the code. + const permissions = [ + { _id: 'access-permissions', roles : ['admin'] }, + { _id: 'add-oauth-service', roles : ['admin'] }, + { _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: 'api-bypass-rate-limit', roles : ['admin', 'bot'] }, + { _id: 'archive-room', roles : ['admin', 'owner'] }, + { _id: 'assign-admin-role', roles : ['admin'] }, + { _id: 'assign-roles', roles : ['admin'] }, + { _id: 'ban-user', roles : ['admin', 'owner', 'moderator'] }, + { _id: 'bulk-create-c', roles : ['admin'] }, + { _id: 'bulk-register-user', roles : ['admin'] }, + { _id: 'create-c', roles : ['admin', 'user', 'bot'] }, + { _id: 'create-d', roles : ['admin', 'user', 'bot'] }, + { _id: 'create-p', roles : ['admin', 'user', 'bot'] }, + { _id: 'create-personal-access-tokens', roles : ['admin', 'user'] }, + { _id: 'create-user', roles : ['admin'] }, + { _id: 'clean-channel-history', roles : ['admin'] }, + { _id: 'delete-c', roles : ['admin', 'owner'] }, + { _id: 'delete-d', roles : ['admin'] }, + { _id: 'delete-message', roles : ['admin', 'owner', 'moderator'] }, + { _id: 'delete-p', roles : ['admin', 'owner'] }, + { _id: 'delete-user', roles : ['admin'] }, + { _id: 'edit-message', roles : ['admin', 'owner', 'moderator'] }, + { _id: 'edit-other-user-active-status', roles : ['admin'] }, + { _id: 'edit-other-user-info', roles : ['admin'] }, + { _id: 'edit-other-user-password', roles : ['admin'] }, + { _id: 'edit-other-user-avatar', roles : ['admin'] }, + { _id: 'edit-privileged-setting', roles : ['admin'] }, + { _id: 'edit-room', roles : ['admin', 'owner', 'moderator'] }, + { _id: 'edit-room-retention-policy', roles : ['admin'] }, + { _id: 'force-delete-message', roles : ['admin', 'owner'] }, + { _id: 'join-without-join-code', roles : ['admin', 'bot'] }, + { _id: 'leave-c', roles : ['admin', 'user', 'bot', 'anonymous'] }, + { _id: 'leave-p', roles : ['admin', 'user', 'bot', 'anonymous'] }, + { _id: 'manage-assets', roles : ['admin'] }, + { _id: 'manage-emoji', roles : ['admin'] }, + { _id: 'manage-integrations', roles : ['admin'] }, + { _id: 'manage-own-integrations', roles : ['admin'] }, + { _id: 'manage-oauth-apps', roles : ['admin'] }, + { _id: 'mention-all', roles : ['admin', 'owner', 'moderator', 'user'] }, + { _id: 'mention-here', roles : ['admin', 'owner', 'moderator', 'user'] }, + { _id: 'mute-user', roles : ['admin', 'owner', 'moderator'] }, + { _id: 'remove-user', roles : ['admin', 'owner', 'moderator'] }, + { _id: 'reset-other-user-e2e-key', roles : ['admin'] }, + { _id: 'run-import', roles : ['admin'] }, + { _id: 'run-migration', roles : ['admin'] }, + { _id: 'set-moderator', roles : ['admin', 'owner'] }, + { _id: 'set-owner', roles : ['admin', 'owner'] }, + { _id: 'send-many-messages', roles : ['admin', 'bot'] }, + { _id: 'set-leader', roles : ['admin', 'owner'] }, + { _id: 'unarchive-room', roles : ['admin'] }, + { _id: 'view-c-room', roles : ['admin', 'user', 'bot', 'anonymous'] }, + { _id: 'user-generate-access-token', roles : ['admin'] }, + { _id: 'view-d-room', roles : ['admin', 'user', 'bot'] }, + { _id: 'view-full-other-user-info', roles : ['admin'] }, + { _id: 'view-history', roles : ['admin', 'user', 'anonymous'] }, + { _id: 'view-joined-room', roles : ['guest', 'bot', 'anonymous'] }, + { _id: 'view-join-code', roles : ['admin'] }, + { _id: 'view-logs', roles : ['admin'] }, + { _id: 'view-other-user-channels', roles : ['admin'] }, + { _id: 'view-p-room', roles : ['admin', 'user', 'anonymous'] }, + { _id: 'view-privileged-setting', roles : ['admin'] }, + { _id: 'view-room-administration', roles : ['admin'] }, + { _id: 'view-statistics', roles : ['admin'] }, + { _id: 'view-user-administration', roles : ['admin'] }, + { _id: 'preview-c-room', roles : ['admin', 'user', 'anonymous'] }, + { _id: 'view-outside-room', roles : ['admin', 'owner', 'moderator', 'user'] }, + { _id: 'view-broadcast-member-list', roles : ['admin', 'owner', 'moderator'] }, + { _id: 'call-management', roles : ['admin', 'owner', 'moderator'] }, + ]; + + for (const permission of permissions) { + if (!Permissions.findOneById(permission._id)) { + Permissions.upsert(permission._id, { $set: permission }); + } + } + + const defaultRoles = [ + { name: 'admin', scope: 'Users', description: 'Admin' }, + { name: 'moderator', scope: 'Subscriptions', description: 'Moderator' }, + { name: 'leader', scope: 'Subscriptions', description: 'Leader' }, + { name: 'owner', scope: 'Subscriptions', description: 'Owner' }, + { name: 'user', scope: 'Users', description: '' }, + { name: 'bot', scope: 'Users', description: '' }, + { name: 'guest', scope: 'Users', description: '' }, + { name: 'anonymous', scope: 'Users', description: '' }, + ]; + + for (const role of defaultRoles) { + Roles.upsert({ _id: role.name }, { $setOnInsert: { scope: role.scope, description: role.description || '', protected: true, mandatory2fa: false } }); + } +}); diff --git a/app/autolinker/client/client.js b/app/autolinker/client/client.js new file mode 100644 index 000000000000..2b6d46cc58c3 --- /dev/null +++ b/app/autolinker/client/client.js @@ -0,0 +1,74 @@ +import { Meteor } from 'meteor/meteor'; +import s from 'underscore.string'; +import { settings } from '../../settings'; +import { callbacks } from '../../callbacks'; +import Autolinker from 'autolinker'; + +const createAutolinker = () => { + const regUrls = new RegExp(settings.get('AutoLinker_UrlsRegExp')); + + const replaceAutolinkerMatch = (match) => { + if (match.getType() !== 'url') { + return null; + } + + if (!regUrls.test(match.matchedText)) { + return null; + } + + if (match.matchedText.indexOf(Meteor.absoluteUrl()) === 0) { + const tag = match.buildTag(); + tag.setAttr('target', ''); + return tag; + } + + return true; + }; + + return new Autolinker({ + stripPrefix: settings.get('AutoLinker_StripPrefix'), + urls: { + schemeMatches: settings.get('AutoLinker_Urls_Scheme'), + wwwMatches: settings.get('AutoLinker_Urls_www'), + tldMatches: settings.get('AutoLinker_Urls_TLD'), + }, + email: settings.get('AutoLinker_Email'), + phone: settings.get('AutoLinker_Phone'), + twitter: false, + stripTrailingSlash: false, + replaceFn: replaceAutolinkerMatch, + }); +}; + +const renderMessage = (message) => { + if (settings.get('AutoLinker') !== true) { + return message; + } + + if (!s.trim(message.html)) { + return message; + } + + let msgParts; + let regexTokens; + if (message.tokens && message.tokens.length) { + regexTokens = new RegExp(`(${ (message.tokens || []).map(({ token }) => RegExp.escape(token)) })`, 'g'); + msgParts = message.html.split(regexTokens); + } else { + msgParts = [message.html]; + } + const autolinker = createAutolinker(); + message.html = msgParts + .map((msgPart) => { + if (regexTokens && regexTokens.test(msgPart)) { + return msgPart; + } + + return autolinker.link(msgPart); + }) + .join(''); + + return message; +}; + +callbacks.add('renderMessage', renderMessage, callbacks.priority.LOW, 'autolinker'); diff --git a/packages/rocketchat-autolinker/client/index.js b/app/autolinker/client/index.js similarity index 100% rename from packages/rocketchat-autolinker/client/index.js rename to app/autolinker/client/index.js diff --git a/packages/rocketchat-autolinker/server/index.js b/app/autolinker/server/index.js similarity index 100% rename from packages/rocketchat-autolinker/server/index.js rename to app/autolinker/server/index.js diff --git a/app/autolinker/server/settings.js b/app/autolinker/server/settings.js new file mode 100644 index 000000000000..d8b3a32eae2a --- /dev/null +++ b/app/autolinker/server/settings.js @@ -0,0 +1,19 @@ +import { Meteor } from 'meteor/meteor'; +import { settings } from '../../settings'; + +Meteor.startup(function() { + const enableQuery = { + _id: 'AutoLinker', + value: true, + }; + + settings.add('AutoLinker', true, { type: 'boolean', group: 'Message', section: 'AutoLinker', public: true, i18nLabel: 'Enabled' }); + + settings.add('AutoLinker_StripPrefix', false, { type: 'boolean', group: 'Message', section: 'AutoLinker', public: true, i18nDescription: 'AutoLinker_StripPrefix_Description', enableQuery }); + settings.add('AutoLinker_Urls_Scheme', true, { type: 'boolean', group: 'Message', section: 'AutoLinker', public: true, enableQuery }); + settings.add('AutoLinker_Urls_www', true, { type: 'boolean', group: 'Message', section: 'AutoLinker', public: true, enableQuery }); + settings.add('AutoLinker_Urls_TLD', true, { type: 'boolean', group: 'Message', section: 'AutoLinker', public: true, enableQuery }); + settings.add('AutoLinker_UrlsRegExp', '(://|www\\.).+', { type: 'string', group: 'Message', section: 'AutoLinker', public: true, enableQuery }); + settings.add('AutoLinker_Email', true, { type: 'boolean', group: 'Message', section: 'AutoLinker', public: true, enableQuery }); + settings.add('AutoLinker_Phone', true, { type: 'boolean', group: 'Message', section: 'AutoLinker', public: true, i18nDescription: 'AutoLinker_Phone_Description', enableQuery }); +}); diff --git a/packages/rocketchat-autotranslate/README.md b/app/autotranslate/README.md similarity index 100% rename from packages/rocketchat-autotranslate/README.md rename to app/autotranslate/README.md diff --git a/app/autotranslate/client/index.js b/app/autotranslate/client/index.js new file mode 100644 index 000000000000..9852ca567287 --- /dev/null +++ b/app/autotranslate/client/index.js @@ -0,0 +1,5 @@ +import './lib/actionButton'; +export { AutoTranslate } from './lib/autotranslate'; +import './lib/tabBar'; +import './views/autoTranslateFlexTab.html'; +import './views/autoTranslateFlexTab'; diff --git a/app/autotranslate/client/lib/actionButton.js b/app/autotranslate/client/lib/actionButton.js new file mode 100644 index 000000000000..c1983132c4bd --- /dev/null +++ b/app/autotranslate/client/lib/actionButton.js @@ -0,0 +1,66 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; +import { settings } from '../../../settings'; +import { hasAtLeastOnePermission } from '../../../authorization'; +import { MessageAction } from '../../../ui-utils'; +import { messageArgs } from '../../../ui-utils/client/lib/messageArgs'; +import { Messages } from '../../../models'; +import { AutoTranslate } from './autotranslate'; + +Meteor.startup(function() { + Tracker.autorun(function() { + if (settings.get('AutoTranslate_Enabled') && hasAtLeastOnePermission(['auto-translate'])) { + MessageAction.addButton({ + id: 'translate', + icon: 'language', + label: 'Translate', + context: [ + 'message', + 'message-mobile', + ], + action() { + const { msg: message } = messageArgs(this); + const language = AutoTranslate.getLanguage(message.rid); + if ((!message.translations || !message.translations[language])) { // } && !_.find(message.attachments, attachment => { return attachment.translations && attachment.translations[language]; })) { + AutoTranslate.messageIdsToWait[message._id] = true; + Messages.update({ _id: message._id }, { $set: { autoTranslateFetching: true } }); + Meteor.call('autoTranslate.translateMessage', message, language); + } + const action = message.autoTranslateShowInverse ? '$unset' : '$set'; + Messages.update({ _id: message._id }, { [action]: { autoTranslateShowInverse: true } }); + }, + condition(message) { + return message && message.u && message.u._id !== Meteor.userId() && message.translations && !message.translations.original; + }, + order: 90, + }); + MessageAction.addButton({ + id: 'view-original', + icon: 'language', + label: 'View_original', + context: [ + 'message', + 'message-mobile', + ], + action() { + const { msg: message } = messageArgs(this); + const language = AutoTranslate.getLanguage(message.rid); + if ((!message.translations || !message.translations[language])) { // } && !_.find(message.attachments, attachment => { return attachment.translations && attachment.translations[language]; })) { + AutoTranslate.messageIdsToWait[message._id] = true; + Messages.update({ _id: message._id }, { $set: { autoTranslateFetching: true } }); + Meteor.call('autoTranslate.translateMessage', message, language); + } + const action = message.autoTranslateShowInverse ? '$unset' : '$set'; + Messages.update({ _id: message._id }, { [action]: { autoTranslateShowInverse: true } }); + }, + condition(message) { + return message && message.u && message.u._id !== Meteor.userId() && message.translations && message.translations.original; + + }, + order: 90, + }); + } else { + MessageAction.removeButton('toggle-language'); + } + }); +}); diff --git a/app/autotranslate/client/lib/autotranslate.js b/app/autotranslate/client/lib/autotranslate.js new file mode 100644 index 000000000000..9b2c02c4a429 --- /dev/null +++ b/app/autotranslate/client/lib/autotranslate.js @@ -0,0 +1,115 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; +import { Subscriptions, Messages } from '../../../models'; +import { callbacks } from '../../../callbacks'; +import { settings } from '../../../settings'; +import { hasAtLeastOnePermission } from '../../../authorization'; +import { CachedCollectionManager } from '../../../ui-cached-collection'; +import _ from 'underscore'; +import mem from 'mem'; + +export const AutoTranslate = { + findSubscriptionByRid: mem((rid) => Subscriptions.findOne({ rid })), + messageIdsToWait: {}, + supportedLanguages: [], + + getLanguage(rid) { + let subscription = {}; + if (rid) { + subscription = this.findSubscriptionByRid(rid); + } + const language = (subscription && subscription.autoTranslateLanguage) || Meteor.user().language || window.defaultUserLanguage(); + if (language.indexOf('-') !== -1) { + if (!_.findWhere(this.supportedLanguages, { language })) { + return language.substr(0, 2); + } + } + return language; + }, + + translateAttachments(attachments, language) { + for (const attachment of attachments) { + if (attachment.author_name !== Meteor.user().username) { + if (attachment.text && attachment.translations && attachment.translations[language]) { + attachment.text = attachment.translations[language]; + } + + if (attachment.description && attachment.translations && attachment.translations[language]) { + attachment.description = attachment.translations[language]; + } + + if (attachment.attachments && attachment.attachments.length > 0) { + attachment.attachments = this.translateAttachments(attachment.attachments, language); + } + } + } + return attachments; + }, + + init() { + Meteor.call('autoTranslate.getSupportedLanguages', 'en', (err, languages) => { + this.supportedLanguages = languages || []; + }); + + Tracker.autorun(() => { + Subscriptions.find().observeChanges({ + changed: (id, fields) => { + if (fields.hasOwnProperty('autoTranslate')) { + mem.clear(this.findSubscriptionByRid); + } + }, + }); + }); + + Tracker.autorun(() => { + if (settings.get('AutoTranslate_Enabled') && hasAtLeastOnePermission(['auto-translate'])) { + callbacks.add('renderMessage', (message) => { + const subscription = this.findSubscriptionByRid(message.rid); + const autoTranslateLanguage = this.getLanguage(message.rid); + if (message.u && message.u._id !== Meteor.userId()) { + if (!message.translations) { + message.translations = {}; + } + if (!!(subscription && subscription.autoTranslate) !== !!message.autoTranslateShowInverse) { + message.translations.original = message.html; + if (message.translations[autoTranslateLanguage]) { + message.html = message.translations[autoTranslateLanguage]; + } + + if (message.attachments && message.attachments.length > 0) { + message.attachments = this.translateAttachments(message.attachments, autoTranslateLanguage); + } + } + } else if (message.attachments && message.attachments.length > 0) { + message.attachments = this.translateAttachments(message.attachments, autoTranslateLanguage); + } + return message; + }, callbacks.priority.HIGH - 3, 'autotranslate'); + + callbacks.add('streamMessage', (message) => { + if (message.u && message.u._id !== Meteor.userId()) { + const subscription = this.findSubscriptionByRid(message.rid); + const language = this.getLanguage(message.rid); + if (subscription && subscription.autoTranslate === true && ((message.msg && (!message.translations || !message.translations[language])))) { // || (message.attachments && !_.find(message.attachments, attachment => { return attachment.translations && attachment.translations[language]; })) + Messages.update({ _id: message._id }, { $set: { autoTranslateFetching: true } }); + } else if (this.messageIdsToWait[message._id] !== undefined && subscription && subscription.autoTranslate !== true) { + Messages.update({ _id: message._id }, { $set: { autoTranslateShowInverse: true }, $unset: { autoTranslateFetching: true } }); + delete this.messageIdsToWait[message._id]; + } else if (message.autoTranslateFetching === true) { + Messages.update({ _id: message._id }, { $unset: { autoTranslateFetching: true } }); + } + } + }, callbacks.priority.HIGH - 3, 'autotranslate-stream'); + } else { + callbacks.remove('renderMessage', 'autotranslate'); + callbacks.remove('streamMessage', 'autotranslate-stream'); + } + }); + }, +}; + +Meteor.startup(function() { + CachedCollectionManager.onLogin(() => { + AutoTranslate.init(); + }); +}); diff --git a/app/autotranslate/client/lib/tabBar.js b/app/autotranslate/client/lib/tabBar.js new file mode 100644 index 000000000000..d3077ec0c636 --- /dev/null +++ b/app/autotranslate/client/lib/tabBar.js @@ -0,0 +1,21 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; +import { settings } from '../../../settings'; +import { hasAtLeastOnePermission } from '../../../authorization'; +import { TabBar } from '../../../ui-utils'; + +Meteor.startup(function() { + Tracker.autorun(function() { + if (settings.get('AutoTranslate_Enabled') && hasAtLeastOnePermission(['auto-translate'])) { + return TabBar.addButton({ + groups: ['channel', 'group', 'direct'], + id: 'autotranslate', + i18nTitle: 'Auto_Translate', + icon: 'language', + template: 'autoTranslateFlexTab', + order: 20, + }); + } + TabBar.removeButton('autotranslate'); + }); +}); diff --git a/packages/rocketchat-autotranslate/client/stylesheets/autotranslate.css b/app/autotranslate/client/stylesheets/autotranslate.css similarity index 100% rename from packages/rocketchat-autotranslate/client/stylesheets/autotranslate.css rename to app/autotranslate/client/stylesheets/autotranslate.css diff --git a/packages/rocketchat-autotranslate/client/views/autoTranslateFlexTab.html b/app/autotranslate/client/views/autoTranslateFlexTab.html similarity index 100% rename from packages/rocketchat-autotranslate/client/views/autoTranslateFlexTab.html rename to app/autotranslate/client/views/autoTranslateFlexTab.html diff --git a/packages/rocketchat-autotranslate/client/views/autoTranslateFlexTab.js b/app/autotranslate/client/views/autoTranslateFlexTab.js similarity index 91% rename from packages/rocketchat-autotranslate/client/views/autoTranslateFlexTab.js rename to app/autotranslate/client/views/autoTranslateFlexTab.js index 4ffcc9cae97c..6f2e0f787efb 100644 --- a/packages/rocketchat-autotranslate/client/views/autoTranslateFlexTab.js +++ b/app/autotranslate/client/views/autoTranslateFlexTab.js @@ -2,9 +2,8 @@ import { Meteor } from 'meteor/meteor'; import { ReactiveVar } from 'meteor/reactive-var'; import { Random } from 'meteor/random'; import { Template } from 'meteor/templating'; -import { RocketChat } from 'meteor/rocketchat:lib'; -import { ChatSubscription } from 'meteor/rocketchat:ui'; -import { t } from 'meteor/rocketchat:utils'; +import { ChatSubscription, Subscriptions, Messages } from '../../../models'; +import { t, handleError } from '../../../utils'; import _ from 'underscore'; import toastr from 'toastr'; @@ -98,7 +97,7 @@ Template.autoTranslateFlexTab.onCreated(function() { this.saveSetting = () => { const field = this.editing.get(); - const subscription = RocketChat.models.Subscriptions.findOne({ rid: this.rid, 'u._id': Meteor.userId() }); + const subscription = Subscriptions.findOne({ rid: this.rid, 'u._id': Meteor.userId() }); const previousLanguage = subscription.autoTranslateLanguage; let value; switch (field) { @@ -124,7 +123,7 @@ Template.autoTranslateFlexTab.onCreated(function() { } if (field === 'autoTranslate' && value === '0') { - RocketChat.models.Messages.update(query, { $unset: { autoTranslateShowInverse: 1 } }, { multi: true }); + Messages.update(query, { $unset: { autoTranslateShowInverse: 1 } }, { multi: true }); } const display = field === 'autoTranslate' ? true : subscription && subscription.autoTranslate; @@ -134,7 +133,7 @@ Template.autoTranslateFlexTab.onCreated(function() { query.autoTranslateShowInverse = true; } - RocketChat.models.Messages.update(query, { $set: { random: Random.id() } }, { multi: true }); + Messages.update(query, { $set: { random: Random.id() } }, { multi: true }); this.editing.set(); }); diff --git a/app/autotranslate/server/autotranslate.js b/app/autotranslate/server/autotranslate.js new file mode 100644 index 000000000000..d362c5656d2e --- /dev/null +++ b/app/autotranslate/server/autotranslate.js @@ -0,0 +1,263 @@ +import { Meteor } from 'meteor/meteor'; +import { HTTP } from 'meteor/http'; +import { settings } from '../../settings'; +import { callbacks } from '../../callbacks'; +import { Subscriptions, Messages } from '../../models'; +import { Markdown } from '../../markdown/server'; +import _ from 'underscore'; +import s from 'underscore.string'; + +class AutoTranslate { + constructor() { + this.languages = []; + this.enabled = settings.get('AutoTranslate_Enabled'); + this.apiKey = settings.get('AutoTranslate_GoogleAPIKey'); + this.supportedLanguages = {}; + callbacks.add('afterSaveMessage', this.translateMessage.bind(this), callbacks.priority.MEDIUM, 'AutoTranslate'); + + settings.get('AutoTranslate_Enabled', (key, value) => { + this.enabled = value; + }); + settings.get('AutoTranslate_GoogleAPIKey', (key, value) => { + this.apiKey = value; + }); + } + + tokenize(message) { + if (!message.tokens || !Array.isArray(message.tokens)) { + message.tokens = []; + } + message = this.tokenizeEmojis(message); + message = this.tokenizeCode(message); + message = this.tokenizeURLs(message); + message = this.tokenizeMentions(message); + return message; + } + + tokenizeEmojis(message) { + let count = message.tokens.length; + message.msg = message.msg.replace(/:[+\w\d]+:/g, function(match) { + const token = `{${ count++ }}`; + message.tokens.push({ + token, + text: match, + }); + return token; + }); + + return message; + } + + tokenizeURLs(message) { + let count = message.tokens.length; + + const schemes = settings.get('Markdown_SupportSchemesForLink').split(',').join('|'); + + // Support ![alt text](http://image url) and [text](http://link) + message.msg = message.msg.replace(new RegExp(`(!?\\[)([^\\]]+)(\\]\\((?:${ schemes }):\\/\\/[^\\)]+\\))`, 'gm'), function(match, pre, text, post) { + const pretoken = `{${ count++ }}`; + message.tokens.push({ + token: pretoken, + text: pre, + }); + + const posttoken = `{${ count++ }}`; + message.tokens.push({ + token: posttoken, + text: post, + }); + + return pretoken + text + posttoken; + }); + + // Support + message.msg = message.msg.replace(new RegExp(`((?:<|<)(?:${ schemes }):\\/\\/[^\\|]+\\|)(.+?)(?=>|>)((?:>|>))`, 'gm'), function(match, pre, text, post) { + const pretoken = `{${ count++ }}`; + message.tokens.push({ + token: pretoken, + text: pre, + }); + + const posttoken = `{${ count++ }}`; + message.tokens.push({ + token: posttoken, + text: post, + }); + + return pretoken + text + posttoken; + }); + + return message; + } + + tokenizeCode(message) { + let count = message.tokens.length; + + message.html = message.msg; + message = Markdown.parseMessageNotEscaped(message); + message.msg = message.html; + + for (const tokenIndex in message.tokens) { + if (message.tokens.hasOwnProperty(tokenIndex)) { + const { token } = message.tokens[tokenIndex]; + if (token.indexOf('notranslate') === -1) { + const newToken = `{${ count++ }}`; + message.msg = message.msg.replace(token, newToken); + message.tokens[tokenIndex].token = newToken; + } + } + } + + return message; + } + + tokenizeMentions(message) { + let count = message.tokens.length; + + if (message.mentions && message.mentions.length > 0) { + message.mentions.forEach((mention) => { + message.msg = message.msg.replace(new RegExp(`(@${ mention.username })`, 'gm'), (match) => { + const token = `{${ count++ }}`; + message.tokens.push({ + token, + text: match, + }); + return token; + }); + }); + } + + if (message.channels && message.channels.length > 0) { + message.channels.forEach((channel) => { + message.msg = message.msg.replace(new RegExp(`(#${ channel.name })`, 'gm'), (match) => { + const token = `{${ count++ }}`; + message.tokens.push({ + token, + text: match, + }); + return token; + }); + }); + } + + return message; + } + + deTokenize(message) { + if (message.tokens && message.tokens.length > 0) { + for (const { token, text, noHtml } of message.tokens) { + message.msg = message.msg.replace(token, () => (noHtml ? noHtml : text)); + } + } + return message.msg; + } + + translateMessage(message, room, targetLanguage) { + if (this.enabled && this.apiKey) { + let targetLanguages; + if (targetLanguage) { + targetLanguages = [targetLanguage]; + } else { + targetLanguages = Subscriptions.getAutoTranslateLanguagesByRoomAndNotUser(room._id, message.u && message.u._id); + } + if (message.msg) { + Meteor.defer(() => { + const translations = {}; + let targetMessage = Object.assign({}, message); + + targetMessage.html = s.escapeHTML(String(targetMessage.msg)); + targetMessage = this.tokenize(targetMessage); + + let msgs = targetMessage.msg.split('\n'); + msgs = msgs.map((msg) => encodeURIComponent(msg)); + const query = `q=${ msgs.join('&q=') }`; + + const supportedLanguages = this.getSupportedLanguages('en'); + targetLanguages.forEach((language) => { + if (language.indexOf('-') !== -1 && !_.findWhere(supportedLanguages, { language })) { + language = language.substr(0, 2); + } + let result; + try { + result = HTTP.get('https://translation.googleapis.com/language/translate/v2', { params: { key: this.apiKey, target: language }, query }); + } catch (e) { + console.log('Error translating message', e); + return message; + } + if (result.statusCode === 200 && result.data && result.data.data && result.data.data.translations && Array.isArray(result.data.data.translations) && result.data.data.translations.length > 0) { + const txt = result.data.data.translations.map((translation) => translation.translatedText).join('\n'); + translations[language] = this.deTokenize(Object.assign({}, targetMessage, { msg: txt })); + } + }); + if (!_.isEmpty(translations)) { + Messages.addTranslations(message._id, translations); + } + }); + } + + if (message.attachments && message.attachments.length > 0) { + Meteor.defer(() => { + for (const index in message.attachments) { + if (message.attachments.hasOwnProperty(index)) { + const attachment = message.attachments[index]; + const translations = {}; + if (attachment.description || attachment.text) { + const query = `q=${ encodeURIComponent(attachment.description || attachment.text) }`; + const supportedLanguages = this.getSupportedLanguages('en'); + targetLanguages.forEach((language) => { + if (language.indexOf('-') !== -1 && !_.findWhere(supportedLanguages, { language })) { + language = language.substr(0, 2); + } + const result = HTTP.get('https://translation.googleapis.com/language/translate/v2', { params: { key: this.apiKey, target: language }, query }); + if (result.statusCode === 200 && result.data && result.data.data && result.data.data.translations && Array.isArray(result.data.data.translations) && result.data.data.translations.length > 0) { + const txt = result.data.data.translations.map((translation) => translation.translatedText).join('\n'); + translations[language] = txt; + } + }); + if (!_.isEmpty(translations)) { + Messages.addAttachmentTranslations(message._id, index, translations); + } + } + } + } + }); + } + } + return message; + } + + getSupportedLanguages(target) { + if (this.enabled && this.apiKey) { + if (this.supportedLanguages[target]) { + return this.supportedLanguages[target]; + } + + let result; + const params = { key: this.apiKey }; + if (target) { + params.target = target; + } + + try { + result = HTTP.get('https://translation.googleapis.com/language/translate/v2/languages', { params }); + } catch (e) { + if (e.response && e.response.statusCode === 400 && e.response.data && e.response.data.error && e.response.data.error.status === 'INVALID_ARGUMENT') { + params.target = 'en'; + target = 'en'; + if (!this.supportedLanguages[target]) { + result = HTTP.get('https://translation.googleapis.com/language/translate/v2/languages', { params }); + } + } + } finally { + if (this.supportedLanguages[target]) { + return this.supportedLanguages[target]; + } else { + this.supportedLanguages[target || 'en'] = result && result.data && result.data.data && result.data.data.languages; + return this.supportedLanguages[target || 'en']; + } + } + } + } +} + +export default new AutoTranslate(); diff --git a/app/autotranslate/server/index.js b/app/autotranslate/server/index.js new file mode 100644 index 000000000000..a3692f29c722 --- /dev/null +++ b/app/autotranslate/server/index.js @@ -0,0 +1,6 @@ +import './settings'; +import './permissions'; +import './autotranslate'; +import './methods/getSupportedLanguages'; +import './methods/saveSettings'; +import './methods/translateMessage'; diff --git a/app/autotranslate/server/methods/getSupportedLanguages.js b/app/autotranslate/server/methods/getSupportedLanguages.js new file mode 100644 index 000000000000..a53142464987 --- /dev/null +++ b/app/autotranslate/server/methods/getSupportedLanguages.js @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; +import { hasPermission } from '../../../authorization'; +import AutoTranslate from '../autotranslate'; + +Meteor.methods({ + 'autoTranslate.getSupportedLanguages'(targetLanguage) { + if (!hasPermission(Meteor.userId(), 'auto-translate')) { + throw new Meteor.Error('error-action-not-allowed', 'Auto-Translate is not allowed', { method: 'autoTranslate.saveSettings' }); + } + + return AutoTranslate.getSupportedLanguages(targetLanguage); + }, +}); + +DDPRateLimiter.addRule({ + type: 'method', + name: 'autoTranslate.getSupportedLanguages', + userId(/* userId*/) { + return true; + }, +}, 5, 60000); diff --git a/app/autotranslate/server/methods/saveSettings.js b/app/autotranslate/server/methods/saveSettings.js new file mode 100644 index 000000000000..16544597def3 --- /dev/null +++ b/app/autotranslate/server/methods/saveSettings.js @@ -0,0 +1,43 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { hasPermission } from '../../../authorization'; +import { Subscriptions } from '../../../models'; + +Meteor.methods({ + 'autoTranslate.saveSettings'(rid, field, value, options) { + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'saveAutoTranslateSettings' }); + } + + if (!hasPermission(Meteor.userId(), 'auto-translate')) { + throw new Meteor.Error('error-action-not-allowed', 'Auto-Translate is not allowed', { method: 'autoTranslate.saveSettings' }); + } + + check(rid, String); + check(field, String); + check(value, String); + + if (['autoTranslate', 'autoTranslateLanguage'].indexOf(field) === -1) { + throw new Meteor.Error('error-invalid-settings', 'Invalid settings field', { method: 'saveAutoTranslateSettings' }); + } + + const subscription = Subscriptions.findOneByRoomIdAndUserId(rid, Meteor.userId()); + if (!subscription) { + throw new Meteor.Error('error-invalid-subscription', 'Invalid subscription', { method: 'saveAutoTranslateSettings' }); + } + + switch (field) { + case 'autoTranslate': + Subscriptions.updateAutoTranslateById(subscription._id, value === '1'); + if (!subscription.autoTranslateLanguage && options.defaultLanguage) { + Subscriptions.updateAutoTranslateLanguageById(subscription._id, options.defaultLanguage); + } + break; + case 'autoTranslateLanguage': + Subscriptions.updateAutoTranslateLanguageById(subscription._id, value); + break; + } + + return true; + }, +}); diff --git a/app/autotranslate/server/methods/translateMessage.js b/app/autotranslate/server/methods/translateMessage.js new file mode 100644 index 000000000000..c57abe6d6311 --- /dev/null +++ b/app/autotranslate/server/methods/translateMessage.js @@ -0,0 +1,12 @@ +import { Meteor } from 'meteor/meteor'; +import { Rooms } from '../../../models'; +import AutoTranslate from '../autotranslate'; + +Meteor.methods({ + 'autoTranslate.translateMessage'(message, targetLanguage) { + const room = Rooms.findOneById(message && message.rid); + if (message && room && AutoTranslate) { + return AutoTranslate.translateMessage(message, room, targetLanguage); + } + }, +}); diff --git a/app/autotranslate/server/permissions.js b/app/autotranslate/server/permissions.js new file mode 100644 index 000000000000..aadd3fbb44a7 --- /dev/null +++ b/app/autotranslate/server/permissions.js @@ -0,0 +1,10 @@ +import { Meteor } from 'meteor/meteor'; +import { Permissions } from '../../models'; + +Meteor.startup(() => { + if (Permissions) { + if (!Permissions.findOne({ _id: 'auto-translate' })) { + Permissions.insert({ _id: 'auto-translate', roles: ['admin'] }); + } + } +}); diff --git a/app/autotranslate/server/settings.js b/app/autotranslate/server/settings.js new file mode 100644 index 000000000000..1045144ccefc --- /dev/null +++ b/app/autotranslate/server/settings.js @@ -0,0 +1,7 @@ +import { Meteor } from 'meteor/meteor'; +import { settings } from '../../settings'; + +Meteor.startup(function() { + settings.add('AutoTranslate_Enabled', false, { type: 'boolean', group: 'Message', section: 'AutoTranslate', public: true }); + settings.add('AutoTranslate_GoogleAPIKey', '', { type: 'string', group: 'Message', section: 'AutoTranslate', enableQuery: { _id: 'AutoTranslate_Enabled', value: true } }); +}); diff --git a/app/bigbluebutton/index.js b/app/bigbluebutton/index.js new file mode 100644 index 000000000000..ba58589ba3d7 --- /dev/null +++ b/app/bigbluebutton/index.js @@ -0,0 +1 @@ +export { default } from './server/bigbluebutton-api'; diff --git a/packages/rocketchat-bigbluebutton/server/bigbluebutton-api.js b/app/bigbluebutton/server/bigbluebutton-api.js similarity index 100% rename from packages/rocketchat-bigbluebutton/server/bigbluebutton-api.js rename to app/bigbluebutton/server/bigbluebutton-api.js diff --git a/packages/rocketchat-blockstack/client/main.js b/app/blockstack/client/index.js similarity index 100% rename from packages/rocketchat-blockstack/client/main.js rename to app/blockstack/client/index.js diff --git a/packages/rocketchat-blockstack/client/routes.js b/app/blockstack/client/routes.js similarity index 100% rename from packages/rocketchat-blockstack/client/routes.js rename to app/blockstack/client/routes.js diff --git a/packages/rocketchat-blockstack/server/main.js b/app/blockstack/server/index.js similarity index 100% rename from packages/rocketchat-blockstack/server/main.js rename to app/blockstack/server/index.js diff --git a/app/blockstack/server/logger.js b/app/blockstack/server/logger.js new file mode 100644 index 000000000000..e88f4df9bf1c --- /dev/null +++ b/app/blockstack/server/logger.js @@ -0,0 +1,3 @@ +import { Logger } from '../../logger'; + +export const logger = new Logger('Blockstack'); diff --git a/app/blockstack/server/loginHandler.js b/app/blockstack/server/loginHandler.js new file mode 100644 index 000000000000..83b5a71a60a9 --- /dev/null +++ b/app/blockstack/server/loginHandler.js @@ -0,0 +1,53 @@ +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; +import { settings } from '../../settings'; +import { Users } from '../../models'; +import { setUserAvatar } from '../../lib'; +import { updateOrCreateUser } from './userHandler'; +import { handleAccessToken } from './tokenHandler'; +import { logger } from './logger'; + +// Blockstack login handler, triggered by a blockstack authResponse in route +Accounts.registerLoginHandler('blockstack', (loginRequest) => { + if (!loginRequest.blockstack || !loginRequest.authResponse) { + return; + } + + if (!settings.get('Blockstack_Enable')) { + return; + } + + logger.debug('Processing login request', loginRequest); + + const auth = handleAccessToken(loginRequest); + + // TODO: Fix #9484 and re-instate usage of accounts helper + // const result = Accounts.updateOrCreateUserFromExternalService('blockstack', auth.serviceData, auth.options) + const result = updateOrCreateUser(auth.serviceData, auth.options); + logger.debug('User create/update result', result); + + // Ensure processing succeeded + if (result === undefined || result.userId === undefined) { + return { + type: 'blockstack', + error: new Meteor.Error(Accounts.LoginCancelledError.numericError, 'User creation failed from Blockstack response token'), + }; + } + + if (result.isNew) { + try { + const user = Users.findOneById(result.userId, { fields: { 'services.blockstack.image': 1, username: 1 } }); + if (user && user.services && user.services.blockstack && user.services.blockstack.image) { + Meteor.runAsUser(user._id, () => { + setUserAvatar(user, user.services.blockstack.image, undefined, 'url'); + }); + } + } catch (e) { + console.error(e); + } + } + + delete result.isNew; + + return result; +}); diff --git a/app/blockstack/server/routes.js b/app/blockstack/server/routes.js new file mode 100644 index 000000000000..1f19ecb8c696 --- /dev/null +++ b/app/blockstack/server/routes.js @@ -0,0 +1,27 @@ +import { Meteor } from 'meteor/meteor'; +import { WebApp } from 'meteor/webapp'; +import { settings } from '../../settings'; +import { RocketChatAssets } from '../../assets'; + +WebApp.connectHandlers.use('/_blockstack/manifest', Meteor.bindEnvironment(function(req, res) { + const name = settings.get('Site_Name'); + const startUrl = Meteor.absoluteUrl(); + const description = settings.get('Blockstack_Auth_Description'); + const iconUrl = RocketChatAssets.getURL('Assets_favicon_192'); + + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }); + + res.end(`{ + "name": "${ name }", + "start_url": "${ startUrl }", + "description": "${ description }", + "icons": [{ + "src": "${ iconUrl }", + "sizes": "192x192", + "type": "image/png" + }] + }`); +})); diff --git a/app/blockstack/server/settings.js b/app/blockstack/server/settings.js new file mode 100644 index 000000000000..be4f0095aafb --- /dev/null +++ b/app/blockstack/server/settings.js @@ -0,0 +1,69 @@ +import _ from 'underscore'; +import { Meteor } from 'meteor/meteor'; +import { settings } from '../../settings'; +import { ServiceConfiguration } from 'meteor/service-configuration'; +import { logger } from './logger'; + +const defaults = { + enable: false, + loginStyle: 'redirect', + generateUsername: false, + manifestURI: Meteor.absoluteUrl('_blockstack/manifest'), + redirectURI: Meteor.absoluteUrl('_blockstack/validate'), + authDescription: 'Rocket.Chat login', + buttonLabelText: 'Blockstack', + buttonColor: '#271132', + buttonLabelColor: '#ffffff', +}; + +Meteor.startup(() => { + settings.addGroup('Blockstack', function() { + this.add('Blockstack_Enable', defaults.enable, { + type: 'boolean', + i18nLabel: 'Enable', + }); + this.add('Blockstack_Auth_Description', defaults.authDescription, { + type: 'string', + }); + this.add('Blockstack_ButtonLabelText', defaults.buttonLabelText, { + type: 'string', + }); + this.add('Blockstack_Generate_Username', defaults.generateUsername, { + type: 'boolean', + }); + }); +}); + +// Helper to return all Blockstack settings +const getSettings = () => Object.assign({}, defaults, { + enable: settings.get('Blockstack_Enable'), + authDescription: settings.get('Blockstack_Auth_Description'), + buttonLabelText: settings.get('Blockstack_ButtonLabelText'), + generateUsername: settings.get('Blockstack_Generate_Username'), +}); + +const configureService = _.debounce(Meteor.bindEnvironment(() => { + const serviceConfig = getSettings(); + + if (!serviceConfig.enable) { + logger.debug('Blockstack not enabled', serviceConfig); + return ServiceConfiguration.configurations.remove({ + service: 'blockstack', + }); + } + + ServiceConfiguration.configurations.upsert({ + service: 'blockstack', + }, { + $set: serviceConfig, + }); + + logger.debug('Init Blockstack auth', serviceConfig); +}), 1000); + +// Add settings to auth provider configs on startup +Meteor.startup(() => { + settings.get(/^Blockstack_.+/, () => { + configureService(); + }); +}); diff --git a/packages/rocketchat-blockstack/server/tokenHandler.js b/app/blockstack/server/tokenHandler.js similarity index 99% rename from packages/rocketchat-blockstack/server/tokenHandler.js rename to app/blockstack/server/tokenHandler.js index 30686814b113..bfe1afe7ccc1 100644 --- a/packages/rocketchat-blockstack/server/tokenHandler.js +++ b/app/blockstack/server/tokenHandler.js @@ -1,5 +1,4 @@ import { decodeToken } from 'blockstack'; - import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; import { Match, check } from 'meteor/check'; diff --git a/packages/rocketchat-blockstack/server/userHandler.js b/app/blockstack/server/userHandler.js similarity index 95% rename from packages/rocketchat-blockstack/server/userHandler.js rename to app/blockstack/server/userHandler.js index 8e23773b2735..4b3bdd28c589 100644 --- a/packages/rocketchat-blockstack/server/userHandler.js +++ b/app/blockstack/server/userHandler.js @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; import { ServiceConfiguration } from 'meteor/service-configuration'; -import { RocketChat } from 'meteor/rocketchat:lib'; +import { generateUsernameSuggestion } from '../../lib'; import { logger } from './logger'; // Updates or creates a user after we authenticate with Blockstack @@ -53,7 +53,7 @@ export const updateOrCreateUser = (serviceData, options) => { if (profile.username && profile.username !== '') { newUser.username = profile.username; } else if (serviceConfig.generateUsername === true) { - newUser.username = RocketChat.generateUsernameSuggestion(newUser); + newUser.username = generateUsernameSuggestion(newUser); } // If no username at this point it will suggest one from the name diff --git a/packages/rocketchat-bot-helpers/README.md b/app/bot-helpers/README.md similarity index 100% rename from packages/rocketchat-bot-helpers/README.md rename to app/bot-helpers/README.md diff --git a/app/bot-helpers/index.js b/app/bot-helpers/index.js new file mode 100644 index 000000000000..f5778a23b606 --- /dev/null +++ b/app/bot-helpers/index.js @@ -0,0 +1 @@ +import './server/index'; diff --git a/app/bot-helpers/server/index.js b/app/bot-helpers/server/index.js new file mode 100644 index 000000000000..ec853a1d35ba --- /dev/null +++ b/app/bot-helpers/server/index.js @@ -0,0 +1,168 @@ +import './settings'; +import { Meteor } from 'meteor/meteor'; +import { Users, Rooms } from '../../models'; +import { settings } from '../../settings'; +import { hasRole } from '../../authorization'; +import _ from 'underscore'; + +/** + * BotHelpers helps bots + * "private" properties use meteor collection cursors, so they stay reactive + * "public" properties use getters to fetch and filter collections as array + */ +class BotHelpers { + constructor() { + this.queries = { + online: { status: { $ne: 'offline' } }, + users: { roles: { $not: { $all: ['bot'] } } }, + }; + } + + // setup collection cursors with array of fields from setting + setupCursors(fieldsSetting) { + this.userFields = {}; + if (typeof fieldsSetting === 'string') { + fieldsSetting = fieldsSetting.split(','); + } + fieldsSetting.forEach((n) => { + this.userFields[n.trim()] = 1; + }); + this._allUsers = Users.find(this.queries.users, { fields: this.userFields }); + this._onlineUsers = Users.find({ $and: [this.queries.users, this.queries.online] }, { fields: this.userFields }); + } + + // request methods or props as arguments to Meteor.call + request(prop, ...params) { + if (typeof this[prop] === 'undefined') { + return null; + } else if (typeof this[prop] === 'function') { + return this[prop](...params); + } else { + return this[prop]; + } + } + + addUserToRole(userName, roleName) { + Meteor.call('authorization:addUserToRole', roleName, userName); + } + + removeUserFromRole(userName, roleName) { + Meteor.call('authorization:removeUserFromRole', roleName, userName); + } + + addUserToRoom(userName, room) { + const foundRoom = Rooms.findOneByIdOrName(room); + + if (!_.isObject(foundRoom)) { + throw new Meteor.Error('invalid-channel'); + } + + const data = {}; + data.rid = foundRoom._id; + data.username = userName; + Meteor.call('addUserToRoom', data); + } + + removeUserFromRoom(userName, room) { + const foundRoom = Rooms.findOneByIdOrName(room); + + if (!_.isObject(foundRoom)) { + throw new Meteor.Error('invalid-channel'); + } + const data = {}; + data.rid = foundRoom._id; + data.username = userName; + Meteor.call('removeUserFromRoom', data); + } + + // generic error whenever property access insufficient to fill request + requestError() { + throw new Meteor.Error('error-not-allowed', 'Bot request not allowed', { method: 'botRequest', action: 'bot_request' }); + } + + // "public" properties accessed by getters + // allUsers / onlineUsers return whichever properties are enabled by settings + get allUsers() { + if (!Object.keys(this.userFields).length) { + this.requestError(); + return false; + } else { + return this._allUsers.fetch(); + } + } + get onlineUsers() { + if (!Object.keys(this.userFields).length) { + this.requestError(); + return false; + } else { + return this._onlineUsers.fetch(); + } + } + get allUsernames() { + if (!this.userFields.hasOwnProperty('username')) { + this.requestError(); + return false; + } else { + return this._allUsers.fetch().map((user) => user.username); + } + } + get onlineUsernames() { + if (!this.userFields.hasOwnProperty('username')) { + this.requestError(); + return false; + } else { + return this._onlineUsers.fetch().map((user) => user.username); + } + } + get allNames() { + if (!this.userFields.hasOwnProperty('name')) { + this.requestError(); + return false; + } else { + return this._allUsers.fetch().map((user) => user.name); + } + } + get onlineNames() { + if (!this.userFields.hasOwnProperty('name')) { + this.requestError(); + return false; + } else { + return this._onlineUsers.fetch().map((user) => user.name); + } + } + get allIDs() { + if (!this.userFields.hasOwnProperty('_id') || !this.userFields.hasOwnProperty('username')) { + this.requestError(); + return false; + } else { + return this._allUsers.fetch().map((user) => ({ id: user._id, name: user.username })); + } + } + get onlineIDs() { + if (!this.userFields.hasOwnProperty('_id') || !this.userFields.hasOwnProperty('username')) { + this.requestError(); + return false; + } else { + return this._onlineUsers.fetch().map((user) => ({ id: user._id, name: user.username })); + } + } +} + +// add class to meteor methods +const botHelpers = new BotHelpers(); + +// init cursors with fields setting and update on setting change +settings.get('BotHelpers_userFields', function(settingKey, settingValue) { + botHelpers.setupCursors(settingValue); +}); + +Meteor.methods({ + botRequest: (...args) => { + const userID = Meteor.userId(); + if (userID && hasRole(userID, 'bot')) { + return botHelpers.request(...args); + } else { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'botRequest' }); + } + }, +}); diff --git a/app/bot-helpers/server/settings.js b/app/bot-helpers/server/settings.js new file mode 100644 index 000000000000..fc5206c17d06 --- /dev/null +++ b/app/bot-helpers/server/settings.js @@ -0,0 +1,13 @@ +import { Meteor } from 'meteor/meteor'; +import { settings } from '../../settings'; + +Meteor.startup(function() { + settings.addGroup('Bots', function() { + this.add('BotHelpers_userFields', '_id, name, username, emails, language, utcOffset', { + type: 'string', + section: 'Helpers', + i18nLabel: 'BotHelpers_userFields', + i18nDescription: 'BotHelpers_userFields_Description', + }); + }); +}); diff --git a/packages/rocketchat-callbacks/client/index.js b/app/callbacks/client/index.js similarity index 100% rename from packages/rocketchat-callbacks/client/index.js rename to app/callbacks/client/index.js diff --git a/app/callbacks/index.js b/app/callbacks/index.js new file mode 100644 index 000000000000..a67eca871efb --- /dev/null +++ b/app/callbacks/index.js @@ -0,0 +1,8 @@ +import { Meteor } from 'meteor/meteor'; + +if (Meteor.isClient) { + module.exports = require('./client/index.js'); +} +if (Meteor.isServer) { + module.exports = require('./server/index.js'); +} diff --git a/packages/rocketchat-callbacks/lib/callbacks.js b/app/callbacks/lib/callbacks.js similarity index 100% rename from packages/rocketchat-callbacks/lib/callbacks.js rename to app/callbacks/lib/callbacks.js diff --git a/packages/rocketchat-callbacks/server/index.js b/app/callbacks/server/index.js similarity index 100% rename from packages/rocketchat-callbacks/server/index.js rename to app/callbacks/server/index.js diff --git a/packages/rocketchat-cas/client/cas_client.js b/app/cas/client/cas_client.js similarity index 90% rename from packages/rocketchat-cas/client/cas_client.js rename to app/cas/client/cas_client.js index 4722bf13ccfd..2f451f1929c1 100644 --- a/packages/rocketchat-cas/client/cas_client.js +++ b/app/cas/client/cas_client.js @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; import { Random } from 'meteor/random'; -import { RocketChat } from 'meteor/rocketchat:lib'; +import { settings } from '../../settings'; const openCenteredPopup = function(url, width, height) { @@ -29,9 +29,9 @@ Meteor.loginWithCas = function(options, callback) { options = options || {}; const credentialToken = Random.id(); - const login_url = RocketChat.settings.get('CAS_login_url'); - const popup_width = RocketChat.settings.get('CAS_popup_width'); - const popup_height = RocketChat.settings.get('CAS_popup_height'); + const login_url = settings.get('CAS_login_url'); + const popup_width = settings.get('CAS_popup_width'); + const popup_height = settings.get('CAS_popup_height'); if (!login_url) { return; diff --git a/packages/rocketchat-cas/client/index.js b/app/cas/client/index.js similarity index 100% rename from packages/rocketchat-cas/client/index.js rename to app/cas/client/index.js diff --git a/app/cas/server/cas_rocketchat.js b/app/cas/server/cas_rocketchat.js new file mode 100644 index 000000000000..45f5fd61b6d2 --- /dev/null +++ b/app/cas/server/cas_rocketchat.js @@ -0,0 +1,67 @@ +import { Meteor } from 'meteor/meteor'; +import { Logger } from '../../logger'; +import { settings } from '../../settings'; +import { ServiceConfiguration } from 'meteor/service-configuration'; +export const logger = new Logger('CAS', {}); + +Meteor.startup(function() { + settings.addGroup('CAS', function() { + this.add('CAS_enabled', false, { type: 'boolean', group: 'CAS', public: true }); + this.add('CAS_base_url', '', { type: 'string', group: 'CAS', public: true }); + this.add('CAS_login_url', '', { type: 'string', group: 'CAS', public: true }); + this.add('CAS_version', '1.0', { type: 'select', values: [{ key: '1.0', i18nLabel: '1.0' }, { key: '2.0', i18nLabel: '2.0' }], group: 'CAS' }); + + this.section('Attribute_handling', function() { + // Enable/disable sync + this.add('CAS_Sync_User_Data_Enabled', true, { type: 'boolean' }); + // Attribute mapping table + this.add('CAS_Sync_User_Data_FieldMap', '{}', { type: 'string' }); + }); + + this.section('CAS_Login_Layout', function() { + this.add('CAS_popup_width', '810', { type: 'string', group: 'CAS', public: true }); + this.add('CAS_popup_height', '610', { type: 'string', group: 'CAS', public: true }); + this.add('CAS_button_label_text', 'CAS', { type: 'string', group: 'CAS' }); + this.add('CAS_button_label_color', '#FFFFFF', { type: 'color', group: 'CAS' }); + this.add('CAS_button_color', '#1d74f5', { type: 'color', group: 'CAS' }); + this.add('CAS_autoclose', true, { type: 'boolean', group: 'CAS' }); + }); + }); +}); + +let timer; + +function updateServices(/* record*/) { + if (typeof timer !== 'undefined') { + Meteor.clearTimeout(timer); + } + + timer = Meteor.setTimeout(function() { + const data = { + // These will pe passed to 'node-cas' as options + enabled: settings.get('CAS_enabled'), + base_url: settings.get('CAS_base_url'), + login_url: settings.get('CAS_login_url'), + // Rocketchat Visuals + buttonLabelText: settings.get('CAS_button_label_text'), + buttonLabelColor: settings.get('CAS_button_label_color'), + buttonColor: settings.get('CAS_button_color'), + width: settings.get('CAS_popup_width'), + height: settings.get('CAS_popup_height'), + autoclose: settings.get('CAS_autoclose'), + }; + + // Either register or deregister the CAS login service based upon its configuration + if (data.enabled) { + logger.info('Enabling CAS login service'); + ServiceConfiguration.configurations.upsert({ service: 'cas' }, { $set: data }); + } else { + logger.info('Disabling CAS login service'); + ServiceConfiguration.configurations.remove({ service: 'cas' }); + } + }, 2000); +} + +settings.get(/^CAS_.+/, (key, value) => { + updateServices(value); +}); diff --git a/packages/rocketchat-cas/server/cas_server.js b/app/cas/server/cas_server.js similarity index 87% rename from packages/rocketchat-cas/server/cas_server.js rename to app/cas/server/cas_server.js index e52283ca7579..c3d60a2a5af3 100644 --- a/packages/rocketchat-cas/server/cas_server.js +++ b/app/cas/server/cas_server.js @@ -2,11 +2,12 @@ import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; import { Random } from 'meteor/random'; import { WebApp } from 'meteor/webapp'; -import { RocketChat } from 'meteor/rocketchat:lib'; +import { settings } from '../../settings'; import { RoutePolicy } from 'meteor/routepolicy'; +import { Rooms, Subscriptions, CredentialTokens } from '../../models'; +import { _setRealName } from '../../lib'; import { logger } from './cas_rocketchat'; import _ from 'underscore'; - import fiber from 'fibers'; import url from 'url'; import CAS from 'cas'; @@ -22,7 +23,7 @@ const closePopup = function(res) { const casTicket = function(req, token, callback) { // get configuration - if (!RocketChat.settings.get('CAS_enabled')) { + if (!settings.get('CAS_enabled')) { logger.error('Got ticket validation request, but CAS is not enabled'); callback(); } @@ -30,8 +31,8 @@ const casTicket = function(req, token, callback) { // get ticket and validate. const parsedUrl = url.parse(req.url, true); const ticketId = parsedUrl.query.ticket; - const baseUrl = RocketChat.settings.get('CAS_base_url'); - const cas_version = parseFloat(RocketChat.settings.get('CAS_version')); + const baseUrl = settings.get('CAS_base_url'); + const cas_version = parseFloat(settings.get('CAS_version')); const appUrl = Meteor.absoluteUrl().replace(/\/$/, '') + __meteor_runtime_config__.ROOT_URL_PATH_PREFIX; logger.debug(`Using CAS_base_url: ${ baseUrl }`); @@ -52,7 +53,7 @@ const casTicket = function(req, token, callback) { if (details && details.attributes) { _.extend(user_info, { attributes: details.attributes }); } - RocketChat.models.CredentialTokens.create(token, user_info); + CredentialTokens.create(token, user_info); } else { logger.error(`Unable to validate ticket: ${ ticketId }`); } @@ -116,16 +117,16 @@ Accounts.registerLoginHandler(function(options) { return undefined; } - const credentials = RocketChat.models.CredentialTokens.findOneById(options.cas.credentialToken); + const credentials = CredentialTokens.findOneById(options.cas.credentialToken); if (credentials === undefined) { throw new Meteor.Error(Accounts.LoginCancelledError.numericError, 'no matching login attempt found'); } const result = credentials.userInfo; - const syncUserDataFieldMap = RocketChat.settings.get('CAS_Sync_User_Data_FieldMap').trim(); - const cas_version = parseFloat(RocketChat.settings.get('CAS_version')); - const sync_enabled = RocketChat.settings.get('CAS_Sync_User_Data_Enabled'); + const syncUserDataFieldMap = settings.get('CAS_Sync_User_Data_FieldMap').trim(); + const cas_version = parseFloat(settings.get('CAS_version')); + const sync_enabled = settings.get('CAS_Sync_User_Data_Enabled'); // We have these const ext_attrs = { @@ -196,7 +197,7 @@ Accounts.registerLoginHandler(function(options) { logger.debug('Syncing user attributes'); // Update name if (int_attrs.name) { - RocketChat._setRealName(user._id, int_attrs.name); + _setRealName(user._id, int_attrs.name); } // Update email @@ -248,13 +249,13 @@ Accounts.registerLoginHandler(function(options) { if (int_attrs.rooms) { _.each(int_attrs.rooms.split(','), function(room_name) { if (room_name) { - let room = RocketChat.models.Rooms.findOneByNameAndType(room_name, 'c'); + let room = Rooms.findOneByNameAndType(room_name, 'c'); if (!room) { - room = RocketChat.models.Rooms.createWithIdTypeAndName(Random.id(), 'c', room_name); + room = Rooms.createWithIdTypeAndName(Random.id(), 'c', room_name); } - if (!RocketChat.models.Subscriptions.findOneByRoomIdAndUserId(room._id, userId)) { - RocketChat.models.Subscriptions.createWithRoomAndUser(room, user, { + if (!Subscriptions.findOneByRoomIdAndUserId(room._id, userId)) { + Subscriptions.createWithRoomAndUser(room, user, { ts: new Date(), open: true, alert: true, diff --git a/app/cas/server/index.js b/app/cas/server/index.js new file mode 100644 index 000000000000..0ad22d77b198 --- /dev/null +++ b/app/cas/server/index.js @@ -0,0 +1,2 @@ +import './cas_rocketchat'; +import './cas_server'; diff --git a/packages/rocketchat-channel-settings-mail-messages/client/index.js b/app/channel-settings-mail-messages/client/index.js similarity index 100% rename from packages/rocketchat-channel-settings-mail-messages/client/index.js rename to app/channel-settings-mail-messages/client/index.js diff --git a/app/channel-settings-mail-messages/client/lib/startup.js b/app/channel-settings-mail-messages/client/lib/startup.js new file mode 100644 index 000000000000..0ed7a682f798 --- /dev/null +++ b/app/channel-settings-mail-messages/client/lib/startup.js @@ -0,0 +1,19 @@ +// import resetSelection from '../resetSelection'; +import { Meteor } from 'meteor/meteor'; +import { TabBar } from '../../../ui-utils'; +import { hasAllPermission } from '../../../authorization'; + +Meteor.startup(() => { + TabBar.addButton({ + groups: ['channel', 'group', 'direct'], + id: 'mail-messages', + anonymous: true, + i18nTitle: 'Mail_Messages', + icon: 'mail', + template: 'mailMessagesInstructions', + order: 10, + condition: () => hasAllPermission('mail-messages'), + }); + + // RocketChat.callbacks.add('roomExit', () => resetSelection(false), RocketChat.callbacks.priority.MEDIUM, 'room-exit-mail-messages'); +}); diff --git a/packages/rocketchat-channel-settings-mail-messages/client/resetSelection.js b/app/channel-settings-mail-messages/client/resetSelection.js similarity index 100% rename from packages/rocketchat-channel-settings-mail-messages/client/resetSelection.js rename to app/channel-settings-mail-messages/client/resetSelection.js diff --git a/packages/rocketchat-channel-settings-mail-messages/client/views/mailMessagesInstructions.html b/app/channel-settings-mail-messages/client/views/mailMessagesInstructions.html similarity index 90% rename from packages/rocketchat-channel-settings-mail-messages/client/views/mailMessagesInstructions.html rename to app/channel-settings-mail-messages/client/views/mailMessagesInstructions.html index 9cdfbe698a9b..6b79bef0a061 100644 --- a/packages/rocketchat-channel-settings-mail-messages/client/views/mailMessagesInstructions.html +++ b/app/channel-settings-mail-messages/client/views/mailMessagesInstructions.html @@ -3,7 +3,7 @@ {{#if selectedMessages}}
- {{> icon block="mail-messages__instructions-icon" icon="modal-success"}} + {{> icon block="mail-messages__instructions-icon rc-icon--default-size" icon="checkmark-circled"}}
{{selectedMessages.length}} Messages selected Click here to clear the selection @@ -13,7 +13,7 @@ {{else}}
- {{> icon block="mail-messages__instructions-icon" icon="hand-pointer"}} + {{> icon block="mail-messages__instructions-icon rc-icon--default-size" icon="hand-pointer"}}
{{_ "Click_the_messages_you_would_like_to_send_by_email"}}
@@ -25,7 +25,7 @@
{{_ "To_users"}}
- {{> icon block="rc-input__icon-svg" icon="at"}} + {{> icon block="rc-input__icon-svg rc-icon--default-size" icon="at"}}
{{#each user in selectedUsers}} @@ -36,9 +36,7 @@
{{#with config}} {{#if autocomplete 'isShowing'}} - {{#if autocomplete 'isLoaded'}} - {{> popupList data=config items=items}} - {{/if}} + {{> popupList data=config items=items ready=(autocomplete 'isLoaded')}} {{/if}} {{/with}} @@ -48,7 +46,7 @@
{{_ "To_additional_emails"}}
- {{> icon block="rc-input__icon-svg" icon="mail"}} + {{> icon block="rc-input__icon-svg rc-icon--default-size" icon="mail"}}
{{#each selectedEmails}} @@ -64,7 +62,7 @@
{{_ "Subject"}}
- {{> icon block="rc-input__icon-svg" icon="edit"}} + {{> icon block="rc-input__icon-svg rc-icon--default-size" icon="edit"}}
diff --git a/packages/rocketchat-channel-settings-mail-messages/client/views/mailMessagesInstructions.js b/app/channel-settings-mail-messages/client/views/mailMessagesInstructions.js similarity index 96% rename from packages/rocketchat-channel-settings-mail-messages/client/views/mailMessagesInstructions.js rename to app/channel-settings-mail-messages/client/views/mailMessagesInstructions.js index a21bc68db689..861e371c1443 100644 --- a/packages/rocketchat-channel-settings-mail-messages/client/views/mailMessagesInstructions.js +++ b/app/channel-settings-mail-messages/client/views/mailMessagesInstructions.js @@ -4,15 +4,15 @@ import { Blaze } from 'meteor/blaze'; import { Session } from 'meteor/session'; import { Template } from 'meteor/templating'; import { AutoComplete } from 'meteor/mizzao:autocomplete'; -import { RocketChat, handleError } from 'meteor/rocketchat:lib'; -import { ChatRoom } from 'meteor/rocketchat:ui'; -import { t, isEmail } from 'meteor/rocketchat:utils'; +import { ChatRoom } from '../../../models'; +import { t, isEmail, handleError, roomTypes } from '../../../utils'; +import { settings } from '../../../settings'; import { Deps } from 'meteor/deps'; import toastr from 'toastr'; import resetSelection from '../resetSelection'; const filterNames = (old) => { - const reg = new RegExp(`^${ RocketChat.settings.get('UTF8_Names_Validation') }$`); + const reg = new RegExp(`^${ settings.get('UTF8_Names_Validation') }$`); return [...old.replace(' ', '').toLocaleLowerCase()].filter((f) => reg.test(f)).join(''); }; @@ -26,7 +26,7 @@ Template.mailMessagesInstructions.helpers({ }, roomName() { const room = ChatRoom.findOne(Session.get('openedRoom')); - return room && RocketChat.roomTypes.getRoomName(room.t, room); + return room && roomTypes.getRoomName(room.t, room); }, erroredEmails() { const instance = Template.instance(); @@ -246,7 +246,7 @@ Template.mailMessagesInstructions.onCreated(function() { item: '.rc-popup-list__item', container: '.rc-popup-list__list', }, - + position: 'fixed', limit: 10, inputDelay: 300, rules: [ diff --git a/packages/rocketchat-channel-settings-mail-messages/server/index.js b/app/channel-settings-mail-messages/server/index.js similarity index 100% rename from packages/rocketchat-channel-settings-mail-messages/server/index.js rename to app/channel-settings-mail-messages/server/index.js diff --git a/app/channel-settings-mail-messages/server/lib/startup.js b/app/channel-settings-mail-messages/server/lib/startup.js new file mode 100644 index 000000000000..c96f5ac6e6cf --- /dev/null +++ b/app/channel-settings-mail-messages/server/lib/startup.js @@ -0,0 +1,12 @@ +import { Meteor } from 'meteor/meteor'; +import { Permissions } from '../../../models'; + +Meteor.startup(function() { + const permission = { + _id: 'mail-messages', + roles: ['admin'], + }; + return Permissions.upsert(permission._id, { + $setOnInsert: permission, + }); +}); diff --git a/packages/rocketchat-channel-settings-mail-messages/server/methods/mailMessages.js b/app/channel-settings-mail-messages/server/methods/mailMessages.js similarity index 80% rename from packages/rocketchat-channel-settings-mail-messages/server/methods/mailMessages.js rename to app/channel-settings-mail-messages/server/methods/mailMessages.js index 1ad34a81a0f8..d5a832730a18 100644 --- a/packages/rocketchat-channel-settings-mail-messages/server/methods/mailMessages.js +++ b/app/channel-settings-mail-messages/server/methods/mailMessages.js @@ -1,9 +1,12 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; -import { RocketChat } from 'meteor/rocketchat:lib'; +import { hasPermission } from '../../../authorization'; +import { Users, Messages } from '../../../models'; +import { settings } from '../../../settings'; +import { Message } from '../../../ui-utils'; import _ from 'underscore'; import moment from 'moment'; -import * as Mailer from 'meteor/rocketchat:mailer'; +import * as Mailer from '../../../mailer'; Meteor.methods({ 'mailMessages'(data) { @@ -27,7 +30,7 @@ Meteor.methods({ method: 'mailMessages', }); } - if (!RocketChat.authz.hasPermission(userId, 'mail-messages')) { + if (!hasPermission(userId, 'mail-messages')) { throw new Meteor.Error('error-action-not-allowed', 'Mailing is not allowed', { method: 'mailMessages', action: 'Mailing', @@ -38,7 +41,7 @@ Meteor.methods({ const missing = []; if (data.to_users.length > 0) { _.each(data.to_users, (username) => { - const user = RocketChat.models.Users.findOneByUsername(username); + const user = Users.findOneByUsername(username); if (user && user.emails && user.emails[0] && user.emails[0].address) { emails.push(user.emails[0].address); } else { @@ -65,16 +68,16 @@ Meteor.methods({ } } - const html = RocketChat.models.Messages.findByRoomIdAndMessageIds(data.rid, data.messages, { + const html = Messages.findByRoomIdAndMessageIds(data.rid, data.messages, { sort: { ts: 1 }, }).map(function(message) { const dateTime = moment(message.ts).locale(data.language).format('L LT'); - return `

${ message.u.username } ${ dateTime }
${ RocketChat.Message.parse(message, data.language) }

`; + return `

${ message.u.username } ${ dateTime }
${ Message.parse(message, data.language) }

`; }).join(''); Mailer.send({ to: emails, - from: RocketChat.settings.get('From_Email'), + from: settings.get('From_Email'), replyTo: email, subject: data.subject, html, diff --git a/app/channel-settings/client/index.js b/app/channel-settings/client/index.js new file mode 100644 index 000000000000..5f84cf2870b3 --- /dev/null +++ b/app/channel-settings/client/index.js @@ -0,0 +1,6 @@ +import './startup/messageTypes'; +import './startup/tabBar'; +import './startup/trackSettingsChange'; +export { ChannelSettings } from './lib/ChannelSettings'; +import './views/channelSettings.html'; +import './views/channelSettings'; diff --git a/packages/rocketchat-channel-settings/client/lib/ChannelSettings.js b/app/channel-settings/client/lib/ChannelSettings.js similarity index 92% rename from packages/rocketchat-channel-settings/client/lib/ChannelSettings.js rename to app/channel-settings/client/lib/ChannelSettings.js index f643d2c6674b..b705c206b20d 100644 --- a/packages/rocketchat-channel-settings/client/lib/ChannelSettings.js +++ b/app/channel-settings/client/lib/ChannelSettings.js @@ -1,9 +1,8 @@ import { ReactiveVar } from 'meteor/reactive-var'; import { Tracker } from 'meteor/tracker'; -import { RocketChat } from 'meteor/rocketchat:lib'; import _ from 'underscore'; -RocketChat.ChannelSettings = new class { +export const ChannelSettings = new class { constructor() { this.options = new ReactiveVar({}); } diff --git a/app/channel-settings/client/startup/messageTypes.js b/app/channel-settings/client/startup/messageTypes.js new file mode 100644 index 000000000000..c0128c87ef34 --- /dev/null +++ b/app/channel-settings/client/startup/messageTypes.js @@ -0,0 +1,54 @@ +import { Meteor } from 'meteor/meteor'; +import { MessageTypes } from '../../../ui-utils'; +import { t } from '../../../utils'; +import s from 'underscore.string'; + +Meteor.startup(function() { + MessageTypes.registerType({ + id: 'room_changed_privacy', + system: true, + message: 'room_changed_privacy', + data(message) { + return { + user_by: message.u && message.u.username, + room_type: t(message.msg), + }; + }, + }); + + MessageTypes.registerType({ + id: 'room_changed_topic', + system: true, + message: 'room_changed_topic', + data(message) { + return { + user_by: message.u && message.u.username, + room_topic: s.escapeHTML(message.msg || `(${ t('None').toLowerCase() })`), + }; + }, + }); + + MessageTypes.registerType({ + id: 'room_changed_announcement', + system: true, + message: 'room_changed_announcement', + data(message) { + return { + user_by: message.u && message.u.username, + room_announcement: s.escapeHTML(message.msg || `(${ t('None').toLowerCase() })`), + }; + }, + }); + + MessageTypes.registerType({ + id: 'room_changed_description', + system: true, + message: 'room_changed_description', + data(message) { + return { + user_by: message.u && message.u.username, + room_description: s.escapeHTML(message.msg || `(${ t('None').toLowerCase() })`), + }; + }, + }); +}); diff --git a/app/channel-settings/client/startup/tabBar.js b/app/channel-settings/client/startup/tabBar.js new file mode 100644 index 000000000000..798dff511e36 --- /dev/null +++ b/app/channel-settings/client/startup/tabBar.js @@ -0,0 +1,14 @@ +import { Meteor } from 'meteor/meteor'; +import { TabBar } from '../../../ui-utils'; + +Meteor.startup(() => { + TabBar.addButton({ + groups: ['channel', 'group'], + id: 'channel-settings', + anonymous: true, + i18nTitle: 'Room_Info', + icon: 'info-circled', + template: 'channelSettings', + order: 1, + }); +}); diff --git a/app/channel-settings/client/startup/trackSettingsChange.js b/app/channel-settings/client/startup/trackSettingsChange.js new file mode 100644 index 000000000000..3b4c423725a8 --- /dev/null +++ b/app/channel-settings/client/startup/trackSettingsChange.js @@ -0,0 +1,47 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { Session } from 'meteor/session'; +import { callbacks } from '../../../callbacks'; +import { RoomManager } from '../../../ui-utils'; +import { roomTypes } from '../../../utils'; +import { ChatRoom, ChatSubscription } from '../../../models'; + +Meteor.startup(function() { + const roomSettingsChangedCallback = (msg) => { + Tracker.nonreactive(() => { + if (msg.t === 'room_changed_privacy') { + if (Session.get('openedRoom') === msg.rid) { + const type = FlowRouter.current().route.name === 'channel' ? 'c' : 'p'; + RoomManager.close(type + FlowRouter.getParam('name')); + + const subscription = ChatSubscription.findOne({ rid: msg.rid }); + const route = subscription.t === 'c' ? 'channel' : 'group'; + FlowRouter.go(route, { name: subscription.name }, FlowRouter.current().queryParams); + } + } + }); + + return msg; + }; + + callbacks.add('streamMessage', roomSettingsChangedCallback, callbacks.priority.HIGH, 'room-settings-changed'); + + const roomNameChangedCallback = (msg) => { + Tracker.nonreactive(() => { + if (msg.t === 'r') { + if (Session.get('openedRoom') === msg.rid) { + const room = ChatRoom.findOne(msg.rid); + if (room.name !== FlowRouter.getParam('name')) { + RoomManager.close(room.t + FlowRouter.getParam('name')); + roomTypes.openRouteLink(room.t, room, FlowRouter.current().queryParams); + } + } + } + }); + + return msg; + }; + + callbacks.add('streamMessage', roomNameChangedCallback, callbacks.priority.HIGH, 'room-name-changed'); +}); diff --git a/packages/rocketchat-channel-settings/client/stylesheets/channel-settings.css b/app/channel-settings/client/stylesheets/channel-settings.css similarity index 100% rename from packages/rocketchat-channel-settings/client/stylesheets/channel-settings.css rename to app/channel-settings/client/stylesheets/channel-settings.css diff --git a/packages/rocketchat-channel-settings/client/views/channelSettings.html b/app/channel-settings/client/views/channelSettings.html similarity index 97% rename from packages/rocketchat-channel-settings/client/views/channelSettings.html rename to app/channel-settings/client/views/channelSettings.html index 7cbb2c38f609..c7c50ecff63a 100644 --- a/packages/rocketchat-channel-settings/client/views/channelSettings.html +++ b/app/channel-settings/client/views/channelSettings.html @@ -330,18 +330,21 @@