diff --git a/.circleci/config.yml b/.circleci/config.yml index 673ddf13b631d..d2d9d39a5d3f2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,23 +1,86 @@ defaults: &defaults working_directory: ~/repo +attach_workspace: &attach_workspace + at: /tmp + +test-install-dependencies: &test-install-dependencies + name: 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 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 + +test-run: &test-run + name: Run Tests + command: | + for i in $(seq 1 5); do mongo rocketchat --eval 'db.dropDatabase()' && npm test && s=0 && break || s=$? && sleep 1; done; (exit $s) + +test-npm-install: &test-npm-install + name: NPM install + command: | + npm install + +test-store_artifacts: &test-store_artifacts + path: .screenshots/ + +test-configure-replicaset: &test-configure-replicaset + name: Configure Replica Set + command: | + mongo --eval 'rs.initiate({_id:"rs0", members: [{"_id":1, "host":"localhost:27017"}]})' + mongo --eval 'rs.status()' + +test-restore-npm-cache: &test-restore-npm-cache + keys: + - node-modules-cache-{{ checksum ".circleci/config.yml" }}-{{ checksum "package.json" }} + +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 + <<: *defaults + environment: + TEST_MODE: "true" + MONGO_URL: mongodb://localhost:27017/rocketchat + MONGO_OPLOG_URL: mongodb://localhost:27017/local + + steps: + - attach_workspace: *attach_workspace + - 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 + version: 2 jobs: build: <<: *defaults docker: - - image: circleci/node:8.11 + - image: circleci/node:8.11-stretch + - image: mongo:3.2 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 @@ -55,6 +118,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 @@ -64,30 +130,36 @@ jobs: - run: name: Unit Test command: | - meteor npm run testunit + MONGO_URL=mongodb://localhost:27017 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 @@ -98,30 +170,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/ @@ -132,96 +204,39 @@ jobs: - store_artifacts: path: /tmp/build - test-with-oplog: - <<: *defaults + + test-with-oplog-mongo-3-2: + <<: *test-with-oplog docker: - - image: circleci/node:8.11-browsers - - image: mongo:4.0 + - image: *test-docker-image + - image: mongo:3.2 command: [mongod, --noprealloc, --smallfiles, --replSet=rs0] - environment: - TEST_MODE: "true" - MONGO_URL: mongodb://localhost:27017/rocketchat - MONGO_OPLOG_URL: mongodb://localhost:27017/local - - steps: - - attach_workspace: - at: /tmp - - - checkout - - - run: - name: 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 - sudo apt-get update - sudo apt-get install -y mongodb-org-shell google-chrome-stable - - - run: - name: Configure Replica Set - command: | - mongo --eval 'rs.initiate({_id:"rs0", members: [{"_id":1, "host":"localhost:27017"}]})' - mongo --eval 'rs.status()' - - - run: - name: NPM install - command: | - npm install - - - run: - name: Run Tests - command: | - for i in $(seq 1 5); do mongo rocketchat --eval 'db.dropDatabase()' && npm test && s=0 && break || s=$? && sleep 1; done; (exit $s) - - - store_artifacts: - path: .screenshots/ - - test-without-oplog: - <<: *defaults + test-with-oplog-mongo-3-4: + <<: *test-with-oplog docker: - - image: circleci/node:8.11-browsers - - image: circleci/mongo:4.0 - - environment: - TEST_MODE: "true" - MONGO_URL: mongodb://localhost:27017/rocketchat - - steps: - - attach_workspace: - at: /tmp - - - checkout - - - run: - name: 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 - sudo apt-get update - sudo apt-get install -y mongodb-org-shell google-chrome-stable - - - run: - name: NPM install - command: | - npm install + - image: *test-docker-image + - image: mongo:3.4 + command: [mongod, --noprealloc, --smallfiles, --replSet=rs0] - - run: - name: Run Tests - command: | - for i in $(seq 1 5); do mongo rocketchat --eval 'db.dropDatabase()' && npm test && s=0 && break || s=$? && sleep 1; done; (exit $s) + test-with-oplog-mongo-3-6: + <<: *test-with-oplog + docker: + - image: *test-docker-image + - image: mongo:3.6 + command: [mongod, --noprealloc, --smallfiles, --replSet=rs0] - - store_artifacts: - path: .screenshots/ + test-with-oplog-mongo-4-0: + <<: *test-with-oplog + docker: + - image: *test-docker-image + - image: mongo:4.0 + command: [mongod, --noprealloc, --smallfiles, --replSet=rs0] deploy: <<: *defaults docker: - - image: circleci/node:8.11 + - image: circleci/node:8.11-stretch steps: - attach_workspace: @@ -235,9 +250,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 @@ -253,7 +268,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 @@ -377,28 +391,34 @@ workflows: - build: filters: tags: - only: /^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$/ - - test-with-oplog: + 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]+)?$/ - - test-without-oplog: + only: /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|beta)\.[0-9]+)?$/ + - test-with-oplog-mongo-3-4: &test-mongo-no-pr requires: - build 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]+)?$/ + - test-with-oplog-mongo-3-6: *test-mongo-no-pr + - test-with-oplog-mongo-4-0: *test-mongo - deploy: requires: - - test-with-oplog - - test-without-oplog + - test-with-oplog-mongo-3-2 + - test-with-oplog-mongo-3-4 + - test-with-oplog-mongo-3-6 + - test-with-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 @@ -406,7 +426,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: @@ -415,7 +435,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 @@ -423,5 +443,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 8a5faf28da378..bc250d5967130 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.local b/.docker/Dockerfile.local deleted file mode 100644 index 9fc3eb43797b0..0000000000000 --- a/.docker/Dockerfile.local +++ /dev/null @@ -1,20 +0,0 @@ -FROM node:8 - -ADD . /app - -ENV RC_VERSION=0.57.0-designpreview \ - DEPLOY_METHOD=docker \ - NODE_ENV=production \ - PORT=3000 \ - ROOT_URL=http://localhost:3000 - -RUN set -x \ - && cd /app/bundle/programs/server \ - && npm install \ - && npm cache clear --force - -WORKDIR /app/bundle - -EXPOSE 3000 - -CMD ["node", "main.js"] diff --git a/.docker/Dockerfile.rhel b/.docker/Dockerfile.rhel index 07ae78cd2ba6e..70113a8558e15 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.73.0-develop +ENV RC_VERSION 1.1.0-develop MAINTAINER buildmaster@rocket.chat diff --git a/.eslintignore b/.eslintignore index a557dee6c2f53..0f9a4f4d54d5e 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,23 +2,24 @@ 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 +imports/client/ diff --git a/.eslintrc b/.eslintrc index 427e6d39adffd..74f9766d2044d 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,46 +2,10 @@ "extends": ["@rocket.chat/eslint-config"], "parser": "babel-eslint", "globals": { + "__meteor_bootstrap__" : false, "__meteor_runtime_config__" : false, - "AccountBox" : false, - "AgentUsers" : false, - "Apps" : false, "Assets" : false, - "browser" : false, - "ChatMessage" : false, - "ChatMessages" : false, - "ChatRoom" : false, - "ChatSubscription" : false, - "Department" : false, - "FileUpload" : false, - "getNextAgent" : false, - "handleError" : false, - "getAvatarUrlFromUsername" : false, - "LivechatCustomField" : false, - "LivechatDepartment" : false, - "LivechatDepartmentAgents" : false, - "livechatManagerRoutes" : true, - "LivechatMonitoring" : false, - "LivechatPageVisited" : false, - "LivechatTrigger" : false, - "Logger" : false, - "modal" : false, - "Npm" : false, - "Package" : false, - "parentCall" : false, - "RocketChat" : true, - "RoomHistoryManager" : false, - "RoomManager" : false, - "ServiceConfiguration" : false, - "Settings" : false, - "SideNav" : false, - "t" : false, - "TimeSync" : false, - "toastr" : false, - "Trigger" : false, - "Triggers" : false, - "visitor" : false, - "VideoRecorder" : false, - "VRecDialog" : false + "chrome" : false, + "jscolor" : false } } diff --git a/.github/history.json b/.github/history.json index 94af4750486cc..499478ee9aac2 100644 --- a/.github/history.json +++ b/.github/history.json @@ -21638,6 +21638,8337 @@ ] } ] + }, + "0.70.5": { + "node_version": "8.11.3", + "npm_version": "5.6.0", + "pull_requests": [ + { + "pr": "12898", + "title": "[FIX] Reset password email", + "userLogin": "sampaiodiego", + "milestone": "0.72.2", + "contributors": [ + "sampaiodiego" + ] + } + ] + }, + "0.71.2": { + "node_version": "8.11.3", + "npm_version": "5.6.0", + "pull_requests": [ + { + "pr": "12898", + "title": "[FIX] Reset password email", + "userLogin": "sampaiodiego", + "milestone": "0.72.2", + "contributors": [ + "sampaiodiego" + ] + } + ] + }, + "0.73.0-rc.0": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "pull_requests": [ + { + "pr": "12985", + "title": "[IMPROVE] Hipchat Enterprise Importer", + "userLogin": "Hudell", + "milestone": "0.73.0", + "contributors": [ + "Hudell", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "13014", + "title": "LingoHub based on develop", + "userLogin": "engelgabriel", + "milestone": "0.73.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "12123", + "title": "[FIX] Avoiding links with highlighted words", + "userLogin": "rssilva", + "milestone": "0.73.0", + "contributors": [ + "rssilva", + "tassoevan", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12748", + "title": "[NEW] Create new permission.listAll endpoint to be able to use updatedSince parameter", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "9748", + "title": "[NEW] Mandatory 2fa for role", + "userLogin": "Hudell", + "milestone": "0.73.0", + "contributors": [ + "Hudell", + "karlprieb", + "web-flow", + "rodrigok" + ] + }, + { + "pr": "12739", + "title": "[FIX] Pin and unpin message were not checking permissions", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12734", + "title": "[FIX] Fix users.setPreferences endpoint, set language correctly", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12754", + "title": "[NEW] Add query parameter support to emoji-custom endpoint", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12790", + "title": "[FIX] Fix set avatar http call, to avoid SSL errors", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12856", + "title": "[NEW] Added a link to contributing.md", + "userLogin": "sanketsingh24", + "milestone": "0.73.0", + "contributors": [ + "sanketsingh24", + "web-flow" + ] + }, + { + "pr": "13010", + "title": "[NEW] Added chat.getDeletedMessages since specific date", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12569", + "title": "[FIX] Webdav integration account settings were being shown even when Webdav was disabled", + "userLogin": "karakayasemi", + "milestone": "0.73.0", + "contributors": [ + "karakayasemi" + ] + }, + { + "pr": "12722", + "title": "[IMPROVE] Add missing translation keys.", + "userLogin": "ura14h", + "milestone": "0.73.0", + "contributors": [ + "ura14h", + "web-flow" + ] + }, + { + "pr": "12792", + "title": "[FIX] Provide better Dutch translations 🇳🇱", + "userLogin": "mathysie", + "milestone": "0.73.0", + "contributors": [ + "mathysie", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "12795", + "title": "[FIX] E2E`s password reaveal text is always `>%S` when language is zh", + "userLogin": "lvyue", + "milestone": "0.73.0", + "contributors": [ + "lvyue", + "web-flow" + ] + }, + { + "pr": "12874", + "title": "[NEW] Download button for each file in fileslist", + "userLogin": "alexbartsch", + "milestone": "0.73.0", + "contributors": [ + "alexbartsch", + "web-flow", + "tassoevan" + ] + }, + { + "pr": "13011", + "title": "Move isFirefox and isChrome functions to rocketchat-utils", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12998", + "title": "[FIX] Nested Markdown blocks not parsed properly", + "userLogin": "Hudell", + "milestone": "0.73.0", + "contributors": [ + "Hudell", + "rodrigok" + ] + }, + { + "pr": "13009", + "title": "[IMPROVE] Accept Slash Commands via Action Buttons when `msg_in_chat_window: true`", + "userLogin": "rodrigok", + "milestone": "0.73.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13008", + "title": "[IMPROVE] Allow transfer Livechats to online agents only", + "userLogin": "renatobecker", + "milestone": "0.73.0", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "12706", + "title": "[FIX] Change JSON to EJSON.parse query to support type Date", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13005", + "title": "Move tapi18n t and isRtl functions from ui to utils", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13004", + "title": "[FIX] Inherit font family in message user card", + "userLogin": "tassoevan", + "milestone": "0.73.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "12999", + "title": "Remove /* globals */ wave 4", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12997", + "title": "Remove /* globals */ wave 3", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12995", + "title": "Convert rocketchat-logger to main module structure and remove Logger from eslintrc", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12948", + "title": "[FIX] Some deprecation issues for media recording", + "userLogin": "tassoevan", + "milestone": "0.72.4", + "contributors": [ + "tassoevan", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12983", + "title": "[FIX] Stop click event propagation on mention link or user card", + "userLogin": "tassoevan", + "milestone": "0.73.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "12973", + "title": "[FIX] Change field checks in RocketChat.saveStreamingOptions", + "userLogin": "tassoevan", + "milestone": "0.73.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "12980", + "title": "[FIX] Remove sharp's deprecation warnings on image upload", + "userLogin": "tassoevan", + "milestone": "0.73.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "12975", + "title": "[FIX] Use web.browser.legacy bundle for Livechat script", + "userLogin": "tassoevan", + "milestone": "0.72.4", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "12954", + "title": "[FIX] Revert Jitsi external API to an asset", + "userLogin": "sampaiodiego", + "milestone": "0.72.4", + "contributors": [ + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "12970", + "title": "[FIX] Exception in getSingleMessage", + "userLogin": "tsukiRep", + "milestone": "0.72.4", + "contributors": [ + "tsukiRep", + "web-flow" + ] + }, + { + "pr": "12988", + "title": "Remove /* globals */ wave 2", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12984", + "title": "Remove /* globals */ from files wave-1", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12940", + "title": "[FIX] multiple rooms-changed", + "userLogin": "ggazzo", + "milestone": "0.72.4", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "12994", + "title": "[FIX] Readable validation on the apps engine environment bridge", + "userLogin": "d-gubert", + "milestone": "0.73.0", + "contributors": [ + "d-gubert", + "web-flow" + ] + }, + { + "pr": "12989", + "title": "[IMPROVE] Adding debugging instructions in README", + "userLogin": "hypery2k", + "contributors": [ + "hypery2k", + "web-flow" + ] + }, + { + "pr": "12972", + "title": "[FIX] Check for object falsehood before referencing properties in saveRoomSettings", + "userLogin": "tassoevan", + "milestone": "0.72.4", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "12959", + "title": "Move globals of test to a specific eslintrc file", + "userLogin": "rodrigok", + "milestone": "0.73.0", + "contributors": [ + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12960", + "title": "Remove global ServiceConfiguration", + "userLogin": "rodrigok", + "milestone": "0.73.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "12961", + "title": "Remove global toastr", + "userLogin": "rodrigok", + "milestone": "0.73.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "12942", + "title": "Convert rocketchat-livechat to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12957", + "title": "[FIX] Spotlight being called while in background", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "12949", + "title": "changed maxRoomsOpen", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "12934", + "title": "Revert imports of css, reAdd them to the addFiles function", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12896", + "title": "Convert rocketchat-theme to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12895", + "title": "Convert rocketchat-katex to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12886", + "title": "Convert rocketchat-webdav to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12871", + "title": "Convert rocketchat-ui-message to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12881", + "title": "Convert rocketchat-videobridge to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12556", + "title": "[FIX] Padding for message box in embedded layout", + "userLogin": "tassoevan", + "milestone": "0.72.3", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "12930", + "title": "[FIX] Crowd sync was being stopped when a user was not found", + "userLogin": "piotrkochan", + "milestone": "0.72.3", + "contributors": [ + "piotrkochan", + "web-flow", + "rodrigok" + ] + }, + { + "pr": "12913", + "title": "[FIX] Some icons were missing", + "userLogin": "tassoevan", + "milestone": "0.72.3", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "12829", + "title": "[FIX] User data download fails when a room has been deleted.", + "userLogin": "Hudell", + "milestone": "0.72.3", + "contributors": [ + "Hudell" + ] + }, + { + "pr": "12860", + "title": "[FIX] CAS Login not working with renamed users", + "userLogin": "Hudell", + "milestone": "0.72.3", + "contributors": [ + "Hudell" + ] + }, + { + "pr": "12914", + "title": "[FIX] Stream of my_message wasn't sending the room information", + "userLogin": "ggazzo", + "milestone": "0.72.3", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "12903", + "title": "[FIX] cannot reset password", + "userLogin": "ggazzo", + "milestone": "0.72.3", + "contributors": [ + "ggazzo", + "web-flow", + "Hudell" + ] + }, + { + "pr": "12904", + "title": "[IMPROVE] Do not emit settings if there are no changes", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "12905", + "title": "[FIX] Version check update notification", + "userLogin": "rodrigok", + "milestone": "0.72.3", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "12900", + "title": "[FIX] line-height for unread bar buttons (jump to first and mark as read)", + "userLogin": "tassoevan", + "milestone": "0.72.2", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "12882", + "title": "[FIX] PDF view loading indicator", + "userLogin": "tassoevan", + "milestone": "0.72.2", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "12898", + "title": "[FIX] Reset password email", + "userLogin": "sampaiodiego", + "milestone": "0.72.2", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "12888", + "title": " Convert rocketchat-reactions to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12887", + "title": "Convert rocketchat-wordpress to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12883", + "title": "Fix: snap push from ci", + "userLogin": "geekgonecrazy", + "contributors": [ + "geekgonecrazy", + "web-flow" + ] + }, + { + "pr": "12865", + "title": "[IMPROVE] Returning an open room object in the Livechat config endpoint", + "userLogin": "renatobecker", + "milestone": "0.73.0", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "12867", + "title": " [NEW] Syncloud deploy option", + "userLogin": "cyberb", + "contributors": [ + "cyberb", + "web-flow", + "geekgonecrazy" + ] + }, + { + "pr": "12879", + "title": "Convert rocketchat-version-check to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12351", + "title": "[NEW] Config hooks for snap", + "userLogin": "LuluGO", + "contributors": [ + "LuluGO", + "geekgonecrazy", + "web-flow" + ] + }, + { + "pr": "12597", + "title": "[NEW] Livechat registration form message", + "userLogin": "renatobecker", + "milestone": "0.73.0", + "contributors": [ + "renatobecker", + "web-flow" + ] + }, + { + "pr": "12877", + "title": "Convert rocketchat-user-data-dowload to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12875", + "title": "Convert rocketchat-ui-vrecord to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12861", + "title": "Convert rocketchat-ui-login to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12859", + "title": " Convert rocketchat-ui-flextab to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12866", + "title": "[FIX] Data Import not working", + "userLogin": "Hudell", + "contributors": [ + "Hudell" + ] + }, + { + "pr": "12771", + "title": "[NEW] Include message type & id in push notification payload", + "userLogin": "cardoso", + "contributors": [ + "cardoso", + "web-flow" + ] + }, + { + "pr": "12851", + "title": "[FIX] Incorrect parameter name in Livechat stream", + "userLogin": "renatobecker", + "milestone": "0.73.0", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "12585", + "title": "[FIX] Autotranslate icon on message action menu", + "userLogin": "marceloschmidt", + "milestone": "0.73.0", + "contributors": [ + "marceloschmidt", + "engelgabriel", + "web-flow" + ] + }, + { + "pr": "12761", + "title": "German translation typo fix for Reacted_with", + "userLogin": "localguru", + "contributors": [ + "localguru" + ] + }, + { + "pr": "12848", + "title": "Bump Apps-Engine version", + "userLogin": "d-gubert", + "milestone": "0.72.1", + "contributors": [ + "d-gubert" + ] + }, + { + "pr": "12818", + "title": "[FIX] Change spread operator to Array.from for Edge browser", + "userLogin": "ohmonster", + "milestone": "0.72.1", + "contributors": [ + "ohmonster" + ] + }, + { + "pr": "12842", + "title": " Convert rocketchat-ui-account to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12727", + "title": "[FIX] API users.info returns caller rooms and not requested user ones", + "userLogin": "piotrkochan", + "milestone": "0.72.1", + "contributors": [ + "piotrkochan", + "web-flow" + ] + }, + { + "pr": "12804", + "title": "Change file order in rocketchat-cors", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.1", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12847", + "title": "[FIX] Missing HipChat Enterprise Importer", + "userLogin": "Hudell", + "milestone": "0.72.1", + "contributors": [ + "Hudell" + ] + }, + { + "pr": "12846", + "title": "Convert rocketchat-ui-clean-history to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12844", + "title": "Convert rocketchat-ui-admin to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12843", + "title": "[FIX] Google Cloud Storage storage provider", + "userLogin": "sampaiodiego", + "milestone": "0.73.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "12838", + "title": "Convert rocketchat-tokenpass to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12840", + "title": "Remove rocketchat-tutum package", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12839", + "title": "Convert rocketchat-tooltip to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12837", + "title": "Convert rocketchat-token-login to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12566", + "title": "[IMPROVE] Use MongoBD aggregation to get users from a room", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo", + "web-flow", + "engelgabriel", + "sampaiodiego" + ] + }, + { + "pr": "12833", + "title": "Convert rocketchat-statistics to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12832", + "title": "Convert rocketchat-spotify to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12831", + "title": "Convert rocketchat-sms to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12801", + "title": "Convert rocketchat-search to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12767", + "title": "Convert rocketchat-message-pin to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12770", + "title": "Convert rocketchat-message-star to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12823", + "title": "Convert rocketchat-slashcommands-msg to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12830", + "title": "Convert rocketchat-smarsh-connector to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12828", + "title": "Convert rocketchat-slider to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12827", + "title": "Convert rocketchat-slashcommands-unarchiveroom to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12624", + "title": "Dependencies update", + "userLogin": "engelgabriel", + "milestone": "0.73.0", + "contributors": [ + "engelgabriel", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12826", + "title": "Convert rocketchat-slashcommands-topic to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12825", + "title": "Convert rocketchat-slashcommands-open to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12824", + "title": "Convert rocketchat-slashcommands-mute to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12822", + "title": "Convert rocketchat-slashcommands-me to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12821", + "title": "Convert rocketchat-slashcommands-leave to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12817", + "title": "Convert rocketchat-slashcommands-kick to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12816", + "title": "Convert rocketchat-slashcommands-join to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12815", + "title": "Convert rocketchat-slashcommands-inviteall to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12814", + "title": "Convert rocketchat-slashcommands-invite to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12813", + "title": "Convert rocketchat-slashcommands-hide to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12812", + "title": "Convert rocketchat-slashcommands-help to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12811", + "title": "Convert rocketchat-slashcommands-create to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12810", + "title": "Convert rocketchat-slashcomands-archiveroom to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12808", + "title": "Convert rocketchat-slashcommands-asciiarts to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12807", + "title": "Convert rocketchat-slackbridge to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12806", + "title": "Convert rocketchat-setup-wizard to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12799", + "title": "Convert rocketchat-sandstorm to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12773", + "title": "Convert rocketchat-oauth2-server-config to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12768", + "title": "Convert rocketchat-message-snippet to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12805", + "title": "[FIX] Emoji as avatar", + "userLogin": "tassoevan", + "milestone": "0.72.1", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "12779", + "title": "[IMPROVE] Username suggestion logic", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "12803", + "title": "Fix CI deploy job", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "12797", + "title": "Convert rocketchat-retention-policy to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12778", + "title": "Convert rocketchat-push-notifications to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12777", + "title": "Convert rocketchat-otr to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12775", + "title": "Convert rocketchat-oembed to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12772", + "title": "Convert rocketchat-migrations to main-module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12766", + "title": "Convert rocketchat-message-mark-as-unread to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12798", + "title": "Remove conventional changelog cli, we are using our own cli now (Houston)", + "userLogin": "rodrigok", + "milestone": "0.73.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "12760", + "title": "Convert rocketchat-message-attachments to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12759", + "title": "Convert rocketchat-message-action to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12757", + "title": " Convert rocketchat-mentions-flextab to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12756", + "title": "Convert rocketchat-mentions to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12755", + "title": "Convert rocketchat-markdown to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12701", + "title": "Convert rocketchat-mapview to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto", + "engelgabriel", + "web-flow" + ] + }, + { + "pr": "12791", + "title": "Add check to make sure releases was updated", + "userLogin": "geekgonecrazy", + "contributors": [ + "geekgonecrazy", + "web-flow" + ] + }, + { + "pr": "12776", + "title": "Merge master into develop & Set version to 0.73.0-develop", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego", + "web-flow", + "rodrigok" + ] + }, + { + "pr": "12741", + "title": "Update Apps Engine to 1.3.1", + "userLogin": "rodrigok", + "milestone": "0.72.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "12736", + "title": "Regression: Expand Administration sections by toggling section title", + "userLogin": "tassoevan", + "milestone": "0.72.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "12737", + "title": "Regression: Fix Safari detection in PDF previewing", + "userLogin": "tassoevan", + "milestone": "0.72.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "12735", + "title": "Regression: Account pages layout", + "userLogin": "tassoevan", + "milestone": "0.72.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "12729", + "title": "Regression: Inherit font-family for message box", + "userLogin": "tassoevan", + "milestone": "0.72.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "12712", + "title": "Fix some Ukrainian translations", + "userLogin": "zdumitru", + "milestone": "0.72.0", + "contributors": [ + "zdumitru", + "web-flow" + ] + }, + { + "pr": "12713", + "title": "[FIX] Update caret position on insert a new line in message box", + "userLogin": "tassoevan", + "milestone": "0.72.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "12714", + "title": "[IMPROVE] Allow apps to update persistence by association", + "userLogin": "marceloschmidt", + "contributors": [ + "marceloschmidt", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12708", + "title": "Improve: Add missing translation keys.", + "userLogin": "ura14h", + "milestone": "0.72.0", + "contributors": [ + "ura14h", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12705", + "title": "Bump Apps Engine to 1.3.0", + "userLogin": "rodrigok", + "milestone": "0.72.0", + "contributors": [ + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12699", + "title": "Fix: Exception when registering a user with gravatar", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto", + "engelgabriel", + "web-flow", + "rodrigok" + ] + }, + { + "pr": "12637", + "title": "[FIX] DE translation for idle-time-limit", + "userLogin": "pfuender", + "milestone": "0.72.0", + "contributors": [ + null, + "engelgabriel", + "web-flow", + "pfuender" + ] + }, + { + "pr": "12707", + "title": "Fix: Fix tests by increasing window size", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12680", + "title": "[IMPROVE] Add more methods to deal with rooms via Rocket.Chat.Apps", + "userLogin": "marceloschmidt", + "milestone": "0.72.0", + "contributors": [ + "marceloschmidt" + ] + }, + { + "pr": "12692", + "title": "[IMPROVE] Better query for finding subscriptions that need a new E2E Key", + "userLogin": "Hudell", + "milestone": "0.72.0", + "contributors": [ + "Hudell" + ] + }, + { + "pr": "12684", + "title": "LingoHub based on develop", + "userLogin": "engelgabriel", + "milestone": "0.72.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "12309", + "title": "[NEW] Add permission to enable personal access token to specific roles", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12563", + "title": "[IMPROVE] Improve unreads and unreadsFrom response, prevent it to be equal null", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12483", + "title": "[NEW] Option to reset e2e key", + "userLogin": "Hudell", + "milestone": "0.72.0", + "contributors": [ + "Hudell", + "web-flow", + "engelgabriel" + ] + }, + { + "pr": "12633", + "title": "[FIX] Fixed Anonymous Registration", + "userLogin": "wreiske", + "milestone": "0.72.0", + "contributors": [ + "wreiske", + "web-flow", + "engelgabriel", + "rodrigok" + ] + }, + { + "pr": "12105", + "title": "[IMPROVE] Add rooms property in user object, if the user has the permission, with rooms roles", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12682", + "title": "Convert rocketchat-mail-messages to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12679", + "title": " Convert rocketchat-livestream to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12675", + "title": "[IMPROVE] border-radius to use --border-radius", + "userLogin": "engelgabriel", + "milestone": "0.72.0", + "contributors": [ + "engelgabriel", + "web-flow" + ] + }, + { + "pr": "12677", + "title": "[FIX] high cpu usage ~ svg icon", + "userLogin": "ph1p", + "contributors": [ + null, + "ph1p" + ] + }, + { + "pr": "12374", + "title": "Added \"npm install\" to quick start for developers", + "userLogin": "wreiske", + "milestone": "0.72.0", + "contributors": [ + "wreiske", + "web-flow" + ] + }, + { + "pr": "12651", + "title": "[NEW] /api/v1/spotlight: return joinCodeRequired field for rooms", + "userLogin": "cardoso", + "milestone": "0.72.0", + "contributors": [ + "cardoso", + "web-flow" + ] + }, + { + "pr": "12678", + "title": "Convert rocketchat-ldap to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12674", + "title": "Convert rocketchat-issuelinks to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12670", + "title": "Convert rocketchat-integrations to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12672", + "title": "Convert rocketchat-irc to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12671", + "title": "Convert rocketchat-internal-hubot to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12665", + "title": "Convert rocketchat-importer-hipchat-enterprise to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12669", + "title": "Convert rocketchat-importer-slack-users to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12666", + "title": " Convert rocketchat-importer-slack to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12661", + "title": "Convert rocketchat-iframe-login to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12662", + "title": "Convert rocketchat-importer to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12663", + "title": "Convert rocketchat-importer-csv to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12664", + "title": "Convert rocketchat-importer-hipchat to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12659", + "title": "Convert rocketchat-highlight-words to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12657", + "title": "Convert rocketchat-grant to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12658", + "title": "Convert rocketchat-graphql to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12564", + "title": "[IMPROVE] Update the 'keyboard shortcuts' documentation", + "userLogin": "nicolasbock", + "milestone": "0.72.0", + "contributors": [ + "nicolasbock", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "12643", + "title": "[FIX] Fix favico error", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12649", + "title": "Convert rocketchat-google-vision to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12650", + "title": "Removed RocketChatFile from globals", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12623", + "title": "[NEW] New API Endpoints for the new version of JS SDK", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto", + "engelgabriel", + "web-flow", + "rodrigok" + ] + }, + { + "pr": "12647", + "title": "Added imports for global variables in rocketchat-google-natural-language package", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12646", + "title": "Convert rocketchat-gitlab to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12644", + "title": "Convert rocketchat-file to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12642", + "title": "Convert rocketchat-github-enterprise to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12645", + "title": "Fix: Add email dependency in package.js", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12561", + "title": "[IMPROVE] Add new acceptable header for Livechat REST requests", + "userLogin": "renatobecker", + "milestone": "0.72.0", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "12599", + "title": "Convert rocketchat-custom-sounds to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12641", + "title": "Fix crowd error with import of SyncedCron", + "userLogin": "rodrigok", + "milestone": "0.72.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "12605", + "title": "Convert emoji-emojione to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12607", + "title": "Convert rocketchat-favico to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12604", + "title": " Convert rocketchat-emoji-custom to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12632", + "title": "[FIX] Condition to not render PDF preview", + "userLogin": "tassoevan", + "milestone": "0.72.0", + "contributors": [ + "tassoevan", + "web-flow", + "rodrigok" + ] + }, + { + "pr": "12606", + "title": "Convert rocketchat-error-handler to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12601", + "title": "Convert rocketchat-drupal to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12596", + "title": "Convert rocketchat-crowd to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12603", + "title": "Convert rocketchat-emoji to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12625", + "title": "Fix users.setAvatar endpoint tests and logic", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12616", + "title": "[IMPROVE] Atlassian Crowd settings and option to sync user data", + "userLogin": "rodrigok", + "milestone": "0.72.0", + "contributors": [ + null, + "rodrigok", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "12583", + "title": "[DOCS] Remove Cordova links, include F-Droid download button and few other adjustments", + "userLogin": "rafaelks", + "contributors": [ + "rafaelks", + "web-flow" + ] + }, + { + "pr": "12600", + "title": "Convert rocketchat-dolphin to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12594", + "title": " Convert rocketchat-channel-settings to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12595", + "title": "Convert rocketchat-cors to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12618", + "title": "[IMPROVE] CircleCI to use MongoDB 4.0 for testing", + "userLogin": "engelgabriel", + "milestone": "0.72.0", + "contributors": [ + "engelgabriel", + "web-flow" + ] + }, + { + "pr": "12614", + "title": "[FIX] Admin styles", + "userLogin": "engelgabriel", + "milestone": "0.72.0", + "contributors": [ + "engelgabriel" + ] + }, + { + "pr": "12602", + "title": "[FIX] Admin styles", + "userLogin": "engelgabriel", + "milestone": "0.72.0", + "contributors": [ + "engelgabriel" + ] + }, + { + "pr": "9336", + "title": "[FIX] Change registration message when user need to confirm email", + "userLogin": "karlprieb", + "milestone": "0.72.0", + "contributors": [ + "karlprieb", + "tassoevan", + "ggazzo", + "web-flow", + "rodrigok" + ] + }, + { + "pr": "12570", + "title": "[FIX] Import missed file in rocketchat-authorization", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12558", + "title": "[FIX] Prevent subscriptions and calls to rooms events that the user is not participating", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12382", + "title": "[IMPROVE] Japanese translations", + "userLogin": "ura14h", + "contributors": [ + "ura14h", + "web-flow" + ] + }, + { + "pr": "12525", + "title": "[IMPROVE] Add CTRL modifier for keyboard shortcut", + "userLogin": "nicolasbock", + "milestone": "0.72.0", + "contributors": [ + "nicolasbock" + ] + }, + { + "pr": "12530", + "title": "Convert rocketchat-autotranslate to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12537", + "title": "Convert rocketchat-channel-settings-mail-messages to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12538", + "title": "Convert rocketchat-colors to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12532", + "title": "Convert rocketchat-cas to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12531", + "title": "Convert rocketchat-bot-helpers to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12529", + "title": "Convert rocketchat-autolinker to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12523", + "title": "Convert rocketchat-authorization to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12547", + "title": "[NEW] Setting to configure robots.txt content", + "userLogin": "Hudell", + "contributors": [ + "Hudell" + ] + }, + { + "pr": "12539", + "title": "[FIX] Wrong test case for `users.setAvatar` endpoint", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto", + "sampaiodiego" + ] + }, + { + "pr": "12536", + "title": "[FIX] Spotlight method being called multiple times", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "12524", + "title": "Fix CSS import order", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "12518", + "title": "[FIX] German translation for for API_EmbedIgnoredHosts label", + "userLogin": "mbrodala", + "milestone": "0.72.0", + "contributors": [ + "mbrodala" + ] + }, + { + "pr": "12426", + "title": "Remove template for feature requests as issues", + "userLogin": "tassoevan", + "contributors": [ + "tassoevan", + "web-flow" + ] + }, + { + "pr": "12451", + "title": "Fix punctuation, spelling, and grammar", + "userLogin": "imronras", + "milestone": "0.72.0", + "contributors": [ + "imronras", + "web-flow" + ] + }, + { + "pr": "12522", + "title": "[IMPROVE] Ignore non-existent Livechat custom fields on Livechat API", + "userLogin": "renatobecker", + "milestone": "0.72.0", + "contributors": [ + "renatobecker", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12507", + "title": "[FIX] Handle all events for enter key in message box", + "userLogin": "tassoevan", + "milestone": "0.72.0", + "contributors": [ + "tassoevan", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12378", + "title": "[NEW] Make Livechat's widget draggable", + "userLogin": "tassoevan", + "milestone": "0.72.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "12408", + "title": "[FIX] Fix wrong parameter in chat.delete endpoint and add some test cases", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12452", + "title": "[IMPROVE] Emoji search on messageBox behaving like emojiPicker's search (#9607)", + "userLogin": "vinade", + "milestone": "0.72.0", + "contributors": [ + "vinade" + ] + }, + { + "pr": "12521", + "title": "Convert rocketchat-assets to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12510", + "title": "Convert rocketchat-api to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "rodrigok", + "MarcosSpessatto" + ] + }, + { + "pr": "12506", + "title": "Convert rocketchat-analytics to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "rodrigok", + "MarcosSpessatto" + ] + }, + { + "pr": "12503", + "title": "Convert rocketchat-action-links to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "rodrigok", + "MarcosSpessatto" + ] + }, + { + "pr": "12501", + "title": "Convert rocketchat-2fa to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "rodrigok", + "MarcosSpessatto" + ] + }, + { + "pr": "12495", + "title": "Convert meteor-timesync to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "rodrigok", + "MarcosSpessatto" + ] + }, + { + "pr": "12491", + "title": "Convert meteor-autocomplete package to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "rodrigok", + "MarcosSpessatto" + ] + }, + { + "pr": "12486", + "title": "Convert meteor-accounts-saml to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "rodrigok", + "MarcosSpessatto" + ] + }, + { + "pr": "12485", + "title": "Convert chatpal search package to modular structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "rodrigok", + "MarcosSpessatto" + ] + }, + { + "pr": "12467", + "title": "Removal of TAPi18n and TAPi18next global variables", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "rodrigok", + "MarcosSpessatto" + ] + }, + { + "pr": "12433", + "title": "Removal of Template, Blaze, BlazeLayout, FlowRouter, DDPRateLimiter, Session, UAParser, Promise, Reload and CryptoJS global variables", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "rodrigok", + "MarcosSpessatto", + "web-flow" + ] + }, + { + "pr": "12410", + "title": "Removal of Match, check, moment, Tracker and Mongo global variables", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.0", + "contributors": [ + "rodrigok", + "MarcosSpessatto", + "web-flow" + ] + }, + { + "pr": "12377", + "title": "Removal of EJSON, Accounts, Email, HTTP, Random, ReactiveDict, ReactiveVar, SHA256 and WebApp global variables", + "userLogin": "rodrigok", + "milestone": "0.72.0", + "contributors": [ + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12371", + "title": "Removal of Meteor global variable", + "userLogin": "rodrigok", + "contributors": [ + "rodrigok", + "web-flow" + ] + }, + { + "pr": "12468", + "title": "[BREAK] Update to Meteor to 1.8", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "12509", + "title": "Fix ES translation", + "userLogin": "tassoevan", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "12487", + "title": "[FIX] Email sending with GDPR user data", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "12471", + "title": "[IMPROVE] German translations", + "userLogin": "mrsimpson", + "contributors": [ + "mrsimpson", + "web-flow" + ] + }, + { + "pr": "12470", + "title": "LingoHub based on develop", + "userLogin": "engelgabriel", + "contributors": [ + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "12397", + "title": "[FIX] Manage own integrations permissions check", + "userLogin": "ggazzo", + "milestone": "0.72.0", + "contributors": [ + "ggazzo", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "12411", + "title": "[FIX] stream room-changed", + "userLogin": "ggazzo", + "milestone": "0.72.0", + "contributors": [ + "ggazzo", + "sampaiodiego" + ] + }, + { + "pr": "12457", + "title": "[FIX] Emoji picker is not in viewport on small screens", + "userLogin": "ramrami", + "milestone": "0.72.0", + "contributors": [ + "ramrami" + ] + }, + { + "pr": "12400", + "title": "[IMPROVE] Limit the number of typing users shown (#8722)", + "userLogin": "vinade", + "contributors": [ + "vinade", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "12406", + "title": "[FIX] `Disabled` word translation to Spanish", + "userLogin": "Ismaw34", + "contributors": [ + "Ismaw34", + "web-flow" + ] + }, + { + "pr": "12260", + "title": "[FIX] `Disabled` word translation to Chinese", + "userLogin": "AndreamApp", + "milestone": "0.72.0", + "contributors": [ + "AndreamApp", + "web-flow" + ] + }, + { + "pr": "12465", + "title": "Update npm dependencies", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "12440", + "title": "Fix: Developers not being able to debug root files in VSCode", + "userLogin": "mrsimpson", + "contributors": [ + "mrsimpson" + ] + }, + { + "pr": "12453", + "title": "[FIX] Correct roomName value in Mail Messages (#12363)", + "userLogin": "vinade", + "milestone": "0.72.0", + "contributors": [ + "vinade" + ] + }, + { + "pr": "12460", + "title": "Merge master into develop & Set version to 0.72.0-develop", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego", + "web-flow", + "Hudell" + ] + } + ] + }, + "0.72.1": { + "pull_requests": [ + { + "pr": "12850", + "title": "Release 0.72.1", + "userLogin": "rodrigok", + "contributors": [ + "tassoevan", + "rodrigok", + "Hudell", + "MarcosSpessatto", + "piotrkochan", + "ohmonster", + "d-gubert", + "sampaiodiego" + ] + }, + { + "pr": "12848", + "title": "Bump Apps-Engine version", + "userLogin": "d-gubert", + "milestone": "0.72.1", + "contributors": [ + "d-gubert" + ] + }, + { + "pr": "12818", + "title": "[FIX] Change spread operator to Array.from for Edge browser", + "userLogin": "ohmonster", + "milestone": "0.72.1", + "contributors": [ + "ohmonster" + ] + }, + { + "pr": "12727", + "title": "[FIX] API users.info returns caller rooms and not requested user ones", + "userLogin": "piotrkochan", + "milestone": "0.72.1", + "contributors": [ + "piotrkochan", + "web-flow" + ] + }, + { + "pr": "12804", + "title": "Change file order in rocketchat-cors", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.1", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12847", + "title": "[FIX] Missing HipChat Enterprise Importer", + "userLogin": "Hudell", + "milestone": "0.72.1", + "contributors": [ + "Hudell" + ] + }, + { + "pr": "12805", + "title": "[FIX] Emoji as avatar", + "userLogin": "tassoevan", + "milestone": "0.72.1", + "contributors": [ + "tassoevan" + ] + } + ] + }, + "0.72.2": { + "pull_requests": [ + { + "pr": "12901", + "title": "Release 0.72.2", + "userLogin": "sampaiodiego", + "contributors": [ + "tassoevan", + "sampaiodiego" + ] + }, + { + "pr": "12900", + "title": "[FIX] line-height for unread bar buttons (jump to first and mark as read)", + "userLogin": "tassoevan", + "milestone": "0.72.2", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "12898", + "title": "[FIX] Reset password email", + "userLogin": "sampaiodiego", + "milestone": "0.72.2", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "12882", + "title": "[FIX] PDF view loading indicator", + "userLogin": "tassoevan", + "milestone": "0.72.2", + "contributors": [ + "tassoevan" + ] + } + ] + }, + "0.72.3": { + "pull_requests": [ + { + "pr": "12932", + "title": "Release 0.72.3", + "userLogin": "rodrigok", + "contributors": [ + "rodrigok", + "ggazzo", + "Hudell", + "tassoevan", + "piotrkochan" + ] + } + ] + }, + "0.73.0-rc.1": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "pull_requests": [ + { + "pr": "13021", + "title": "Change `chat.getDeletedMessages` to get messages after informed date and return only message's _id", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13020", + "title": "Improve Importer code quality", + "userLogin": "Hudell", + "milestone": "0.73.0", + "contributors": [ + "Hudell" + ] + } + ] + }, + "0.73.0-rc.2": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "pull_requests": [ + { + "pr": "13031", + "title": "Regression: List of custom emojis wasn't working", + "userLogin": "MarcosSpessatto", + "milestone": "0.73.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13033", + "title": "[FIX] Download files without extension wasn't possible", + "userLogin": "tassoevan", + "milestone": "0.73.0", + "contributors": [ + "tassoevan" + ] + } + ] + }, + "0.73.0": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "pull_requests": [ + { + "pr": "12932", + "title": "Release 0.72.3", + "userLogin": "rodrigok", + "contributors": [ + "rodrigok", + "ggazzo", + "Hudell", + "tassoevan", + "piotrkochan" + ] + }, + { + "pr": "12901", + "title": "Release 0.72.2", + "userLogin": "sampaiodiego", + "contributors": [ + "tassoevan", + "sampaiodiego" + ] + }, + { + "pr": "12848", + "title": "Bump Apps-Engine version", + "userLogin": "d-gubert", + "milestone": "0.72.1", + "contributors": [ + "d-gubert" + ] + }, + { + "pr": "12818", + "title": "[FIX] Change spread operator to Array.from for Edge browser", + "userLogin": "ohmonster", + "milestone": "0.72.1", + "contributors": [ + "ohmonster" + ] + }, + { + "pr": "12727", + "title": "[FIX] API users.info returns caller rooms and not requested user ones", + "userLogin": "piotrkochan", + "milestone": "0.72.1", + "contributors": [ + "piotrkochan", + "web-flow" + ] + }, + { + "pr": "12804", + "title": "Change file order in rocketchat-cors", + "userLogin": "MarcosSpessatto", + "milestone": "0.72.1", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12847", + "title": "[FIX] Missing HipChat Enterprise Importer", + "userLogin": "Hudell", + "milestone": "0.72.1", + "contributors": [ + "Hudell" + ] + }, + { + "pr": "12805", + "title": "[FIX] Emoji as avatar", + "userLogin": "tassoevan", + "milestone": "0.72.1", + "contributors": [ + "tassoevan" + ] + } + ] + }, + "0.73.1": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "13052", + "title": "Release 0.73.1", + "userLogin": "rodrigok", + "milestone": "0.73.1", + "contributors": [ + "sampaiodiego", + "rodrigok" + ] + }, + { + "pr": "13049", + "title": "Execute tests with versions 3.2, 3.4, 3.6 and 4.0 of MongoDB", + "userLogin": "rodrigok", + "milestone": "0.73.1", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13051", + "title": "Regression: Get room's members list not working on MongoDB 3.2", + "userLogin": "sampaiodiego", + "milestone": "0.73.1", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13045", + "title": "[FIX] Default importer path", + "userLogin": "sampaiodiego", + "milestone": "0.73.1", + "contributors": [ + "sampaiodiego" + ] + } + ] + }, + "0.73.2": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "13086", + "title": "Release 0.73.2", + "userLogin": "sampaiodiego", + "contributors": [ + "graywolf336", + "sampaiodiego" + ] + }, + { + "pr": "13013", + "title": "[NEW] Cloud Integration", + "userLogin": "graywolf336", + "milestone": "0.73.2", + "contributors": [ + "graywolf336", + "geekgonecrazy", + "web-flow", + "sampaiodiego" + ] + } + ] + }, + "0.74.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": "12294", + "title": "[IMPROVE] Dutch translations", + "userLogin": "Jeroeny", + "contributors": [ + "Jeroeny", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13112", + "title": "[FIX] Few polish translating", + "userLogin": "theundefined", + "contributors": [ + "theundefined" + ] + }, + { + "pr": "13114", + "title": "[IMPROVE] Persian translations", + "userLogin": "behnejad", + "contributors": [ + "behnejad", + "web-flow" + ] + }, + { + "pr": "13201", + "title": "LingoHub based on develop", + "userLogin": "engelgabriel", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13053", + "title": "[FIX] Update Message: Does not show edited when message was not edited.", + "userLogin": "Kailash0311", + "contributors": [ + "Kailash0311", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13067", + "title": "[FIX] Notifications for mentions not working on large rooms and don't emit desktop notifications for offline users", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "12153", + "title": "[NEW] SAML: Adds possibility to decrypt encrypted assertions", + "userLogin": "gerbsen", + "milestone": "0.74.0", + "contributors": [ + "gerbsen", + "web-flow" + ] + }, + { + "pr": "13177", + "title": "Language: Edit typo \"Обновлить\"", + "userLogin": "zpavlig", + "milestone": "0.74.0", + "contributors": [ + "zpavlig", + "web-flow" + ] + }, + { + "pr": "11251", + "title": "[NEW] Add rate limiter to REST endpoints", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto", + "geekgonecrazy", + "web-flow", + "rodrigok" + ] + }, + { + "pr": "12858", + "title": "[FIX] Emoticons not displayed in room topic", + "userLogin": "alexbartsch", + "milestone": "0.74.0", + "contributors": [ + "alexbartsch", + "web-flow" + ] + }, + { + "pr": "13129", + "title": "[IMPROVE] Change the way the app detail screen shows support link when it's an email", + "userLogin": "d-gubert", + "milestone": "0.74.0", + "contributors": [ + "d-gubert", + "web-flow" + ] + }, + { + "pr": "13183", + "title": "[NEW] Added an option to disable email when activate and deactivate users", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "13160", + "title": "[NEW] Add create, update and delete endpoint for custom emojis", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13150", + "title": "[FIX] REST API endpoint `users.getPersonalAccessTokens` error when user has no access tokens", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13192", + "title": "Regression: Fix export AudioRecorder", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13167", + "title": "[NEW] Added endpoint to update timeout of the jitsi video conference", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13188", + "title": "[FIX] Remove unused code for Cordova", + "userLogin": "rodrigok", + "milestone": "0.74.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13194", + "title": "[IMPROVE] Process alerts from update checking", + "userLogin": "rodrigok", + "milestone": "0.74.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13184", + "title": "[NEW] Display total number of files and total upload size in admin", + "userLogin": "geekgonecrazy", + "milestone": "0.74.0", + "contributors": [ + "geekgonecrazy", + "sampaiodiego", + "web-flow", + "rodrigok" + ] + }, + { + "pr": "13181", + "title": "[FIX] Avatars with transparency were being converted to black", + "userLogin": "rodrigok", + "milestone": "0.74.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13180", + "title": "[FIX] REST api client base url on subdir", + "userLogin": "d-gubert", + "contributors": [ + "d-gubert" + ] + }, + { + "pr": "13170", + "title": "[FIX] Change webdav creation, due to changes in the npm lib after last update", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "12927", + "title": "[FIX] Invite command was not accpeting @ in username", + "userLogin": "piotrkochan", + "milestone": "0.74.0", + "contributors": [ + "piotrkochan", + "marceloschmidt", + "web-flow" + ] + }, + { + "pr": "13105", + "title": "[FIX] Remove ES6 code from Livechat widget script", + "userLogin": "tassoevan", + "milestone": "0.74.0", + "contributors": [ + "tassoevan", + "web-flow" + ] + }, + { + "pr": "13169", + "title": "[IMPROVE] Add \"Apps Engine Version\" to Administration > Info", + "userLogin": "d-gubert", + "milestone": "0.74.0", + "contributors": [ + "d-gubert" + ] + }, + { + "pr": "12982", + "title": "[NEW] Livechat GDPR compliance", + "userLogin": "renatobecker", + "milestone": "0.74.0", + "contributors": [ + "renatobecker", + "web-flow" + ] + }, + { + "pr": "13168", + "title": "[IMPROVE] New Livechat statistics added to statistics collector", + "userLogin": "renatobecker", + "milestone": "0.74.0", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "13076", + "title": "[NEW] Added stream to notify when agent status change", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "13078", + "title": "[IMPROVE] Return room type field on Livechat findRoom method", + "userLogin": "renatobecker", + "milestone": "0.74.0", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "13097", + "title": "[IMPROVE] Return visitorEmails field on Livechat findGuest method", + "userLogin": "renatobecker", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto", + "renatobecker", + "web-flow" + ] + }, + { + "pr": "13108", + "title": "[NEW] Add new Livechat REST endpoint to update the visitor's status", + "userLogin": "renatobecker", + "milestone": "0.74.0", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "13158", + "title": "[IMPROVE] Adds the \"showConnecting\" property to Livechat Config payload", + "userLogin": "renatobecker", + "milestone": "0.74.0", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "13137", + "title": " Remove dependency of RocketChat namespace and push-notifications", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13136", + "title": "Remove dependency of RocketChat namespace and custom-sounds", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13135", + "title": "Remove dependency of RocketChat namespace and logger", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13133", + "title": "Remove dependency between RocketChat namespace and migrations", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13132", + "title": "Convert rocketchat:ui to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13131", + "title": "Remove dependency of RocketChat namespace inside rocketchat:ui", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13123", + "title": "Move some ui function to ui-utils", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13157", + "title": "Regression: fix upload permissions", + "userLogin": "sampaiodiego", + "milestone": "0.74.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13122", + "title": "Move some function to utils", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13118", + "title": "Remove directly dependency between rocketchat:lib and emoji", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13117", + "title": "Convert rocketchat-webrtc to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13115", + "title": "Remove directly dependency between lib and e2e", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13107", + "title": "Convert rocketchat-ui-master to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13146", + "title": "Regression: fix rooms model's collection name", + "userLogin": "sampaiodiego", + "milestone": "0.74.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13098", + "title": "Convert rocketchat-ui-sidenav to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13094", + "title": "Convert rocketchat-file-upload to main module structure", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13066", + "title": "Remove dependency between lib and authz", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13037", + "title": "Globals/main module custom oauth", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13064", + "title": "Move UI Collections to rocketchat:models", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13036", + "title": "Rocketchat mailer", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "13039", + "title": "Move rocketchat promises", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "13035", + "title": "Globals/move rocketchat notifications", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "13119", + "title": "Test only MongoDB with oplog versions 3.2 and 4.0 for PRs", + "userLogin": "rodrigok", + "milestone": "0.74.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13034", + "title": "Move/create rocketchat callbacks", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13032", + "title": "Move/create rocketchat metrics", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok" + ] + }, + { + "pr": "13027", + "title": "Move rocketchat models", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "13096", + "title": "[FIX] User status on header and user info are not translated", + "userLogin": "sampaiodiego", + "milestone": "0.74.0", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13091", + "title": "[FIX] #11692 - Suppress error when drop collection in migration to suit to …", + "userLogin": "Xuhao", + "contributors": [ + "Xuhao", + "d-gubert", + "web-flow" + ] + }, + { + "pr": "13073", + "title": "[NEW] Add Allow Methods directive to CORS", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13026", + "title": "Move rocketchat settings to specific package", + "userLogin": "MarcosSpessatto", + "milestone": "0.74.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "13013", + "title": "[NEW] Cloud Integration", + "userLogin": "graywolf336", + "milestone": "0.73.2", + "contributors": [ + "graywolf336", + "geekgonecrazy", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "13077", + "title": "[FIX] Change input type of e2e to password", + "userLogin": "supra08", + "milestone": "0.74.0", + "contributors": [ + "supra08" + ] + }, + { + "pr": "13074", + "title": "Remove incorrect pt-BR translation", + "userLogin": "tassoevan", + "milestone": "0.74.0", + "contributors": [ + "tassoevan", + "web-flow" + ] + }, + { + "pr": "13049", + "title": "Execute tests with versions 3.2, 3.4, 3.6 and 4.0 of MongoDB", + "userLogin": "rodrigok", + "milestone": "0.73.1", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13051", + "title": "Regression: Get room's members list not working on MongoDB 3.2", + "userLogin": "sampaiodiego", + "milestone": "0.73.1", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13045", + "title": "[FIX] Default importer path", + "userLogin": "sampaiodiego", + "milestone": "0.73.1", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13050", + "title": "Merge master into develop & Set version to 0.74.0-develop", + "userLogin": "rodrigok", + "contributors": [ + "tassoevan", + "rodrigok", + "Hudell", + "MarcosSpessatto", + "piotrkochan", + "ohmonster", + "d-gubert", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "13086", + "title": "Release 0.73.2", + "userLogin": "sampaiodiego", + "contributors": [ + "graywolf336", + "sampaiodiego" + ] + }, + { + "pr": "13049", + "title": "Execute tests with versions 3.2, 3.4, 3.6 and 4.0 of MongoDB", + "userLogin": "rodrigok", + "milestone": "0.73.1", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13051", + "title": "Regression: Get room's members list not working on MongoDB 3.2", + "userLogin": "sampaiodiego", + "milestone": "0.73.1", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13045", + "title": "[FIX] Default importer path", + "userLogin": "sampaiodiego", + "milestone": "0.73.1", + "contributors": [ + "sampaiodiego" + ] + } + ] + }, + "0.74.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": "13224", + "title": "Regression: Fix audio message upload", + "userLogin": "sampaiodiego", + "milestone": "0.74.0-rc.1", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13213", + "title": "Regression: Fix message pinning", + "userLogin": "TkTech", + "milestone": "0.74.0-rc.1", + "contributors": [ + "TkTech" + ] + }, + { + "pr": "13203", + "title": "[FIX] LDAP login of new users overwriting `fname` from all subscriptions", + "userLogin": "sampaiodiego", + "milestone": "0.74.0-rc.1", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13153", + "title": "[FIX] Snap upgrade add post-refresh hook", + "userLogin": "LuluGO", + "milestone": "0.74.0-rc.1", + "contributors": [ + "LuluGO", + "geekgonecrazy", + "web-flow", + "rodrigok" + ] + }, + { + "pr": "13083", + "title": "[IMPROVE] Adds history log for all Importers and improves HipChat import performance", + "userLogin": "Hudell", + "milestone": "0.74.0-rc.1", + "contributors": [ + "Hudell" + ] + }, + { + "pr": "13207", + "title": "Regression: Fix emoji search", + "userLogin": "sampaiodiego", + "milestone": "0.74.0-rc.1", + "contributors": [ + "sampaiodiego" + ] + } + ] + }, + "0.74.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": "13266", + "title": "[IMPROVE] Inject metrics on callbacks", + "userLogin": "sampaiodiego", + "milestone": "0.74.0-rc.2", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13239", + "title": "Change apps engine persistence bridge method to updateByAssociations", + "userLogin": "d-gubert", + "milestone": "0.74.0-rc.2", + "contributors": [ + "d-gubert" + ] + } + ] + }, + "0.74.0": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "13270", + "title": "Release 0.74.0", + "userLogin": "sampaiodiego", + "contributors": [ + "rodrigok", + "web-flow", + "sampaiodiego", + "tassoevan", + "supra08", + "graywolf336", + "MarcosSpessatto", + "Xuhao", + "d-gubert" + ] + }, + { + "pr": "13213", + "title": "Regression: Fix message pinning", + "userLogin": "TkTech", + "milestone": "0.74.0-rc.1", + "contributors": [ + "TkTech" + ] + }, + { + "pr": "13112", + "title": "[FIX] Few polish translating", + "userLogin": "theundefined", + "contributors": [ + "theundefined" + ] + }, + { + "pr": "13086", + "title": "Release 0.73.2", + "userLogin": "sampaiodiego", + "contributors": [ + "graywolf336", + "sampaiodiego" + ] + }, + { + "pr": "13049", + "title": "Execute tests with versions 3.2, 3.4, 3.6 and 4.0 of MongoDB", + "userLogin": "rodrigok", + "milestone": "0.73.1", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13051", + "title": "Regression: Get room's members list not working on MongoDB 3.2", + "userLogin": "sampaiodiego", + "milestone": "0.73.1", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "13045", + "title": "[FIX] Default importer path", + "userLogin": "sampaiodiego", + "milestone": "0.73.1", + "contributors": [ + "sampaiodiego" + ] + } + ] + }, + "0.74.1": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "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" + ] + } + ] + }, + "0.74.2": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "13326", + "title": "[FIX] Rate Limiter was limiting communication between instances", + "userLogin": "rodrigok", + "milestone": "0.74.2", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "13349", + "title": "[FIX] Setup wizard calling 'saveSetting' for each field/setting", + "userLogin": "ggazzo", + "milestone": "0.74.2", + "contributors": [ + "ggazzo" + ] + }, + { + "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": "13350", + "title": "[FIX] Pass token for cloud register", + "userLogin": "geekgonecrazy", + "milestone": "0.74.2", + "contributors": [ + "geekgonecrazy" + ] + } + ] + }, + "0.74.3": { + "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", + "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", + "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.1": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "14296", + "title": "[FIX] Popup cloud console in new window", + "userLogin": "geekgonecrazy", + "milestone": "1.0.1", + "contributors": [ + "geekgonecrazy" + ] + }, + { + "pr": "14288", + "title": "[FIX] Switch oplog required doc link to more accurate link", + "userLogin": "geekgonecrazy", + "milestone": "1.0.1", + "contributors": [ + "geekgonecrazy", + "web-flow" + ] + }, + { + "pr": "14291", + "title": "[FIX] Optional exit on Unhandled Promise Rejection", + "userLogin": "geekgonecrazy", + "milestone": "1.0.1", + "contributors": [ + "geekgonecrazy", + "web-flow", + "rodrigok" + ] + }, + { + "pr": "14293", + "title": "[FIX] Error when accessing avatar with no token", + "userLogin": "geekgonecrazy", + "milestone": "1.0.1", + "contributors": [ + "geekgonecrazy", + "rodrigok" + ] + }, + { + "pr": "14286", + "title": "[FIX] Startup error in registration check", + "userLogin": "geekgonecrazy", + "milestone": "1.0.1", + "contributors": [ + "geekgonecrazy", + "web-flow" + ] + }, + { + "pr": "14290", + "title": "[FIX] Wrong header at Apps admin section", + "userLogin": "geekgonecrazy", + "milestone": "1.0.1", + "contributors": [ + "geekgonecrazy", + "web-flow" + ] + }, + { + "pr": "14282", + "title": "[FIX] Error when accessing an invalid file upload url", + "userLogin": "wreiske", + "milestone": "1.0.1", + "contributors": [ + "wreiske", + "d-gubert", + "web-flow" + ] + } + ] } } } \ No newline at end of file diff --git a/.gitignore b/.gitignore index ae7a0245e4049..1d778eda57fec 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 6131d799c7dbb..6cfd59d2a843c 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,123 +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: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 @@ -171,7 +59,6 @@ jparker:gravatar kadira:blaze-layout kadira:flow-router keepnox:perfect-scrollbar -kenton:accounts-sandstorm mizzao:autocomplete mizzao:timesync mrt:reactive-store @@ -183,20 +70,33 @@ 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 + +edgee:slingshot +jalik:ufs-local@0.2.5 +accounts-base +accounts-oauth +autoupdate +babel-compiler +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 2299ae70d9553..91e05fc15b2fe 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 2037460254514..16e0f8be56535 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,14 +34,13 @@ 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 edgee:slingshot@0.7.1 ejson@1.1.0 email@1.2.3 -emojione:emojione@2.2.6 es5-shim@4.8.0 facebook-oauth@1.5.0 fastclick@1.0.13 @@ -68,12 +66,11 @@ 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 konecty:multiple-instances-status@1.1.0 -konecty:user-presence@2.3.0 +konecty:user-presence@2.4.0 launch-screen@1.1.1 less@2.8.0 littledata:synced-cron@1.5.1 @@ -117,7 +114,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,134 +125,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:cas@1.0.0 -rocketchat:channel-settings@0.0.1 -rocketchat:channel-settings-mail-messages@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: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:mongo-config@0.0.1 rocketchat:monitoring@2.30.2_3 -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: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: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-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-vrecord@0.0.1 -rocketchat:user-data-download@1.0.0 +rocketchat:streamer@1.0.2 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 @@ -267,7 +144,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 @@ -283,5 +159,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 8000dd9db47c0..0000000000000 --- a/.sandstorm/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.vagrant diff --git a/.sandstorm/CHANGELOG.md b/.sandstorm/CHANGELOG.md deleted file mode 100644 index 8d14253d53f66..0000000000000 --- 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 17e5bc5bc5345..0000000000000 --- 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 c7eee5ae79ea7..0000000000000 --- 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 c8a155a2a3515..0000000000000 --- 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 7001f7c09a4aa..0000000000000 --- 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 af9d391aaac93..0000000000000 --- 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 d63b973fdbd2c..0000000000000 --- 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 ad9fabd672857..0000000000000 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 8d8e2bbe62f43..0000000000000 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 06b1893ee1e70..0000000000000 --- 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 c1e19551c19ad..0000000000000 --- 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 31c373726528b..0000000000000 --- 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 df5cd046d526f..0000000000000 --- 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 = 117, # Increment this for every release. - - appMarketingVersion = (defaultText = "0.73.0-develop"), - # 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 ec123d99cd035..0000000000000 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 30713297c2f2b..0000000000000 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 d8e88683c4cf8..0000000000000 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 3955cc694a360..0000000000000 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 6882afab185d4..0000000000000 --- 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 f148e1141a45c..0000000000000 --- a/.sandstorm/stack +++ /dev/null @@ -1 +0,0 @@ -meteor diff --git a/.scripts/fix-i18n.js b/.scripts/fix-i18n.js new file mode 100644 index 0000000000000..ad8210d7cd331 --- /dev/null +++ b/.scripts/fix-i18n.js @@ -0,0 +1,29 @@ +/** + * This script will: + * + * - remove any duplicated i18n key on the same file; + * - re-order all keys based on source i18n file (en.i18n.json) + * - remove all keys not present in source i18n file + */ + +const fs = require('fs'); + +const fg = require('fast-glob'); + +const fixFiles = (path, source, newlineAtEnd = false) => { + const sourceFile = JSON.parse(fs.readFileSync(`${ path }${ source }`, 'utf8')); + const sourceKeys = Object.keys(sourceFile); + + fg([`${ path }/**/*.i18n.json`]).then((entries) => { + entries.forEach((file) => { + console.log(file); + + const json = JSON.parse(fs.readFileSync(file, 'utf8')); + + fs.writeFileSync(file, `${ JSON.stringify(json, sourceKeys, 2) }${ newlineAtEnd ? '\n' : '' }`); + }); + }); +}; + +fixFiles('./packages/rocketchat-i18n', '/i18n/en.i18n.json'); +fixFiles('./packages/rocketchat-livechat/.app/i18n', '/en.i18n.json'); diff --git a/.scripts/logs.js b/.scripts/logs.js index be65d91454b5e..a1acf80b8174e 100644 --- a/.scripts/logs.js +++ b/.scripts/logs.js @@ -1,5 +1,6 @@ const path = require('path'); const fs = require('fs'); + const semver = require('semver'); const ProgressBar = require('progress'); const _ = require('underscore'); @@ -14,7 +15,7 @@ const historyDataFile = path.join(__dirname, '../.github/history.json'); let historyData = (() => { try { - return require(historyDataFile); + return require(historyDataFile); // eslint-disable-line import/no-dynamic-require } catch (error) { return {}; } diff --git a/.scripts/md.js b/.scripts/md.js index 6f927f192cc28..ac935873a69e9 100644 --- a/.scripts/md.js +++ b/.scripts/md.js @@ -1,8 +1,9 @@ const path = require('path'); const fs = require('fs'); +const { execSync } = require('child_process'); + const semver = require('semver'); const _ = require('underscore'); -const { execSync } = require('child_process'); const historyDataFile = path.join(__dirname, '../.github/history.json'); const historyManualDataFile = path.join(__dirname, '../.github/history-manual.json'); @@ -50,7 +51,7 @@ const SummaryNameEmoticons = { const historyData = (() => { try { - return require(historyDataFile); + return require(historyDataFile); // eslint-disable-line import/no-dynamic-require } catch (error) { return {}; } @@ -58,7 +59,7 @@ const historyData = (() => { const historyManualData = (() => { try { - return require(historyManualDataFile); + return require(historyManualDataFile); // eslint-disable-line import/no-dynamic-require } catch (error) { return {}; } diff --git a/.scripts/npm-postinstall.js b/.scripts/npm-postinstall.js new file mode 100644 index 0000000000000..b8362a2401de5 --- /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 0016f95e8f5a2..6524ce70017b2 100644 --- a/.scripts/set-version.js +++ b/.scripts/set-version.js @@ -2,6 +2,7 @@ const path = require('path'); const fs = require('fs'); + const semver = require('semver'); const inquirer = require('inquirer'); // const execSync = require('child_process').execSync; @@ -10,7 +11,7 @@ const git = require('simple-git/promise')(process.cwd()); let pkgJson = {}; try { - pkgJson = require(path.resolve( + pkgJson = require(path.resolve( // eslint-disable-line import/no-dynamic-require process.cwd(), './package.json' )); @@ -20,13 +21,12 @@ try { const files = [ './package.json', - './.sandstorm/sandstorm-pkgdef.capnp', './.travis/snap.sh', './.circleci/snap.sh', './.circleci/update-releases.sh', './.docker/Dockerfile', './.docker/Dockerfile.rhel', - './packages/rocketchat-lib/rocketchat.info', + './packages/rocketchat-utils/rocketchat.info', ]; const readFile = (file) => new Promise((resolve, reject) => { fs.readFile(file, 'utf8', (error, result) => { diff --git a/.scripts/start.js b/.scripts/start.js index 3f08b5608276c..5d75151e2256b 100644 --- a/.scripts/start.js +++ b/.scripts/start.js @@ -3,22 +3,46 @@ 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 +67,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 +96,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,12 +111,15 @@ 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 + - path.delimiter + srcDir + - path.delimiter + srcDir }/node_modules`, + NODE_PATH: `${ process.env.NODE_PATH + + path.delimiter + srcDir + + path.delimiter + srcDir }/node_modules`, }), }, }); diff --git a/.scripts/version.js b/.scripts/version.js index cd18c67114392..461003c2d585e 100644 --- a/.scripts/version.js +++ b/.scripts/version.js @@ -1,10 +1,9 @@ -/* eslint object-shorthand: 0, prefer-template: 0 */ - const path = require('path'); + let pkgJson = {}; try { - pkgJson = require(path.resolve( + pkgJson = require(path.resolve( // eslint-disable-line import/no-dynamic-require process.cwd(), './package.json' )); diff --git a/.snapcraft/resources/prepareRocketChat b/.snapcraft/resources/prepareRocketChat index cd653b6fcfc2a..ac352ebda9f76 100755 --- a/.snapcraft/resources/prepareRocketChat +++ b/.snapcraft/resources/prepareRocketChat @@ -25,3 +25,6 @@ fi # sharp needs execution stack removed - https://forum.snapcraft.io/t/snap-and-executable-stacks/1812 ls -l npm/node_modules/sharp/vendor execstack --clear-execstack npm/node_modules/sharp/vendor/lib/librsvg-2.so* + +# Having to manually remove because of latest warning +rm -rf npm/node_modules/meteor/konecty_user-presence/node_modules/colors/lib/.colors.js.swp diff --git a/.snapcraft/snap/hooks/post-refresh b/.snapcraft/snap/hooks/post-refresh new file mode 100755 index 0000000000000..f4172f2dd0642 --- /dev/null +++ b/.snapcraft/snap/hooks/post-refresh @@ -0,0 +1,32 @@ +#!/bin/bash + +# Initialize the CADDY_URL to a default +caddy="$(snapctl get caddy)" +if [ -z "$caddy" ]; then + snapctl set caddy=disable +fi + +# Initialize the PORT to a default +port="$(snapctl get port)" +if [ -z "$port" ]; then + snapctl set port=3000 +fi + +# Initialize the MONGO_URL to a default +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 +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 +https="$(snapctl get https)" +if [ -z "$https" ]; then + snapctl set https=disable +fi + diff --git a/.stylelintignore b/.stylelintignore index 88092312fb930..4f8093de49f06 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 b602c51213aef..31f618fadec91 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 72095e70e1e84..0000000000000 --- 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 59ac8dc62fc07..8dc15d84197a3 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.73.0-develop + RC_VERSION=1.1.0-develop fi echo "Preparing to trigger a snap release for $CHANNEL channel" diff --git a/.vscode/launch.json b/.vscode/launch.json index 465d780a4a590..58b8073d89b2a 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 26a2996f3a710..e802f4f6c1ec9 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,1027 @@ +# 1.0.1 +`2019-04-28 · 7 🐛 · 4 👩‍💻👨‍💻` + +### Engine versions +- Node: `8.11.4` +- NPM: `6.4.1` +- MongoDB: `3.2, 3.4, 3.6, 4.0` + +### 🐛 Bug fixes + +- Popup cloud console in new window ([#14296](https://github.com/RocketChat/Rocket.Chat/pull/14296)) +- Switch oplog required doc link to more accurate link ([#14288](https://github.com/RocketChat/Rocket.Chat/pull/14288)) +- Optional exit on Unhandled Promise Rejection ([#14291](https://github.com/RocketChat/Rocket.Chat/pull/14291)) +- Error when accessing avatar with no token ([#14293](https://github.com/RocketChat/Rocket.Chat/pull/14293)) +- Startup error in registration check ([#14286](https://github.com/RocketChat/Rocket.Chat/pull/14286)) +- Wrong header at Apps admin section ([#14290](https://github.com/RocketChat/Rocket.Chat/pull/14290)) +- Error when accessing an invalid file upload url ([#14282](https://github.com/RocketChat/Rocket.Chat/pull/14282) by [@wreiske](https://github.com/wreiske)) + +### 👩‍💻👨‍💻 Contributors 😍 + +- [@wreiske](https://github.com/wreiske) + +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@d-gubert](https://github.com/d-gubert) +- [@geekgonecrazy](https://github.com/geekgonecrazy) +- [@rodrigok](https://github.com/rodrigok) + +# 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 🐛 · 3 🔍 · 9 👩‍💻👨‍💻` + +### Engine versions +- Node: `8.11.4` +- NPM: `6.4.1` +- MongoDB: `3.2, 3.4, 3.6, 4.0` + +### 🚀 Improvements + +- Open rooms quicker ([#13417](https://github.com/RocketChat/Rocket.Chat/pull/13417)) +- Allow configure Prometheus port per process via Environment Variable ([#13436](https://github.com/RocketChat/Rocket.Chat/pull/13436)) +- Add API option "permissionsRequired" ([#13430](https://github.com/RocketChat/Rocket.Chat/pull/13430)) + +### 🐛 Bug fixes + +- Invalid condition on getting next livechat agent over REST API endpoint ([#13360](https://github.com/RocketChat/Rocket.Chat/pull/13360)) +- "Test Desktop Notifications" not triggering a notification ([#13457](https://github.com/RocketChat/Rocket.Chat/pull/13457)) +- Translated and incorrect i18n variables ([#13463](https://github.com/RocketChat/Rocket.Chat/pull/13463) by [@leonboot](https://github.com/leonboot)) +- Properly escape custom emoji names for pattern matching ([#13408](https://github.com/RocketChat/Rocket.Chat/pull/13408)) +- Not translated emails ([#13452](https://github.com/RocketChat/Rocket.Chat/pull/13452)) +- XML-decryption module not found ([#13437](https://github.com/RocketChat/Rocket.Chat/pull/13437)) +- Update Russian localization ([#13244](https://github.com/RocketChat/Rocket.Chat/pull/13244) by [@BehindLoader](https://github.com/BehindLoader)) +- Several Problems on HipChat Importer ([#13336](https://github.com/RocketChat/Rocket.Chat/pull/13336)) +- Invalid push gateway configuration, requires the uniqueId ([#13423](https://github.com/RocketChat/Rocket.Chat/pull/13423)) +- Notify private settings changes even on public settings changed ([#13369](https://github.com/RocketChat/Rocket.Chat/pull/13369)) +- Misaligned upload progress bar "cancel" button ([#13407](https://github.com/RocketChat/Rocket.Chat/pull/13407)) + +
+🔍 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)) + +
+ +### 👩‍💻👨‍💻 Contributors 😍 + +- [@BehindLoader](https://github.com/BehindLoader) +- [@leonboot](https://github.com/leonboot) + +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@Hudell](https://github.com/Hudell) +- [@d-gubert](https://github.com/d-gubert) +- [@graywolf336](https://github.com/graywolf336) +- [@renatobecker](https://github.com/renatobecker) +- [@rodrigok](https://github.com/rodrigok) +- [@sampaiodiego](https://github.com/sampaiodiego) +- [@tassoevan](https://github.com/tassoevan) + +# 0.74.2 +`2019-02-05 · 1 🚀 · 3 🐛 · 4 👩‍💻👨‍💻` + +### Engine versions +- Node: `8.11.4` +- NPM: `6.4.1` +- MongoDB: `3.2, 3.4, 3.6, 4.0` + +### 🚀 Improvements + +- Send `uniqueID` to all clients so Jitsi rooms can be created correctly ([#13342](https://github.com/RocketChat/Rocket.Chat/pull/13342)) + +### 🐛 Bug fixes + +- Rate Limiter was limiting communication between instances ([#13326](https://github.com/RocketChat/Rocket.Chat/pull/13326)) +- Setup wizard calling 'saveSetting' for each field/setting ([#13349](https://github.com/RocketChat/Rocket.Chat/pull/13349)) +- Pass token for cloud register ([#13350](https://github.com/RocketChat/Rocket.Chat/pull/13350)) + +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@geekgonecrazy](https://github.com/geekgonecrazy) +- [@ggazzo](https://github.com/ggazzo) +- [@rodrigok](https://github.com/rodrigok) +- [@sampaiodiego](https://github.com/sampaiodiego) + +# 0.74.1 +`2019-02-01 · 4 🎉 · 7 🐛 · 1 🔍 · 8 👩‍💻👨‍💻` + +### Engine versions +- Node: `8.11.4` +- NPM: `6.4.1` +- MongoDB: `3.2, 3.4, 3.6, 4.0` + +### 🎉 New features + +- Limit all DDP/Websocket requests (configurable via admin panel) ([#13311](https://github.com/RocketChat/Rocket.Chat/pull/13311)) +- REST endpoint to forward livechat rooms ([#13308](https://github.com/RocketChat/Rocket.Chat/pull/13308)) +- Collect data for Monthly/Daily Active Users for a future dashboard ([#11525](https://github.com/RocketChat/Rocket.Chat/pull/11525)) +- Add parseUrls field to the apps message converter ([#13248](https://github.com/RocketChat/Rocket.Chat/pull/13248)) + +### 🐛 Bug fixes + +- Mobile view and re-enable E2E tests ([#13322](https://github.com/RocketChat/Rocket.Chat/pull/13322)) +- Hipchat Enterprise Importer not generating subscriptions ([#13293](https://github.com/RocketChat/Rocket.Chat/pull/13293)) +- Message updating by Apps ([#13294](https://github.com/RocketChat/Rocket.Chat/pull/13294)) +- REST endpoint for creating custom emojis ([#13306](https://github.com/RocketChat/Rocket.Chat/pull/13306)) +- Preview of image uploads were not working when apps framework is enable ([#13303](https://github.com/RocketChat/Rocket.Chat/pull/13303)) +- HipChat Enterprise importer fails when importing a large amount of messages (millions) ([#13221](https://github.com/RocketChat/Rocket.Chat/pull/13221)) +- Fix bug when user try recreate channel or group with same name and remove room from cache when user leaves room ([#12341](https://github.com/RocketChat/Rocket.Chat/pull/12341)) + +
+🔍 Minor changes + +- Fix: Missing export in cloud package ([#13282](https://github.com/RocketChat/Rocket.Chat/pull/13282)) + +
+ +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@Hudell](https://github.com/Hudell) +- [@MarcosSpessatto](https://github.com/MarcosSpessatto) +- [@d-gubert](https://github.com/d-gubert) +- [@geekgonecrazy](https://github.com/geekgonecrazy) +- [@renatobecker](https://github.com/renatobecker) +- [@rodrigok](https://github.com/rodrigok) +- [@sampaiodiego](https://github.com/sampaiodiego) +- [@tassoevan](https://github.com/tassoevan) + +# 0.74.0 +`2019-01-28 · 10 🎉 · 11 🚀 · 17 🐛 · 38 🔍 · 24 👩‍💻👨‍💻` + +### Engine versions +- Node: `8.11.4` +- NPM: `6.4.1` +- MongoDB: `3.2, 3.4, 3.6, 4.0` + +### 🎉 New features + +- SAML: Adds possibility to decrypt encrypted assertions ([#12153](https://github.com/RocketChat/Rocket.Chat/pull/12153) by [@gerbsen](https://github.com/gerbsen)) +- Add rate limiter to REST endpoints ([#11251](https://github.com/RocketChat/Rocket.Chat/pull/11251)) +- Added an option to disable email when activate and deactivate users ([#13183](https://github.com/RocketChat/Rocket.Chat/pull/13183)) +- Add create, update and delete endpoint for custom emojis ([#13160](https://github.com/RocketChat/Rocket.Chat/pull/13160)) +- Added endpoint to update timeout of the jitsi video conference ([#13167](https://github.com/RocketChat/Rocket.Chat/pull/13167)) +- Display total number of files and total upload size in admin ([#13184](https://github.com/RocketChat/Rocket.Chat/pull/13184)) +- Livechat GDPR compliance ([#12982](https://github.com/RocketChat/Rocket.Chat/pull/12982)) +- Added stream to notify when agent status change ([#13076](https://github.com/RocketChat/Rocket.Chat/pull/13076)) +- Add new Livechat REST endpoint to update the visitor's status ([#13108](https://github.com/RocketChat/Rocket.Chat/pull/13108)) +- Add Allow Methods directive to CORS ([#13073](https://github.com/RocketChat/Rocket.Chat/pull/13073)) + +### 🚀 Improvements + +- Dutch translations ([#12294](https://github.com/RocketChat/Rocket.Chat/pull/12294) by [@Jeroeny](https://github.com/Jeroeny)) +- Persian translations ([#13114](https://github.com/RocketChat/Rocket.Chat/pull/13114) by [@behnejad](https://github.com/behnejad)) +- Change the way the app detail screen shows support link when it's an email ([#13129](https://github.com/RocketChat/Rocket.Chat/pull/13129)) +- Process alerts from update checking ([#13194](https://github.com/RocketChat/Rocket.Chat/pull/13194)) +- Add "Apps Engine Version" to Administration > Info ([#13169](https://github.com/RocketChat/Rocket.Chat/pull/13169)) +- New Livechat statistics added to statistics collector ([#13168](https://github.com/RocketChat/Rocket.Chat/pull/13168)) +- Return room type field on Livechat findRoom method ([#13078](https://github.com/RocketChat/Rocket.Chat/pull/13078)) +- Return visitorEmails field on Livechat findGuest method ([#13097](https://github.com/RocketChat/Rocket.Chat/pull/13097)) +- Adds the "showConnecting" property to Livechat Config payload ([#13158](https://github.com/RocketChat/Rocket.Chat/pull/13158)) +- Adds history log for all Importers and improves HipChat import performance ([#13083](https://github.com/RocketChat/Rocket.Chat/pull/13083)) +- Inject metrics on callbacks ([#13266](https://github.com/RocketChat/Rocket.Chat/pull/13266)) + +### 🐛 Bug fixes + +- Few polish translating ([#13112](https://github.com/RocketChat/Rocket.Chat/pull/13112) by [@theundefined](https://github.com/theundefined)) +- Few polish translating ([#13112](https://github.com/RocketChat/Rocket.Chat/pull/13112) by [@theundefined](https://github.com/theundefined)) +- Update Message: Does not show edited when message was not edited. ([#13053](https://github.com/RocketChat/Rocket.Chat/pull/13053) by [@Kailash0311](https://github.com/Kailash0311)) +- Notifications for mentions not working on large rooms and don't emit desktop notifications for offline users ([#13067](https://github.com/RocketChat/Rocket.Chat/pull/13067)) +- Emoticons not displayed in room topic ([#12858](https://github.com/RocketChat/Rocket.Chat/pull/12858) by [@alexbartsch](https://github.com/alexbartsch)) +- REST API endpoint `users.getPersonalAccessTokens` error when user has no access tokens ([#13150](https://github.com/RocketChat/Rocket.Chat/pull/13150)) +- Remove unused code for Cordova ([#13188](https://github.com/RocketChat/Rocket.Chat/pull/13188)) +- Avatars with transparency were being converted to black ([#13181](https://github.com/RocketChat/Rocket.Chat/pull/13181)) +- REST api client base url on subdir ([#13180](https://github.com/RocketChat/Rocket.Chat/pull/13180)) +- Change webdav creation, due to changes in the npm lib after last update ([#13170](https://github.com/RocketChat/Rocket.Chat/pull/13170)) +- Invite command was not accpeting @ in username ([#12927](https://github.com/RocketChat/Rocket.Chat/pull/12927) by [@piotrkochan](https://github.com/piotrkochan)) +- Remove ES6 code from Livechat widget script ([#13105](https://github.com/RocketChat/Rocket.Chat/pull/13105)) +- User status on header and user info are not translated ([#13096](https://github.com/RocketChat/Rocket.Chat/pull/13096)) +- #11692 - Suppress error when drop collection in migration to suit to … ([#13091](https://github.com/RocketChat/Rocket.Chat/pull/13091) by [@Xuhao](https://github.com/Xuhao)) +- Change input type of e2e to password ([#13077](https://github.com/RocketChat/Rocket.Chat/pull/13077) by [@supra08](https://github.com/supra08)) +- LDAP login of new users overwriting `fname` from all subscriptions ([#13203](https://github.com/RocketChat/Rocket.Chat/pull/13203)) +- Snap upgrade add post-refresh hook ([#13153](https://github.com/RocketChat/Rocket.Chat/pull/13153)) + +
+🔍 Minor changes + +- Release 0.74.0 ([#13270](https://github.com/RocketChat/Rocket.Chat/pull/13270) by [@Xuhao](https://github.com/Xuhao) & [@supra08](https://github.com/supra08)) +- Regression: Fix message pinning ([#13213](https://github.com/RocketChat/Rocket.Chat/pull/13213) by [@TkTech](https://github.com/TkTech)) +- LingoHub based on develop ([#13201](https://github.com/RocketChat/Rocket.Chat/pull/13201)) +- Language: Edit typo "Обновлить" ([#13177](https://github.com/RocketChat/Rocket.Chat/pull/13177) by [@zpavlig](https://github.com/zpavlig)) +- Regression: Fix export AudioRecorder ([#13192](https://github.com/RocketChat/Rocket.Chat/pull/13192)) +- Remove dependency of RocketChat namespace and push-notifications ([#13137](https://github.com/RocketChat/Rocket.Chat/pull/13137)) +- Remove dependency of RocketChat namespace and custom-sounds ([#13136](https://github.com/RocketChat/Rocket.Chat/pull/13136)) +- Remove dependency of RocketChat namespace and logger ([#13135](https://github.com/RocketChat/Rocket.Chat/pull/13135)) +- Remove dependency between RocketChat namespace and migrations ([#13133](https://github.com/RocketChat/Rocket.Chat/pull/13133)) +- Convert rocketchat:ui to main module structure ([#13132](https://github.com/RocketChat/Rocket.Chat/pull/13132)) +- Remove dependency of RocketChat namespace inside rocketchat:ui ([#13131](https://github.com/RocketChat/Rocket.Chat/pull/13131)) +- Move some ui function to ui-utils ([#13123](https://github.com/RocketChat/Rocket.Chat/pull/13123)) +- Regression: fix upload permissions ([#13157](https://github.com/RocketChat/Rocket.Chat/pull/13157)) +- Move some function to utils ([#13122](https://github.com/RocketChat/Rocket.Chat/pull/13122)) +- Remove directly dependency between rocketchat:lib and emoji ([#13118](https://github.com/RocketChat/Rocket.Chat/pull/13118)) +- Convert rocketchat-webrtc to main module structure ([#13117](https://github.com/RocketChat/Rocket.Chat/pull/13117)) +- Remove directly dependency between lib and e2e ([#13115](https://github.com/RocketChat/Rocket.Chat/pull/13115)) +- Convert rocketchat-ui-master to main module structure ([#13107](https://github.com/RocketChat/Rocket.Chat/pull/13107)) +- Regression: fix rooms model's collection name ([#13146](https://github.com/RocketChat/Rocket.Chat/pull/13146)) +- Convert rocketchat-ui-sidenav to main module structure ([#13098](https://github.com/RocketChat/Rocket.Chat/pull/13098)) +- Convert rocketchat-file-upload to main module structure ([#13094](https://github.com/RocketChat/Rocket.Chat/pull/13094)) +- Remove dependency between lib and authz ([#13066](https://github.com/RocketChat/Rocket.Chat/pull/13066)) +- Globals/main module custom oauth ([#13037](https://github.com/RocketChat/Rocket.Chat/pull/13037)) +- Move UI Collections to rocketchat:models ([#13064](https://github.com/RocketChat/Rocket.Chat/pull/13064)) +- Rocketchat mailer ([#13036](https://github.com/RocketChat/Rocket.Chat/pull/13036)) +- Move rocketchat promises ([#13039](https://github.com/RocketChat/Rocket.Chat/pull/13039)) +- Globals/move rocketchat notifications ([#13035](https://github.com/RocketChat/Rocket.Chat/pull/13035)) +- Test only MongoDB with oplog versions 3.2 and 4.0 for PRs ([#13119](https://github.com/RocketChat/Rocket.Chat/pull/13119)) +- Move/create rocketchat callbacks ([#13034](https://github.com/RocketChat/Rocket.Chat/pull/13034)) +- Move/create rocketchat metrics ([#13032](https://github.com/RocketChat/Rocket.Chat/pull/13032)) +- Move rocketchat models ([#13027](https://github.com/RocketChat/Rocket.Chat/pull/13027)) +- Move rocketchat settings to specific package ([#13026](https://github.com/RocketChat/Rocket.Chat/pull/13026)) +- Remove incorrect pt-BR translation ([#13074](https://github.com/RocketChat/Rocket.Chat/pull/13074)) +- Merge master into develop & Set version to 0.74.0-develop ([#13050](https://github.com/RocketChat/Rocket.Chat/pull/13050) by [@ohmonster](https://github.com/ohmonster) & [@piotrkochan](https://github.com/piotrkochan)) +- Regression: Fix audio message upload ([#13224](https://github.com/RocketChat/Rocket.Chat/pull/13224)) +- Regression: Fix message pinning ([#13213](https://github.com/RocketChat/Rocket.Chat/pull/13213) by [@TkTech](https://github.com/TkTech)) +- Regression: Fix emoji search ([#13207](https://github.com/RocketChat/Rocket.Chat/pull/13207)) +- Change apps engine persistence bridge method to updateByAssociations ([#13239](https://github.com/RocketChat/Rocket.Chat/pull/13239)) + +
+ +### 👩‍💻👨‍💻 Contributors 😍 + +- [@Jeroeny](https://github.com/Jeroeny) +- [@Kailash0311](https://github.com/Kailash0311) +- [@TkTech](https://github.com/TkTech) +- [@Xuhao](https://github.com/Xuhao) +- [@alexbartsch](https://github.com/alexbartsch) +- [@behnejad](https://github.com/behnejad) +- [@gerbsen](https://github.com/gerbsen) +- [@ohmonster](https://github.com/ohmonster) +- [@piotrkochan](https://github.com/piotrkochan) +- [@supra08](https://github.com/supra08) +- [@theundefined](https://github.com/theundefined) +- [@zpavlig](https://github.com/zpavlig) + +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@Hudell](https://github.com/Hudell) +- [@LuluGO](https://github.com/LuluGO) +- [@MarcosSpessatto](https://github.com/MarcosSpessatto) +- [@d-gubert](https://github.com/d-gubert) +- [@geekgonecrazy](https://github.com/geekgonecrazy) +- [@ggazzo](https://github.com/ggazzo) +- [@graywolf336](https://github.com/graywolf336) +- [@marceloschmidt](https://github.com/marceloschmidt) +- [@renatobecker](https://github.com/renatobecker) +- [@rodrigok](https://github.com/rodrigok) +- [@sampaiodiego](https://github.com/sampaiodiego) +- [@tassoevan](https://github.com/tassoevan) + +# 0.73.2 +`2019-01-07 · 1 🎉 · 1 🔍 · 3 👩‍💻👨‍💻` + +### Engine versions +- Node: `8.11.4` +- NPM: `6.4.1` +- MongoDB: `3.2, 3.4, 3.6, 4.0` + +### 🎉 New features + +- Cloud Integration ([#13013](https://github.com/RocketChat/Rocket.Chat/pull/13013)) + +
+🔍 Minor changes + +- Release 0.73.2 ([#13086](https://github.com/RocketChat/Rocket.Chat/pull/13086)) + +
+ +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@geekgonecrazy](https://github.com/geekgonecrazy) +- [@graywolf336](https://github.com/graywolf336) +- [@sampaiodiego](https://github.com/sampaiodiego) + +# 0.73.1 +`2018-12-28 · 1 🐛 · 3 🔍 · 2 👩‍💻👨‍💻` + +### Engine versions +- Node: `8.11.4` +- NPM: `6.4.1` +- MongoDB: `3.2, 3.4, 3.6, 4.0` + +### 🐛 Bug fixes + +- Default importer path ([#13045](https://github.com/RocketChat/Rocket.Chat/pull/13045)) + +
+🔍 Minor changes + +- Release 0.73.1 ([#13052](https://github.com/RocketChat/Rocket.Chat/pull/13052)) +- Execute tests with versions 3.2, 3.4, 3.6 and 4.0 of MongoDB ([#13049](https://github.com/RocketChat/Rocket.Chat/pull/13049)) +- Regression: Get room's members list not working on MongoDB 3.2 ([#13051](https://github.com/RocketChat/Rocket.Chat/pull/13051)) + +
+ +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@rodrigok](https://github.com/rodrigok) +- [@sampaiodiego](https://github.com/sampaiodiego) + +# 0.73.0 +`2018-12-28 · 10 🎉 · 9 🚀 · 34 🐛 · 84 🔍 · 26 👩‍💻👨‍💻` + +### Engine versions +- Node: `8.11.4` +- NPM: `6.4.1` + +### 🎉 New features + +- Create new permission.listAll endpoint to be able to use updatedSince parameter ([#12748](https://github.com/RocketChat/Rocket.Chat/pull/12748)) +- Mandatory 2fa for role ([#9748](https://github.com/RocketChat/Rocket.Chat/pull/9748) by [@karlprieb](https://github.com/karlprieb)) +- Add query parameter support to emoji-custom endpoint ([#12754](https://github.com/RocketChat/Rocket.Chat/pull/12754)) +- Added a link to contributing.md ([#12856](https://github.com/RocketChat/Rocket.Chat/pull/12856) by [@sanketsingh24](https://github.com/sanketsingh24)) +- Added chat.getDeletedMessages since specific date ([#13010](https://github.com/RocketChat/Rocket.Chat/pull/13010)) +- Download button for each file in fileslist ([#12874](https://github.com/RocketChat/Rocket.Chat/pull/12874) by [@alexbartsch](https://github.com/alexbartsch)) +- 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) by [@cardoso](https://github.com/cardoso)) + +### 🚀 Improvements + +- Hipchat Enterprise Importer ([#12985](https://github.com/RocketChat/Rocket.Chat/pull/12985)) +- Add missing translation keys. ([#12722](https://github.com/RocketChat/Rocket.Chat/pull/12722) by [@ura14h](https://github.com/ura14h)) +- Accept Slash Commands via Action Buttons when `msg_in_chat_window: true` ([#13009](https://github.com/RocketChat/Rocket.Chat/pull/13009)) +- Allow transfer Livechats to online agents only ([#13008](https://github.com/RocketChat/Rocket.Chat/pull/13008)) +- Adding debugging instructions in README ([#12989](https://github.com/RocketChat/Rocket.Chat/pull/12989) by [@hypery2k](https://github.com/hypery2k)) +- Do not emit settings if there are no changes ([#12904](https://github.com/RocketChat/Rocket.Chat/pull/12904)) +- Returning an open room object in the Livechat config endpoint ([#12865](https://github.com/RocketChat/Rocket.Chat/pull/12865)) +- Use MongoBD aggregation to get users from a room ([#12566](https://github.com/RocketChat/Rocket.Chat/pull/12566)) +- Username suggestion logic ([#12779](https://github.com/RocketChat/Rocket.Chat/pull/12779)) + +### 🐛 Bug fixes + +- Avoiding links with highlighted words ([#12123](https://github.com/RocketChat/Rocket.Chat/pull/12123) by [@rssilva](https://github.com/rssilva)) +- Pin and unpin message were not checking permissions ([#12739](https://github.com/RocketChat/Rocket.Chat/pull/12739)) +- Fix users.setPreferences endpoint, set language correctly ([#12734](https://github.com/RocketChat/Rocket.Chat/pull/12734)) +- Fix set avatar http call, to avoid SSL errors ([#12790](https://github.com/RocketChat/Rocket.Chat/pull/12790)) +- Webdav integration account settings were being shown even when Webdav was disabled ([#12569](https://github.com/RocketChat/Rocket.Chat/pull/12569) by [@karakayasemi](https://github.com/karakayasemi)) +- Provide better Dutch translations 🇳🇱 ([#12792](https://github.com/RocketChat/Rocket.Chat/pull/12792) by [@mathysie](https://github.com/mathysie)) +- E2E`s password reaveal text is always `>%S` when language is zh ([#12795](https://github.com/RocketChat/Rocket.Chat/pull/12795) by [@lvyue](https://github.com/lvyue)) +- Nested Markdown blocks not parsed properly ([#12998](https://github.com/RocketChat/Rocket.Chat/pull/12998)) +- Change JSON to EJSON.parse query to support type Date ([#12706](https://github.com/RocketChat/Rocket.Chat/pull/12706)) +- Inherit font family in message user card ([#13004](https://github.com/RocketChat/Rocket.Chat/pull/13004)) +- Some deprecation issues for media recording ([#12948](https://github.com/RocketChat/Rocket.Chat/pull/12948)) +- Stop click event propagation on mention link or user card ([#12983](https://github.com/RocketChat/Rocket.Chat/pull/12983)) +- Change field checks in RocketChat.saveStreamingOptions ([#12973](https://github.com/RocketChat/Rocket.Chat/pull/12973)) +- Remove sharp's deprecation warnings on image upload ([#12980](https://github.com/RocketChat/Rocket.Chat/pull/12980)) +- Use web.browser.legacy bundle for Livechat script ([#12975](https://github.com/RocketChat/Rocket.Chat/pull/12975)) +- Revert Jitsi external API to an asset ([#12954](https://github.com/RocketChat/Rocket.Chat/pull/12954)) +- Exception in getSingleMessage ([#12970](https://github.com/RocketChat/Rocket.Chat/pull/12970) by [@tsukiRep](https://github.com/tsukiRep)) +- multiple rooms-changed ([#12940](https://github.com/RocketChat/Rocket.Chat/pull/12940)) +- Readable validation on the apps engine environment bridge ([#12994](https://github.com/RocketChat/Rocket.Chat/pull/12994)) +- Check for object falsehood before referencing properties in saveRoomSettings ([#12972](https://github.com/RocketChat/Rocket.Chat/pull/12972)) +- Spotlight being called while in background ([#12957](https://github.com/RocketChat/Rocket.Chat/pull/12957)) +- Padding for message box in embedded layout ([#12556](https://github.com/RocketChat/Rocket.Chat/pull/12556)) +- Crowd sync was being stopped when a user was not found ([#12930](https://github.com/RocketChat/Rocket.Chat/pull/12930) by [@piotrkochan](https://github.com/piotrkochan)) +- Some icons were missing ([#12913](https://github.com/RocketChat/Rocket.Chat/pull/12913)) +- User data download fails when a room has been deleted. ([#12829](https://github.com/RocketChat/Rocket.Chat/pull/12829)) +- CAS Login not working with renamed users ([#12860](https://github.com/RocketChat/Rocket.Chat/pull/12860)) +- Stream of my_message wasn't sending the room information ([#12914](https://github.com/RocketChat/Rocket.Chat/pull/12914)) +- cannot reset password ([#12903](https://github.com/RocketChat/Rocket.Chat/pull/12903)) +- Version check update notification ([#12905](https://github.com/RocketChat/Rocket.Chat/pull/12905)) +- Data Import not working ([#12866](https://github.com/RocketChat/Rocket.Chat/pull/12866)) +- Incorrect parameter name in Livechat stream ([#12851](https://github.com/RocketChat/Rocket.Chat/pull/12851)) +- Autotranslate icon on message action menu ([#12585](https://github.com/RocketChat/Rocket.Chat/pull/12585)) +- Google Cloud Storage storage provider ([#12843](https://github.com/RocketChat/Rocket.Chat/pull/12843)) +- Download files without extension wasn't possible ([#13033](https://github.com/RocketChat/Rocket.Chat/pull/13033)) + +
+🔍 Minor changes + +- LingoHub based on develop ([#13014](https://github.com/RocketChat/Rocket.Chat/pull/13014)) +- Move isFirefox and isChrome functions to rocketchat-utils ([#13011](https://github.com/RocketChat/Rocket.Chat/pull/13011)) +- Move tapi18n t and isRtl functions from ui to utils ([#13005](https://github.com/RocketChat/Rocket.Chat/pull/13005)) +- Remove /* globals */ wave 4 ([#12999](https://github.com/RocketChat/Rocket.Chat/pull/12999)) +- Remove /* globals */ wave 3 ([#12997](https://github.com/RocketChat/Rocket.Chat/pull/12997)) +- Convert rocketchat-logger to main module structure and remove Logger from eslintrc ([#12995](https://github.com/RocketChat/Rocket.Chat/pull/12995)) +- Remove /* globals */ wave 2 ([#12988](https://github.com/RocketChat/Rocket.Chat/pull/12988)) +- Remove /* globals */ from files wave-1 ([#12984](https://github.com/RocketChat/Rocket.Chat/pull/12984)) +- Move globals of test to a specific eslintrc file ([#12959](https://github.com/RocketChat/Rocket.Chat/pull/12959)) +- Remove global ServiceConfiguration ([#12960](https://github.com/RocketChat/Rocket.Chat/pull/12960)) +- Remove global toastr ([#12961](https://github.com/RocketChat/Rocket.Chat/pull/12961)) +- Convert rocketchat-livechat to main module structure ([#12942](https://github.com/RocketChat/Rocket.Chat/pull/12942)) +- changed maxRoomsOpen ([#12949](https://github.com/RocketChat/Rocket.Chat/pull/12949)) +- Revert imports of css, reAdd them to the addFiles function ([#12934](https://github.com/RocketChat/Rocket.Chat/pull/12934)) +- Convert rocketchat-theme to main module structure ([#12896](https://github.com/RocketChat/Rocket.Chat/pull/12896)) +- Convert rocketchat-katex to main module structure ([#12895](https://github.com/RocketChat/Rocket.Chat/pull/12895)) +- Convert rocketchat-webdav to main module structure ([#12886](https://github.com/RocketChat/Rocket.Chat/pull/12886)) +- Convert rocketchat-ui-message to main module structure ([#12871](https://github.com/RocketChat/Rocket.Chat/pull/12871)) +- Convert rocketchat-videobridge to main module structure ([#12881](https://github.com/RocketChat/Rocket.Chat/pull/12881)) +- Convert rocketchat-reactions to main module structure ([#12888](https://github.com/RocketChat/Rocket.Chat/pull/12888)) +- Convert rocketchat-wordpress to main module structure ([#12887](https://github.com/RocketChat/Rocket.Chat/pull/12887)) +- Fix: snap push from ci ([#12883](https://github.com/RocketChat/Rocket.Chat/pull/12883)) +- Convert rocketchat-version-check to main module structure ([#12879](https://github.com/RocketChat/Rocket.Chat/pull/12879)) +- Convert rocketchat-user-data-dowload to main module structure ([#12877](https://github.com/RocketChat/Rocket.Chat/pull/12877)) +- Convert rocketchat-ui-vrecord to main module structure ([#12875](https://github.com/RocketChat/Rocket.Chat/pull/12875)) +- Convert rocketchat-ui-login to main module structure ([#12861](https://github.com/RocketChat/Rocket.Chat/pull/12861)) +- Convert rocketchat-ui-flextab to main module structure ([#12859](https://github.com/RocketChat/Rocket.Chat/pull/12859)) +- German translation typo fix for Reacted_with ([#12761](https://github.com/RocketChat/Rocket.Chat/pull/12761) by [@localguru](https://github.com/localguru)) +- Convert rocketchat-ui-account to main module structure ([#12842](https://github.com/RocketChat/Rocket.Chat/pull/12842)) +- Convert rocketchat-ui-clean-history to main module structure ([#12846](https://github.com/RocketChat/Rocket.Chat/pull/12846)) +- Convert rocketchat-ui-admin to main module structure ([#12844](https://github.com/RocketChat/Rocket.Chat/pull/12844)) +- Convert rocketchat-tokenpass to main module structure ([#12838](https://github.com/RocketChat/Rocket.Chat/pull/12838)) +- Remove rocketchat-tutum package ([#12840](https://github.com/RocketChat/Rocket.Chat/pull/12840)) +- Convert rocketchat-tooltip to main module structure ([#12839](https://github.com/RocketChat/Rocket.Chat/pull/12839)) +- Convert rocketchat-token-login to main module structure ([#12837](https://github.com/RocketChat/Rocket.Chat/pull/12837)) +- Convert rocketchat-statistics to main module structure ([#12833](https://github.com/RocketChat/Rocket.Chat/pull/12833)) +- Convert rocketchat-spotify to main module structure ([#12832](https://github.com/RocketChat/Rocket.Chat/pull/12832)) +- Convert rocketchat-sms to main module structure ([#12831](https://github.com/RocketChat/Rocket.Chat/pull/12831)) +- Convert rocketchat-search to main module structure ([#12801](https://github.com/RocketChat/Rocket.Chat/pull/12801)) +- Convert rocketchat-message-pin to main module structure ([#12767](https://github.com/RocketChat/Rocket.Chat/pull/12767)) +- Convert rocketchat-message-star to main module structure ([#12770](https://github.com/RocketChat/Rocket.Chat/pull/12770)) +- Convert rocketchat-slashcommands-msg to main module structure ([#12823](https://github.com/RocketChat/Rocket.Chat/pull/12823)) +- Convert rocketchat-smarsh-connector to main module structure ([#12830](https://github.com/RocketChat/Rocket.Chat/pull/12830)) +- Convert rocketchat-slider to main module structure ([#12828](https://github.com/RocketChat/Rocket.Chat/pull/12828)) +- Convert rocketchat-slashcommands-unarchiveroom to main module structure ([#12827](https://github.com/RocketChat/Rocket.Chat/pull/12827)) +- Dependencies update ([#12624](https://github.com/RocketChat/Rocket.Chat/pull/12624)) +- Convert rocketchat-slashcommands-topic to main module structure ([#12826](https://github.com/RocketChat/Rocket.Chat/pull/12826)) +- Convert rocketchat-slashcommands-open to main module structure ([#12825](https://github.com/RocketChat/Rocket.Chat/pull/12825)) +- Convert rocketchat-slashcommands-mute to main module structure ([#12824](https://github.com/RocketChat/Rocket.Chat/pull/12824)) +- Convert rocketchat-slashcommands-me to main module structure ([#12822](https://github.com/RocketChat/Rocket.Chat/pull/12822)) +- Convert rocketchat-slashcommands-leave to main module structure ([#12821](https://github.com/RocketChat/Rocket.Chat/pull/12821)) +- Convert rocketchat-slashcommands-kick to main module structure ([#12817](https://github.com/RocketChat/Rocket.Chat/pull/12817)) +- Convert rocketchat-slashcommands-join to main module structure ([#12816](https://github.com/RocketChat/Rocket.Chat/pull/12816)) +- Convert rocketchat-slashcommands-inviteall to main module structure ([#12815](https://github.com/RocketChat/Rocket.Chat/pull/12815)) +- Convert rocketchat-slashcommands-invite to main module structure ([#12814](https://github.com/RocketChat/Rocket.Chat/pull/12814)) +- Convert rocketchat-slashcommands-hide to main module structure ([#12813](https://github.com/RocketChat/Rocket.Chat/pull/12813)) +- Convert rocketchat-slashcommands-help to main module structure ([#12812](https://github.com/RocketChat/Rocket.Chat/pull/12812)) +- Convert rocketchat-slashcommands-create to main module structure ([#12811](https://github.com/RocketChat/Rocket.Chat/pull/12811)) +- Convert rocketchat-slashcomands-archiveroom to main module structure ([#12810](https://github.com/RocketChat/Rocket.Chat/pull/12810)) +- Convert rocketchat-slashcommands-asciiarts to main module structure ([#12808](https://github.com/RocketChat/Rocket.Chat/pull/12808)) +- Convert rocketchat-slackbridge to main module structure ([#12807](https://github.com/RocketChat/Rocket.Chat/pull/12807)) +- Convert rocketchat-setup-wizard to main module structure ([#12806](https://github.com/RocketChat/Rocket.Chat/pull/12806)) +- Convert rocketchat-sandstorm to main module structure ([#12799](https://github.com/RocketChat/Rocket.Chat/pull/12799)) +- Convert rocketchat-oauth2-server-config to main module structure ([#12773](https://github.com/RocketChat/Rocket.Chat/pull/12773)) +- Convert rocketchat-message-snippet to main module structure ([#12768](https://github.com/RocketChat/Rocket.Chat/pull/12768)) +- Fix CI deploy job ([#12803](https://github.com/RocketChat/Rocket.Chat/pull/12803)) +- Convert rocketchat-retention-policy to main module structure ([#12797](https://github.com/RocketChat/Rocket.Chat/pull/12797)) +- Convert rocketchat-push-notifications to main module structure ([#12778](https://github.com/RocketChat/Rocket.Chat/pull/12778)) +- Convert rocketchat-otr to main module structure ([#12777](https://github.com/RocketChat/Rocket.Chat/pull/12777)) +- Convert rocketchat-oembed to main module structure ([#12775](https://github.com/RocketChat/Rocket.Chat/pull/12775)) +- Convert rocketchat-migrations to main-module structure ([#12772](https://github.com/RocketChat/Rocket.Chat/pull/12772)) +- Convert rocketchat-message-mark-as-unread to main module structure ([#12766](https://github.com/RocketChat/Rocket.Chat/pull/12766)) +- Remove conventional changelog cli, we are using our own cli now (Houston) ([#12798](https://github.com/RocketChat/Rocket.Chat/pull/12798)) +- Convert rocketchat-message-attachments to main module structure ([#12760](https://github.com/RocketChat/Rocket.Chat/pull/12760)) +- Convert rocketchat-message-action to main module structure ([#12759](https://github.com/RocketChat/Rocket.Chat/pull/12759)) +- Convert rocketchat-mentions-flextab to main module structure ([#12757](https://github.com/RocketChat/Rocket.Chat/pull/12757)) +- Convert rocketchat-mentions to main module structure ([#12756](https://github.com/RocketChat/Rocket.Chat/pull/12756)) +- Convert rocketchat-markdown to main module structure ([#12755](https://github.com/RocketChat/Rocket.Chat/pull/12755)) +- Convert rocketchat-mapview to main module structure ([#12701](https://github.com/RocketChat/Rocket.Chat/pull/12701)) +- Add check to make sure releases was updated ([#12791](https://github.com/RocketChat/Rocket.Chat/pull/12791)) +- Merge master into develop & Set version to 0.73.0-develop ([#12776](https://github.com/RocketChat/Rocket.Chat/pull/12776)) +- Change `chat.getDeletedMessages` to get messages after informed date and return only message's _id ([#13021](https://github.com/RocketChat/Rocket.Chat/pull/13021)) +- Improve Importer code quality ([#13020](https://github.com/RocketChat/Rocket.Chat/pull/13020)) +- Regression: List of custom emojis wasn't working ([#13031](https://github.com/RocketChat/Rocket.Chat/pull/13031)) + +
+ +### 👩‍💻👨‍💻 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) +- [@karlprieb](https://github.com/karlprieb) +- [@localguru](https://github.com/localguru) +- [@lvyue](https://github.com/lvyue) +- [@mathysie](https://github.com/mathysie) +- [@piotrkochan](https://github.com/piotrkochan) +- [@rssilva](https://github.com/rssilva) +- [@sanketsingh24](https://github.com/sanketsingh24) +- [@tsukiRep](https://github.com/tsukiRep) +- [@ura14h](https://github.com/ura14h) + +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@Hudell](https://github.com/Hudell) +- [@LuluGO](https://github.com/LuluGO) +- [@MarcosSpessatto](https://github.com/MarcosSpessatto) +- [@d-gubert](https://github.com/d-gubert) +- [@engelgabriel](https://github.com/engelgabriel) +- [@geekgonecrazy](https://github.com/geekgonecrazy) +- [@ggazzo](https://github.com/ggazzo) +- [@marceloschmidt](https://github.com/marceloschmidt) +- [@renatobecker](https://github.com/renatobecker) +- [@rodrigok](https://github.com/rodrigok) +- [@sampaiodiego](https://github.com/sampaiodiego) +- [@tassoevan](https://github.com/tassoevan) + +# 0.72.3 +`2018-12-12 · 1 🔍 · 5 👩‍💻👨‍💻` + +
+🔍 Minor changes + +- Release 0.72.3 ([#12932](https://github.com/RocketChat/Rocket.Chat/pull/12932) by [@piotrkochan](https://github.com/piotrkochan)) + +
+ +### 👩‍💻👨‍💻 Contributors 😍 + +- [@piotrkochan](https://github.com/piotrkochan) + +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@Hudell](https://github.com/Hudell) +- [@ggazzo](https://github.com/ggazzo) +- [@rodrigok](https://github.com/rodrigok) +- [@tassoevan](https://github.com/tassoevan) + +# 0.72.2 +`2018-12-10 · 2 🐛 · 1 🔍 · 2 👩‍💻👨‍💻` + +### 🐛 Bug fixes + +- line-height for unread bar buttons (jump to first and mark as read) ([#12900](https://github.com/RocketChat/Rocket.Chat/pull/12900)) +- PDF view loading indicator ([#12882](https://github.com/RocketChat/Rocket.Chat/pull/12882)) + +
+🔍 Minor changes + +- Release 0.72.2 ([#12901](https://github.com/RocketChat/Rocket.Chat/pull/12901)) + +
+ +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@sampaiodiego](https://github.com/sampaiodiego) +- [@tassoevan](https://github.com/tassoevan) + +# 0.72.1 +`2018-12-05 · 4 🐛 · 3 🔍 · 8 👩‍💻👨‍💻` + +### 🐛 Bug fixes + +- Change spread operator to Array.from for Edge browser ([#12818](https://github.com/RocketChat/Rocket.Chat/pull/12818) by [@ohmonster](https://github.com/ohmonster)) +- API users.info returns caller rooms and not requested user ones ([#12727](https://github.com/RocketChat/Rocket.Chat/pull/12727) by [@piotrkochan](https://github.com/piotrkochan)) +- Missing HipChat Enterprise Importer ([#12847](https://github.com/RocketChat/Rocket.Chat/pull/12847)) +- Emoji as avatar ([#12805](https://github.com/RocketChat/Rocket.Chat/pull/12805)) + +
+🔍 Minor changes + +- Release 0.72.1 ([#12850](https://github.com/RocketChat/Rocket.Chat/pull/12850) by [@ohmonster](https://github.com/ohmonster) & [@piotrkochan](https://github.com/piotrkochan)) +- Bump Apps-Engine version ([#12848](https://github.com/RocketChat/Rocket.Chat/pull/12848)) +- Change file order in rocketchat-cors ([#12804](https://github.com/RocketChat/Rocket.Chat/pull/12804)) + +
+ +### 👩‍💻👨‍💻 Contributors 😍 + +- [@ohmonster](https://github.com/ohmonster) +- [@piotrkochan](https://github.com/piotrkochan) + +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@Hudell](https://github.com/Hudell) +- [@MarcosSpessatto](https://github.com/MarcosSpessatto) +- [@d-gubert](https://github.com/d-gubert) +- [@rodrigok](https://github.com/rodrigok) +- [@sampaiodiego](https://github.com/sampaiodiego) +- [@tassoevan](https://github.com/tassoevan) + # 0.72.0 -`2018-11-27 · 1 ️️️⚠️ · 6 🎉 · 16 🚀 · 22 🐛 · 79 🔍 · 25 👩‍💻👨‍💻` +`2018-11-28 · 1 ️️️⚠️ · 6 🎉 · 16 🚀 · 22 🐛 · 79 🔍 · 25 👩‍💻👨‍💻` ### ⚠️ BREAKING CHANGES @@ -10,7 +1031,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)) @@ -148,6 +1169,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) @@ -164,7 +1186,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) @@ -175,6 +1196,13 @@ - [@sampaiodiego](https://github.com/sampaiodiego) - [@tassoevan](https://github.com/tassoevan) +# 0.71.2 +`2018-12-10` + +### Engine versions +- Node: `8.11.3` +- NPM: `5.6.0` + # 0.71.1 `2018-10-31 · 1 🐛 · 1 🔍 · 1 👩‍💻👨‍💻` @@ -215,7 +1243,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 @@ -267,6 +1295,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) @@ -283,12 +1312,26 @@ - [@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) - [@tassoevan](https://github.com/tassoevan) +# 0.70.5 +`2018-12-10 · 1 🐛 · 1 👩‍💻👨‍💻` + +### Engine versions +- Node: `8.11.3` +- NPM: `5.6.0` + +### 🐛 Bug fixes + +- Reset password email ([#12898](https://github.com/RocketChat/Rocket.Chat/pull/12898)) + +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@sampaiodiego](https://github.com/sampaiodiego) + # 0.70.4 `2018-10-09 · 1 🐛 · 2 🔍 · 1 👩‍💻👨‍💻` @@ -358,7 +1401,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)) @@ -367,13 +1410,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) @@ -397,9 +1440,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)) @@ -458,13 +1501,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)) @@ -489,11 +1532,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) @@ -508,12 +1553,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) @@ -535,17 +1578,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) @@ -843,7 +1886,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)) @@ -896,6 +1939,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) @@ -907,7 +1951,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) @@ -1137,7 +2180,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)) @@ -1180,7 +2223,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)) @@ -1206,8 +2249,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) @@ -1239,9 +2284,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) @@ -1322,7 +2365,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 @@ -1346,7 +2389,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)) @@ -1370,6 +2413,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-) @@ -1385,7 +2429,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) @@ -1404,11 +2447,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)) @@ -1435,7 +2478,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)) @@ -1472,6 +2515,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) @@ -1482,7 +2526,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) @@ -1610,7 +2653,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)) @@ -1631,7 +2674,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)) @@ -1649,6 +2692,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) @@ -1668,7 +2712,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) @@ -3095,7 +4138,7 @@ - Fix room load on first hit ([#7687](https://github.com/RocketChat/Rocket.Chat/pull/7687)) - Markdown noopener/noreferrer: use correct HTML attribute ([#7644](https://github.com/RocketChat/Rocket.Chat/pull/7644) by [@jangmarker](https://github.com/jangmarker)) - Wrong email subject when "All Messages" setting enabled ([#7639](https://github.com/RocketChat/Rocket.Chat/pull/7639)) -- Csv importer: work with more problematic data ([#7456](https://github.com/RocketChat/Rocket.Chat/pull/7456) by [@reist](https://github.com/reist)) +- Csv importer: work with more problematic data ([#7456](https://github.com/RocketChat/Rocket.Chat/pull/7456)) - make flex-tab visible again when reduced width ([#7738](https://github.com/RocketChat/Rocket.Chat/pull/7738))
@@ -3138,7 +4181,6 @@ - [@jfchevrette](https://github.com/jfchevrette) - [@karlprieb](https://github.com/karlprieb) - [@lindoelio](https://github.com/lindoelio) -- [@reist](https://github.com/reist) - [@ruKurz](https://github.com/ruKurz) - [@ryoshimizu](https://github.com/ryoshimizu) - [@satyapramodh](https://github.com/satyapramodh) @@ -3157,6 +4199,7 @@ - [@ggazzo](https://github.com/ggazzo) - [@graywolf336](https://github.com/graywolf336) - [@pierreozoux](https://github.com/pierreozoux) +- [@reist](https://github.com/reist) - [@rodrigok](https://github.com/rodrigok) - [@sampaiodiego](https://github.com/sampaiodiego) @@ -3274,7 +4317,7 @@ - Migration to add tags to email header and footer ([#7080](https://github.com/RocketChat/Rocket.Chat/pull/7080)) - postcss parser and cssnext implementation ([#6982](https://github.com/RocketChat/Rocket.Chat/pull/6982)) - Start running unit tests ([#6605](https://github.com/RocketChat/Rocket.Chat/pull/6605)) -- Make channel/group delete call answer to roomName ([#6857](https://github.com/RocketChat/Rocket.Chat/pull/6857) by [@reist](https://github.com/reist)) +- Make channel/group delete call answer to roomName ([#6857](https://github.com/RocketChat/Rocket.Chat/pull/6857)) - Feature/delete any message permission ([#6919](https://github.com/RocketChat/Rocket.Chat/pull/6919) by [@phutchins](https://github.com/phutchins)) - Force use of MongoDB for spotlight queries ([#7311](https://github.com/RocketChat/Rocket.Chat/pull/7311)) @@ -3375,7 +4418,6 @@ - [@matthewshirley](https://github.com/matthewshirley) - [@phutchins](https://github.com/phutchins) - [@pmb0](https://github.com/pmb0) -- [@reist](https://github.com/reist) - [@sathieu](https://github.com/sathieu) - [@thinkeridea](https://github.com/thinkeridea) @@ -3389,6 +4431,7 @@ - [@ggazzo](https://github.com/ggazzo) - [@graywolf336](https://github.com/graywolf336) - [@marceloschmidt](https://github.com/marceloschmidt) +- [@reist](https://github.com/reist) - [@rodrigok](https://github.com/rodrigok) - [@sampaiodiego](https://github.com/sampaiodiego) @@ -3402,7 +4445,7 @@ ### 🎉 New features - Add a pointer cursor to message images ([#6881](https://github.com/RocketChat/Rocket.Chat/pull/6881)) -- Make channels.info accept roomName, just like groups.info ([#6827](https://github.com/RocketChat/Rocket.Chat/pull/6827) by [@reist](https://github.com/reist)) +- Make channels.info accept roomName, just like groups.info ([#6827](https://github.com/RocketChat/Rocket.Chat/pull/6827)) - Option to allow to signup as anonymous ([#6797](https://github.com/RocketChat/Rocket.Chat/pull/6797)) - create a method 'create token' ([#6807](https://github.com/RocketChat/Rocket.Chat/pull/6807)) - Add option on Channel Settings: Hide Notifications and Hide Unread Room Status (#2707, #2143) ([#5373](https://github.com/RocketChat/Rocket.Chat/pull/5373)) @@ -3426,14 +4469,14 @@ - Hides nav buttons when selecting own profile ([#6760](https://github.com/RocketChat/Rocket.Chat/pull/6760) by [@gdelavald](https://github.com/gdelavald)) - Search full name on client side ([#6767](https://github.com/RocketChat/Rocket.Chat/pull/6767)) - Sort by real name if use real name setting is enabled ([#6758](https://github.com/RocketChat/Rocket.Chat/pull/6758)) -- CSV importer: require that there is some data in the zip, not ALL data ([#6768](https://github.com/RocketChat/Rocket.Chat/pull/6768) by [@reist](https://github.com/reist)) +- CSV importer: require that there is some data in the zip, not ALL data ([#6768](https://github.com/RocketChat/Rocket.Chat/pull/6768)) - Archiving Direct Messages ([#6737](https://github.com/RocketChat/Rocket.Chat/pull/6737)) - Fix Caddy by forcing go 1.7 as needed by one of caddy's dependencies ([#6721](https://github.com/RocketChat/Rocket.Chat/pull/6721)) - Users status on main menu always offline ([#6896](https://github.com/RocketChat/Rocket.Chat/pull/6896)) - Not showing unread count on electron app’s icon ([#6923](https://github.com/RocketChat/Rocket.Chat/pull/6923)) - Compile CSS color variables ([#6939](https://github.com/RocketChat/Rocket.Chat/pull/6939)) - Remove spaces from env PORT and INSTANCE_IP ([#6955](https://github.com/RocketChat/Rocket.Chat/pull/6955)) -- make channels.create API check for create-c ([#6968](https://github.com/RocketChat/Rocket.Chat/pull/6968) by [@reist](https://github.com/reist)) +- make channels.create API check for create-c ([#6968](https://github.com/RocketChat/Rocket.Chat/pull/6968))
🔍 Minor changes @@ -3468,7 +4511,6 @@ - [@glehmann](https://github.com/glehmann) - [@intelradoux](https://github.com/intelradoux) - [@karlprieb](https://github.com/karlprieb) -- [@reist](https://github.com/reist) - [@robertdown](https://github.com/robertdown) - [@sscholl](https://github.com/sscholl) - [@vlogic](https://github.com/vlogic) @@ -3482,6 +4524,7 @@ - [@ggazzo](https://github.com/ggazzo) - [@graywolf336](https://github.com/graywolf336) - [@marceloschmidt](https://github.com/marceloschmidt) +- [@reist](https://github.com/reist) - [@rodrigok](https://github.com/rodrigok) - [@sampaiodiego](https://github.com/sampaiodiego) diff --git a/LIMITATION_OF_RESPONSIBILITY.md b/LIMITATION_OF_RESPONSIBILITY.md index f451e30c7128b..531868d2c0d38 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 bb3bcee0e0d3e..9936543821290 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) @@ -82,7 +81,7 @@ Join thousands of members worldwide 24/7 in our [community server](https://open. [![Rocket.Chat](https://open.rocket.chat/api/v1/shield.svg?type=channel&name=Rocket.Chat&channel=dev)](https://open.rocket.chat/channel/dev) for developers needing help from the community to developing new features. -You can also join the conversation at [Twitter](https://twitter.com/RocketChat), [Facebook](https://www.facebook.com/RocketChatApp) or [Google Plus](https://plus.google.com/+RocketChatApp). +You can also join the conversation at [Twitter](https://twitter.com/RocketChat) and [Facebook](https://www.facebook.com/RocketChatApp). # Desktop Apps Download the Native Cross-Platform Desktop Application at [Rocket.Chat.Electron](https://github.com/RocketChat/Rocket.Chat.Electron/releases) @@ -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 @@ -414,6 +407,13 @@ meteor npm install meteor npm start ``` +In order to debug the server part use [meteor debugging](https://docs.meteor.com/commandline.html#meteordebug). You should use Chrome for best debugging experience: + +```sh +meteor debug +``` +You'll find a nodejs icon in the developer console. + If you are not a developer and just want to run the server - see [deployment methods](https://rocket.chat/docs/installation/paas-deployments/). ## Branching Model @@ -430,7 +430,7 @@ If you want to help, send an email to support at rocket.chat to be invited to th ## How to Contribute -Already a JavaScript developer? Familiar with Meteor? [Pick an issue](https://github.com/RocketChat/Rocket.Chat/labels/contrib%3A%20easy), push a PR and instantly become a member of Rocket.Chat's international contributors community. +Already a JavaScript developer? Familiar with Meteor? [Pick an issue](https://github.com/RocketChat/Rocket.Chat/labels/contrib%3A%20easy), push a PR and instantly become a member of Rocket.Chat's international contributors community. For more information, check out our [Contributing Guide](.github/CONTRIBUTING.md) and our [Official Documentation for Contributors](https://rocket.chat/docs/contributing/). A lot of work has already gone into Rocket.Chat, but we have much bigger plans for it! @@ -450,9 +450,9 @@ Thanks to our core team [Sing Li](https://github.com/Sing-Li), and to hundreds of awesome [contributors](https://github.com/RocketChat/Rocket.Chat/graphs/contributors). -![Emoji One](https://cloud.githubusercontent.com/assets/1986378/24772858/47290a70-1ae9-11e7-9a5a-2913d0002c94.png) +![JoyPixels](https://i.imgur.com/OrhYvLe.png) -Emoji provided free by [Emoji One](http://emojione.com) +Emoji provided graciously by [JoyPixels](https://www.joypixels.com/) ![BrowserStack](https://cloud.githubusercontent.com/assets/1986378/24772879/57d57b88-1ae9-11e7-98b4-4af824b47933.png) @@ -464,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 95% rename from packages/rocketchat-2fa/client/TOTPPassword.js rename to app/2fa/client/TOTPPassword.js index 12018bca2d7de..7a26863faadfe 100644 --- a/packages/rocketchat-2fa/client/TOTPPassword.js +++ b/app/2fa/client/TOTPPassword.js @@ -1,8 +1,10 @@ import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; -import { t, modal } from 'meteor/rocketchat:ui'; import toastr from 'toastr'; +import { modal } from '../../ui-utils'; +import { t } from '../../utils'; + function reportError(error, callback) { if (callback) { callback(error); 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 95% rename from packages/rocketchat-2fa/client/accountSecurity.js rename to app/2fa/client/accountSecurity.js index 76d1da0ca2902..027da271a65ba 100644 --- a/packages/rocketchat-2fa/client/accountSecurity.js +++ b/app/2fa/client/accountSecurity.js @@ -1,11 +1,13 @@ import { Meteor } from 'meteor/meteor'; import { ReactiveVar } from 'meteor/reactive-var'; import { Template } from 'meteor/templating'; -import { t, modal } from 'meteor/rocketchat:ui'; -import { RocketChat } from 'meteor/rocketchat:lib'; import toastr from 'toastr'; import qrcode from 'yaqrcode'; +import { modal } from '../../ui-utils'; +import { settings } from '../../settings'; +import { t } from '../../utils'; + window.qrcode = qrcode; Template.accountSecurity.helpers({ @@ -26,7 +28,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 0000000000000..e5d5ab3fc4451 --- /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 8d794d9ec3598..fcdf46f3bbdb4 100644 --- a/packages/rocketchat-2fa/server/lib/totp.js +++ b/app/2fa/server/lib/totp.js @@ -1,9 +1,11 @@ import { SHA256 } from 'meteor/sha'; import { Random } from 'meteor/random'; -import { RocketChat } from 'meteor/rocketchat:lib'; import speakeasy from 'speakeasy'; -RocketChat.TOTP = { +import { Users } from '../../../models'; +import { settings } from '../../../settings'; + +export const TOTP = { generateSecret() { return speakeasy.generateSecret(); }, @@ -25,14 +27,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 0000000000000..3afc20b6cf21a --- /dev/null +++ b/app/2fa/server/loginHandler.js @@ -0,0 +1,39 @@ +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; + +import { TOTP } from './lib/totp'; +import { settings } from '../../settings'; +import { callbacks } from '../../callbacks'; + +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 0000000000000..fe6e554305dd1 --- /dev/null +++ b/app/2fa/server/methods/disable.js @@ -0,0 +1,27 @@ +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 0000000000000..ef34662436e60 --- /dev/null +++ b/app/2fa/server/methods/enable.js @@ -0,0 +1,23 @@ +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 0000000000000..bfdc8d955d784 --- /dev/null +++ b/app/2fa/server/methods/regenerateCodes.js @@ -0,0 +1,32 @@ +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 0000000000000..71565b0d42e3c --- /dev/null +++ b/app/2fa/server/methods/validateTempToken.js @@ -0,0 +1,30 @@ +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 0000000000000..51ccf2ac68f30 --- /dev/null +++ b/app/2fa/server/startup/settings.js @@ -0,0 +1,19 @@ +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 0000000000000..ca39cd0df4b1a --- /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 0000000000000..c87c712e079b4 --- /dev/null +++ b/app/action-links/both/lib/actionLinks.js @@ -0,0 +1,36 @@ +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 0000000000000..f49166a5c811d --- /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 0000000000000..2865be4279c9c --- /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 0000000000000..4391eda94afb5 --- /dev/null +++ b/app/action-links/client/lib/actionLinks.js @@ -0,0 +1,27 @@ +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 0000000000000..a67eca871efbb --- /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 0000000000000..067f727e3dda2 --- /dev/null +++ b/app/action-links/server/actionLinkHandler.js @@ -0,0 +1,18 @@ +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 0000000000000..b1c484f79888a --- /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 19c7f95b84139..50d25859814f8 100644 --- a/packages/rocketchat-analytics/client/loadScript.js +++ b/app/analytics/client/loadScript.js @@ -1,17 +1,18 @@ 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 0000000000000..cc86418dca625 --- /dev/null +++ b/app/analytics/client/trackEvents.js @@ -0,0 +1,151 @@ +import { Meteor } from 'meteor/meteor'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { Tracker } from 'meteor/tracker'; + +import { settings } from '../../settings'; +import { callbacks } from '../../callbacks'; +import { ChatRoom } from '../../models'; + +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 0000000000000..932e834cdb6e2 --- /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 0000000000000..ca39cd0df4b1a --- /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 0000000000000..6f24dcc99e97a --- /dev/null +++ b/app/api/server/api.js @@ -0,0 +1,561 @@ +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 { RateLimiter } from 'meteor/rate-limit'; +import _ from 'underscore'; + +import { Logger } from '../../logger'; +import { settings } from '../../settings'; +import { metrics } from '../../metrics'; +import { hasPermission, hasAllPermission } from '../../authorization'; +import { getDefaultUserFields } from '../../utils/server/functions/getDefaultUserFields'; + + +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 || 'Resource not found', + }, + }; + } + + unauthorized(msg) { + return { + statusCode: 403, + body: { + success: false, + error: msg || 'unauthorized', + }, + }; + } + + tooManyRequests(msg) { + return { + statusCode: 429, + body: { + success: false, + error: 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, + }, { + fields: getDefaultUserFields(), + }); + + 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 0000000000000..7c397de09cb1f --- /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 0000000000000..8822e6d575ccd --- /dev/null +++ b/app/api/server/helpers/composeRoomWithLastMessage.js @@ -0,0 +1,10 @@ +import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; +import { API } from '../api'; + +API.helperMethods.set('composeRoomWithLastMessage', function _composeRoomWithLastMessage(room, userId) { + if (room.lastMessage) { + const [lastMessage] = normalizeMessagesForUser([room.lastMessage], userId); + room.lastMessage = lastMessage; + } + return room; +}); diff --git a/app/api/server/helpers/deprecationWarning.js b/app/api/server/helpers/deprecationWarning.js new file mode 100644 index 0000000000000..fdcc98f4b1d2c --- /dev/null +++ b/app/api/server/helpers/deprecationWarning.js @@ -0,0 +1,14 @@ +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 0000000000000..1ce74a93e2fcd --- /dev/null +++ b/app/api/server/helpers/getLoggedInUser.js @@ -0,0 +1,17 @@ +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 0000000000000..93a19b2cbf9fc --- /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 0000000000000..c97d6f34e1e91 --- /dev/null +++ b/app/api/server/helpers/getUserFromParams.js @@ -0,0 +1,27 @@ +// 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.findOneByUsernameIgnoringCase(params.username) || doesntExist; + } else if (params.user && params.user.trim()) { + user = Users.findOneByUsernameIgnoringCase(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 0000000000000..2d2daee1af344 --- /dev/null +++ b/app/api/server/helpers/getUserInfo.js @@ -0,0 +1,37 @@ +import { settings } from '../../../settings/server'; +import { getUserPreference, getURL } from '../../../utils/server'; +import { API } from '../api'; + +API.helperMethods.set('getUserInfo', function _getUserInfo(me) { + 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(me, settingWithoutPrefix); + return accumulator; + }, {}); + }; + const verifiedEmail = isVerifiedEmail(); + me.email = verifiedEmail ? verifiedEmail.address : undefined; + + me.avatarUrl = getURL(`/avatar/${ me.username }`, { cdn: false, full: true }); + + const userPreferences = (me.settings && me.settings.preferences) || {}; + + me.settings = { + preferences: { + ...getUserPreferences(), + ...userPreferences, + }, + }; + + return me; +}); diff --git a/app/api/server/helpers/insertUserObject.js b/app/api/server/helpers/insertUserObject.js new file mode 100644 index 0000000000000..f6674720c0616 --- /dev/null +++ b/app/api/server/helpers/insertUserObject.js @@ -0,0 +1,17 @@ +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 0000000000000..605f598bd43d1 --- /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 0000000000000..d7eafe0f6da8c --- /dev/null +++ b/app/api/server/helpers/parseJsonQuery.js @@ -0,0 +1,86 @@ +import { Meteor } from 'meteor/meteor'; +import { EJSON } from 'meteor/ejson'; + +import { hasPermission } from '../../../authorization'; +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 0000000000000..2883c94a727e7 --- /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 0000000000000..da65a35e8502f --- /dev/null +++ b/app/api/server/index.js @@ -0,0 +1,34 @@ +import './settings'; +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'; + +export { API } from './api'; diff --git a/app/api/server/settings.js b/app/api/server/settings.js new file mode 100644 index 0000000000000..afc90469617d4 --- /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 0000000000000..eacf92ae31cd7 --- /dev/null +++ b/app/api/server/v1/assets.js @@ -0,0 +1,57 @@ +import { Meteor } from 'meteor/meteor'; +import Busboy from 'busboy'; + +import { RocketChatAssets } from '../../../assets'; +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 0000000000000..4bb2f1ada21b4 --- /dev/null +++ b/app/api/server/v1/channels.js @@ -0,0 +1,1009 @@ +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { Rooms, Subscriptions, Messages, Uploads, Integrations, Users } from '../../../models'; +import { hasPermission } from '../../../authorization'; +import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; +import { API } from '../api'; + +// 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) { + const [lastMessage] = normalizeMessagesForUser([room.lastMessage], userId); + room.lastMessage = lastMessage; + } + + 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 || { 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 || { _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 || { 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 || { 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 || { ts: -1 }, + skip: offset, + limit: count, + fields, + }); + + const total = cursor.count(); + const messages = cursor.fetch(); + + return API.v1.success({ + messages: normalizeMessagesForUser(messages, 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.hasOwnProperty('description')) { + 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.hasOwnProperty('purpose')) { + 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.hasOwnProperty('topic')) { + 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.hasOwnProperty('announcement')) { + 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 || { 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 0000000000000..733b9614da511 --- /dev/null +++ b/app/api/server/v1/chat.js @@ -0,0 +1,570 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; + +import { Messages } from '../../../models'; +import { canAccessRoom, hasPermission } from '../../../authorization'; +import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; +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: normalizeMessagesForUser(result.updated, this.userId), + deleted: normalizeMessagesForUser(result.deleted, 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(); + } + + const [message] = normalizeMessagesForUser([msg], this.userId); + + return API.v1.success({ + message, + }); + }, +}); + +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); }); + + const [message] = normalizeMessagesForUser([pinnedMessage], this.userId); + + return API.v1.success({ + message, + }); + }, +}); + +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'); + } + + const [message] = normalizeMessagesForUser([messageReturn.message], this.userId); + + return API.v1.success({ + ts: Date.now(), + channel: messageReturn.channel, + message, + }); + }, +}); + +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: normalizeMessagesForUser(result, this.userId), + }); + }, +}); + +// The difference between `chat.postMessage` and `chat.sendMessage` is that `chat.sendMessage` allows +// for passing a value for `_id` and the other one doesn't. Also, `chat.sendMessage` only sends it to +// one channel whereas the other one allows for sending to more than one channel at a time. +API.v1.addRoute('chat.sendMessage', { authRequired: true }, { + post() { + if (!this.bodyParams.message) { + throw new Meteor.Error('error-invalid-params', 'The "message" parameter must be provided.'); + } + + const sent = Meteor.runAsUser(this.userId, () => Meteor.call('sendMessage', this.bodyParams.message)); + + const [message] = normalizeMessagesForUser([sent], this.userId); + + return API.v1.success({ + message, + }); + }, +}); + +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 }); + }); + + const [message] = normalizeMessagesForUser([Messages.findOneById(msg._id)], this.userId); + + return API.v1.success({ + message, + }); + }, +}); + +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 || { 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 || { 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 0000000000000..80e75fa2b6f99 --- /dev/null +++ b/app/api/server/v1/commands.js @@ -0,0 +1,171 @@ +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 || { 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 0000000000000..f7a6f49108a69 --- /dev/null +++ b/app/api/server/v1/e2e.js @@ -0,0 +1,62 @@ +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 0000000000000..f638f025b6938 --- /dev/null +++ b/app/api/server/v1/emoji-custom.js @@ -0,0 +1,159 @@ +import { Meteor } from 'meteor/meteor'; +import Busboy from 'busboy'; + +import { EmojiCustom } from '../../../models'; +import { API } from '../api'; + +// 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 0000000000000..32cd1609bd7fa --- /dev/null +++ b/app/api/server/v1/groups.js @@ -0,0 +1,815 @@ +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 { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; +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 || { 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 || { _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 || { 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 || { 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 || { ts: -1 }, + skip: offset, + limit: count, + fields, + }).fetch(); + + return API.v1.success({ + messages: normalizeMessagesForUser(messages, 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.hasOwnProperty('description')) { + 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.hasOwnProperty('purpose')) { + 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.hasOwnProperty('topic')) { + 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.hasOwnProperty('announcement')) { + 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 0000000000000..13502c083a641 --- /dev/null +++ b/app/api/server/v1/im.js @@ -0,0 +1,369 @@ +import { Meteor } from 'meteor/meteor'; + +import { getRoomByNameOrIdWithOptionToJoin } from '../../../lib'; +import { Subscriptions, Uploads, Users, Messages, Rooms } from '../../../models'; +import { hasPermission } from '../../../authorization'; +import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; +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 || { 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 || { ts: -1 }, + skip: offset, + limit: count, + fields, + }).fetch(); + + return API.v1.success({ + messages: normalizeMessagesForUser(messages, 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 || { ts: -1 }, + skip: offset, + limit: count, + fields, + }).fetch(); + + return API.v1.success({ + messages: normalizeMessagesForUser(msgs, 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 || { 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.hasOwnProperty('topic')) { + 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 0000000000000..0ee09ae5789ca --- /dev/null +++ b/app/api/server/v1/import.js @@ -0,0 +1,51 @@ +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 0000000000000..b4e69cbf1489f --- /dev/null +++ b/app/api/server/v1/integrations.js @@ -0,0 +1,156 @@ +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 || { _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 || { 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 0000000000000..92681ed0de2e3 --- /dev/null +++ b/app/api/server/v1/misc.js @@ -0,0 +1,204 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { TAPi18n } from 'meteor/tap:i18n'; +import s from 'underscore.string'; + +import { hasRole } from '../../../authorization'; +import { Info } from '../../../utils'; +import { Users } from '../../../models'; +import { settings } from '../../../settings'; +import { API } from '../api'; +import { getDefaultUserFields } from '../../../utils/server/functions/getDefaultUserFields'; + + +// 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, { fields: getDefaultUserFields() }))); + }, +}); + +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 0000000000000..c5d790533287f --- /dev/null +++ b/app/api/server/v1/permissions.js @@ -0,0 +1,120 @@ +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'); + } 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/app/api/server/v1/push.js b/app/api/server/v1/push.js new file mode 100644 index 0000000000000..6c1c9959011a9 --- /dev/null +++ b/app/api/server/v1/push.js @@ -0,0 +1,65 @@ +import { Meteor } from 'meteor/meteor'; +import { Random } from 'meteor/random'; +import { Push } from 'meteor/rocketchat:push'; + +import { API } from '../api'; + +API.v1.addRoute('push.token', { authRequired: true }, { + post() { + const { type, value, appName } = this.bodyParams; + let { id } = this.bodyParams; + + if (id && typeof id !== 'string') { + throw new Meteor.Error('error-id-param-not-valid', 'The required "id" body param is invalid.'); + } else { + id = Random.id(); + } + + if (!type || (type !== 'apn' && type !== 'gcm')) { + throw new Meteor.Error('error-type-param-not-valid', 'The required "type" body param is missing or invalid.'); + } + + if (!value || typeof value !== 'string') { + throw new Meteor.Error('error-token-param-not-valid', 'The required "value" body param is missing or invalid.'); + } + + if (!appName || typeof appName !== 'string') { + throw new Meteor.Error('error-appName-param-not-valid', 'The required "appName" body param is missing or invalid.'); + } + + + let result; + Meteor.runAsUser(this.userId, () => { + result = Meteor.call('raix:push-update', { + id, + token: { [type]: value }, + appName, + userId: this.userId, + }); + }); + + return API.v1.success({ result }); + }, + delete() { + const { token } = this.bodyParams; + + if (!token || typeof token !== 'string') { + throw new Meteor.Error('error-token-param-not-valid', 'The required "token" body param is missing or invalid.'); + } + + const affectedRecords = Push.appCollection.remove({ + $or: [{ + 'token.apn': token, + }, { + 'token.gcm': token, + }], + userId: this.userId, + }); + + if (affectedRecords === 0) { + return API.v1.notFound(); + } + + 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 0000000000000..67a992427a619 --- /dev/null +++ b/app/api/server/v1/roles.js @@ -0,0 +1,57 @@ +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 0000000000000..7cab7a10977b8 --- /dev/null +++ b/app/api/server/v1/rooms.js @@ -0,0 +1,272 @@ +import { Meteor } from 'meteor/meteor'; +import Busboy from 'busboy'; + +import { FileUpload } from '../../../file-upload'; +import { Rooms } from '../../../models'; +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 || { 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 0000000000000..1fd8dba80c0b1 --- /dev/null +++ b/app/api/server/v1/settings.js @@ -0,0 +1,142 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; +import { ServiceConfiguration } from 'meteor/service-configuration'; +import _ from 'underscore'; + +import { Settings } from '../../../models'; +import { hasPermission } from '../../../authorization'; +import { API } from '../api'; + +// 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 || { _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 || { _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 0000000000000..2f74a2092947e --- /dev/null +++ b/app/api/server/v1/stats.js @@ -0,0 +1,48 @@ +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 || { 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 0000000000000..8ffa8711a335a --- /dev/null +++ b/app/api/server/v1/subscriptions.js @@ -0,0 +1,85 @@ +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 0000000000000..1cf3926f035df --- /dev/null +++ b/app/api/server/v1/users.js @@ -0,0 +1,604 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; +import { TAPi18n } from 'meteor/tap:i18n'; +import _ from 'underscore'; +import Busboy from 'busboy'; + +import { Users, Subscriptions } from '../../../models/server'; +import { hasPermission } from '../../../authorization'; +import { settings } from '../../../settings'; +import { getURL } from '../../../utils'; +import { + validateCustomFields, + saveUser, + saveCustomFieldsWithoutValidation, + checkUsernameAvailability, + setUserAvatar, + saveCustomFields, +} from '../../../lib'; +import { getFullUserData } from '../../../lib/server/functions/getFullUserData'; +import { API } from '../api'; + +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(); + + const result = getFullUserData({ + userId: this.userId, + filter: username, + limit: 1, + }); + if (!result || result.count() !== 1) { + return API.v1.failure(`Failed to get the user data for the userId of "${ this.userId }".`); + } + const [user] = result.fetch(); + const myself = user._id === this.userId; + if (fields.userRooms === 1 && (myself || 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 || { 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.findOneByUsernameIgnoringCase(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, + }); + } + 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), + sidebarShowDiscussion: 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(); + }, +}); + +API.v1.addRoute('users.presence', { authRequired: true }, { + get() { + const { from } = this.queryParams; + + const options = { + fields: { + username: 1, + name: 1, + status: 1, + utcOffset: 1, + }, + }; + + if (from) { + const ts = new Date(from); + const diff = (Date.now() - ts) / 1000 / 60; + + if (diff < 10) { + return API.v1.success({ + users: Users.findNotIdUpdatedFrom(this.userId, ts, options).fetch(), + full: false, + }); + } + } + + return API.v1.success({ + users: Users.findUsersNotOffline(options).fetch(), + full: true, + }); + }, +}); diff --git a/app/api/server/v1/video-conference.js b/app/api/server/v1/video-conference.js new file mode 100644 index 0000000000000..51d0b8830499f --- /dev/null +++ b/app/api/server/v1/video-conference.js @@ -0,0 +1,22 @@ +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 0000000000000..b8a9652ea4e80 --- /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/app/apps/client/admin/appInstall.html b/app/apps/client/admin/appInstall.html new file mode 100644 index 0000000000000..86fffb019c877 --- /dev/null +++ b/app/apps/client/admin/appInstall.html @@ -0,0 +1,66 @@ + 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 f5e15623ef51a..8ff298c54febd 100644 --- a/packages/rocketchat-apps/client/admin/appInstall.js +++ b/app/apps/client/admin/appInstall.js @@ -10,6 +10,10 @@ 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 +76,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 +119,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 +138,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 0000000000000..47e901331c899 --- /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 0000000000000..bcd621370cde2 --- /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 moment from 'moment'; +import hljs from 'highlight.js'; + +import { APIClient } from '../../../utils'; +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; + } 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 94% rename from packages/rocketchat-apps/client/admin/appManage.html rename to app/apps/client/admin/appManage.html index 292a0d740d437..a1f8aa6597699 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}}
@@ -74,7 +89,11 @@

{{_ "Author_Site"}}

{{#if author.support}}

{{_ "Support"}}

- {{author.support}} + {{#if isEmail author.support}} + {{author.support}} + {{else}} + {{author.support}} + {{/if}} {{/if}}
@@ -275,8 +294,8 @@

{{_ "Settings"}}

{{> CodeMirror name=id options=getEditorOptions code=value }}
- - + +
{{/if}} @@ -450,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 0000000000000..286dd35e4ab24 --- /dev/null +++ b/app/apps/client/admin/appManage.js @@ -0,0 +1,516 @@ +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 _ from 'underscore'; +import s from 'underscore.string'; +import toastr from 'toastr'; +import semver from 'semver'; + +import { isEmail, APIClient } from '../../../utils'; +import { settings } from '../../../settings'; +import { Markdown } from '../../../markdown/client'; +import { modal } from '../../../ui-utils'; +import { AppEvents } from '../communication'; +import { Utilities } from '../../lib/misc/Utilities'; +import { Apps } from '../orchestrator'; +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 new Error('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 = setting.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 75% rename from packages/rocketchat-apps/client/admin/appWhatIsIt.js rename to app/apps/client/admin/appWhatIsIt.js index 5da60b4b05b9c..dd63716de0f50 100644 --- a/packages/rocketchat-apps/client/admin/appWhatIsIt.js +++ b/app/apps/client/admin/appWhatIsIt.js @@ -2,6 +2,10 @@ 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 +40,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 0000000000000..96f0f9b699bd2 --- /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 0000000000000..683df77ef6a65 --- /dev/null +++ b/app/apps/client/admin/apps.js @@ -0,0 +1,206 @@ +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 0000000000000..417f825e418a0 --- /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 0000000000000..e06de068ab224 --- /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; + } + + // 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 0000000000000..6902d2cd0db62 --- /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 0000000000000..bc1029a56f7d7 --- /dev/null +++ b/app/apps/client/admin/modalTemplates/iframeModal.js @@ -0,0 +1,49 @@ +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 a43c8312c62a1..1da0efecccf2f 100644 --- a/packages/rocketchat-apps/client/communication/websockets.js +++ b/app/apps/client/communication/websockets.js @@ -1,5 +1,8 @@ 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', APP_REMOVED: 'app/removed', @@ -17,7 +20,7 @@ export class AppWebsocketReceiver { this.orch = orch; this.streamer = new Meteor.Streamer('apps'); - RocketChat.CachedCollectionManager.onLogin(() => { + CachedCollectionManager.onLogin(() => { this.listenStreamerEvents(); }); @@ -49,7 +52,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 +76,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 0000000000000..4ee0430ecc5fa --- /dev/null +++ b/app/apps/client/index.js @@ -0,0 +1,16 @@ +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'; + +export { Apps } from './orchestrator'; diff --git a/app/apps/client/orchestrator.js b/app/apps/client/orchestrator.js new file mode 100644 index 0000000000000..0ade72a4811a1 --- /dev/null +++ b/app/apps/client/orchestrator.js @@ -0,0 +1,190 @@ +import { Meteor } from 'meteor/meteor'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { BlazeLayout } from 'meteor/kadira:blaze-layout'; +import { TAPi18next } from 'meteor/tap:i18n'; + +import { AppWebsocketReceiver } from './communication'; +import { Utilities } from '../lib/misc/Utilities'; +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 0000000000000..0bba2c6460812 --- /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 0000000000000..47801c7d644ae --- /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 f79a3b54cb215..5781842fb766d 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 0000000000000..827c2606d2c0a --- /dev/null +++ b/app/apps/server/bridges/commands.js @@ -0,0 +1,173 @@ +import { Meteor } from 'meteor/meteor'; +import { SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands'; + +import { slashCommands } from '../../../utils'; +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 0000000000000..28d8b3654eb8e --- /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 0000000000000..743343fa12c96 --- /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 0000000000000..2a41c5829897c --- /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/app/apps/server/bridges/listeners.js b/app/apps/server/bridges/listeners.js new file mode 100644 index 0000000000000..26f0b71630895 --- /dev/null +++ b/app/apps/server/bridges/listeners.js @@ -0,0 +1,39 @@ +export class AppListenerBridge { + constructor(orch) { + this.orch = orch; + } + + async messageEvent(inte, message) { + const msg = this.orch.getConverters().get('messages').convertMessage(message); + const result = await this.orch.getManager().getListenerManager().executeListener(inte, msg); + + if (typeof result === 'boolean') { + return result; + } + return this.orch.getConverters().get('messages').convertAppMessage(result); + + // try { + + // } catch (e) { + // this.orch.debugLog(`${ e.name }: ${ e.message }`); + // this.orch.debugLog(e.stack); + // } + } + + async roomEvent(inte, room) { + const rm = this.orch.getConverters().get('rooms').convertRoom(room); + const result = await this.orch.getManager().getListenerManager().executeListener(inte, rm); + + if (typeof result === 'boolean') { + return result; + } + return this.orch.getConverters().get('rooms').convertAppRoom(result); + + // try { + + // } catch (e) { + // 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 0000000000000..062efcd64b8af --- /dev/null +++ b/app/apps/server/bridges/messages.js @@ -0,0 +1,84 @@ +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 0000000000000..47a6f2639e237 --- /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 0000000000000..250f25aa22c30 --- /dev/null +++ b/app/apps/server/bridges/rooms.js @@ -0,0 +1,124 @@ +import { Meteor } from 'meteor/meteor'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; + +import { Rooms, Subscriptions, Users } from '../../../models'; +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 0000000000000..ca01afacbbb7a --- /dev/null +++ b/app/apps/server/bridges/settings.js @@ -0,0 +1,58 @@ +import { Settings } from '../../../models'; + +export class AppSettingBridge { + constructor(orch) { + this.orch = orch; + this.allowedGroups = []; + } + + async getAll(appId) { + this.orch.debugLog(`The App ${ appId } is getting all the settings.`); + + return Settings.find({ secret: false }) + .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 !Settings.findOneById(id).secret; + } + + 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 0000000000000..dae08b2d02d9e --- /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 0000000000000..16170105caa61 --- /dev/null +++ b/app/apps/server/communication/methods.js @@ -0,0 +1,94 @@ +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 0000000000000..0bd7e1efff614 --- /dev/null +++ b/app/apps/server/communication/rest.js @@ -0,0 +1,493 @@ +import { Meteor } from 'meteor/meteor'; +import { HTTP } from 'meteor/http'; +import Busboy from 'busboy'; + +import { API } from '../../../api/server'; +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 }); + } + 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 }); + } + 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 }); + } + 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 }); + } + 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 || { _updatedAt: -1 }, + skip: offset, + limit: count, + fields, + }; + + const logs = Promise.await(orchestrator.getLogStorage().find(ourQuery, options)); + + return API.v1.success({ logs }); + } + 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 }); + } + 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 }"`); + } if (e.message.includes('No App found')) { + return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`); + } + 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 }"`); + } if (e.message.includes('No App found')) { + return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`); + } + 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), + }); + } + 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() }); + } + 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() }); + } + 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 0000000000000..2abaedd58b92c --- /dev/null +++ b/app/apps/server/converters/messages.js @@ -0,0 +1,238 @@ +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 0000000000000..59c16e40ba91b --- /dev/null +++ b/app/apps/server/converters/rooms.js @@ -0,0 +1,95 @@ +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; + +import { Rooms, Users } from '../../../models'; + +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 0000000000000..82ffcd2b2f0f1 --- /dev/null +++ b/app/apps/server/converters/settings.js @@ -0,0 +1,53 @@ +import { SettingType } from '@rocket.chat/apps-engine/definition/settings'; + +import { Settings } from '../../../models'; + +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 0000000000000..bccbfa0fce53c --- /dev/null +++ b/app/apps/server/converters/users.js @@ -0,0 +1,80 @@ +import { UserStatusConnection, UserType } from '@rocket.chat/apps-engine/definition/users'; + +import { Users } from '../../../models'; + +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 0000000000000..0d8b925c2207f --- /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 0000000000000..60b0f874de610 --- /dev/null +++ b/app/apps/server/orchestrator.js @@ -0,0 +1,161 @@ +import { Meteor } from 'meteor/meteor'; +import { AppManager } from '@rocket.chat/apps-engine/server/AppManager'; + +import { RealAppBridges } from './bridges'; +import { AppMethods, AppsRestApi, AppServerNotifier } from './communication'; +import { AppMessagesConverter, AppRoomsConverter, AppSettingsConverter, AppUsersConverter } from './converters'; +import { AppRealStorage, AppRealLogsStorage } from './storage'; +import { settings } from '../../settings'; +import { Permissions, AppsLogsModel, AppsModel, AppsPersistenceModel } from '../../models'; + +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 0000000000000..fd1680c1c9c31 --- /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 0000000000000..9dee0c68bcdae --- /dev/null +++ b/app/apps/server/tests/messages.tests.js @@ -0,0 +1,153 @@ +/* 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 0000000000000..975b294d41d0f --- /dev/null +++ b/app/apps/server/tests/mocks/data/messages.data.js @@ -0,0 +1,115 @@ +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 0000000000000..920db0d95fdc9 --- /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 0000000000000..d21fb5a69adaf --- /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 0000000000000..a56d7777a28d7 --- /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 0000000000000..2604a0cb95888 --- /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 0000000000000..7257050146074 --- /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 0000000000000..6429b7a182ef0 --- /dev/null +++ b/app/apps/server/tests/mocks/orchestrator.mock.js @@ -0,0 +1,105 @@ +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 0000000000000..ca39cd0df4b1a --- /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 0000000000000..280dc99c17c58 --- /dev/null +++ b/app/assets/server/assets.js @@ -0,0 +1,533 @@ +import crypto from 'crypto'; + +import { Meteor } from 'meteor/meteor'; +import { WebApp, WebAppInternals } from 'meteor/webapp'; +import { WebAppHashing } from 'meteor/webapp-hashing'; +import _ from 'underscore'; +import sizeOf from 'image-size'; +import sharp from 'sharp'; + +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'; + + +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(); + + 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, + }; + + return assetValue.cache; + } + + 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 0000000000000..8e5b30ff6b6f5 --- /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 0000000000000..994c363428d90 --- /dev/null +++ b/app/authorization/client/hasPermission.js @@ -0,0 +1,65 @@ +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; + +import { ChatPermissions } from './lib/ChatPermissions'; +import * as Models from '../../models'; + +function atLeastOne(permissions = [], scope, userId) { + userId = userId || Meteor.userId(); + + 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(userId, roleName, scope); + }); + }); +} + +function all(permissions = [], scope, userId) { + userId = userId || Meteor.userId(); + + 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(userId, roleName, scope); + }); + }); +} + +function _hasPermission(permissions, scope, strategy, userId) { + userId = userId || Meteor.userId(); + if (!userId) { + return false; + } + + if (!Models.AuthzCachedCollection.ready.get()) { + return false; + } + + permissions = [].concat(permissions); + return strategy(permissions, scope, userId); +} + +Template.registerHelper('hasPermission', function(permission, scope) { + return _hasPermission(permission, scope, atLeastOne); +}); +Template.registerHelper('userHasAllPermission', function(userId, permission, scope) { + return _hasPermission(permission, scope, all, userId); +}); + +export const hasAllPermission = (permissions, scope) => _hasPermission(permissions, scope, all); +export const hasAtLeastOnePermission = (permissions, scope) => _hasPermission(permissions, scope, atLeastOne); +export const userHasAllPermission = (permissions, scope, userId) => _hasPermission(permissions, scope, all, userId); +export const hasPermission = hasAllPermission; diff --git a/app/authorization/client/hasRole.js b/app/authorization/client/hasRole.js new file mode 100644 index 0000000000000..710ae1803cae8 --- /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/app/authorization/client/index.js b/app/authorization/client/index.js new file mode 100644 index 0000000000000..709df4e2f38c8 --- /dev/null +++ b/app/authorization/client/index.js @@ -0,0 +1,18 @@ +import { hasAllPermission, hasAtLeastOnePermission, hasPermission, userHasAllPermission } from './hasPermission'; +import { hasRole } from './hasRole'; +import './usersNameChanged'; +import './requiresPermission.html'; +import './route'; +import './startup'; +import './views/permissions.html'; +import './views/permissions'; +import './views/permissionsRole.html'; +import './views/permissionsRole'; + +export { + hasAllPermission, + hasAtLeastOnePermission, + hasRole, + hasPermission, + userHasAllPermission, +}; diff --git a/app/authorization/client/lib/ChatPermissions.js b/app/authorization/client/lib/ChatPermissions.js new file mode 100644 index 0000000000000..faafd72fa5e94 --- /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 0000000000000..5d54d53c8888d --- /dev/null +++ b/app/authorization/client/route.js @@ -0,0 +1,36 @@ +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 0000000000000..cfdacbe8813f4 --- /dev/null +++ b/app/authorization/client/startup.js @@ -0,0 +1,18 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasAllPermission } from './hasPermission'; +import { CachedCollectionManager } from '../../ui-cached-collection'; +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 0000000000000..aaca6008a0cf3 --- /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 0000000000000..c498ca472b3c1 --- /dev/null +++ b/app/authorization/client/usersNameChanged.js @@ -0,0 +1,18 @@ +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 0000000000000..281f3d584c4e0 --- /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 0000000000000..d7c4900228be3 --- /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); + } + 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 94% rename from packages/rocketchat-authorization/client/views/permissionsRole.html rename to app/authorization/client/views/permissionsRole.html index 4aaa9f6924316..403d69ec50fc2 100644 --- a/packages/rocketchat-authorization/client/views/permissionsRole.html +++ b/app/authorization/client/views/permissionsRole.html @@ -21,6 +21,11 @@ + +
+ + +
{{#if editable}} diff --git a/packages/rocketchat-authorization/client/views/permissionsRole.js b/app/authorization/client/views/permissionsRole.js similarity index 84% rename from packages/rocketchat-authorization/client/views/permissionsRole.js rename to app/authorization/client/views/permissionsRole.js index 779b29b74d8bc..e76cac546cf77 100644 --- a/packages/rocketchat-authorization/client/views/permissionsRole.js +++ b/app/authorization/client/views/permissionsRole.js @@ -2,14 +2,19 @@ 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, modal } from 'meteor/rocketchat:ui'; -import { RocketChat, handleError } from 'meteor/rocketchat:lib'; - +import { Tracker } from 'meteor/tracker'; import toastr from 'toastr'; +import { handleError } from '../../../utils/client/lib/handleError'; +import { t } from '../../../utils/lib/tapi18n'; +import { Roles } from '../../../models'; +import { hasAllPermission } from '../hasPermission'; +import { modal } from '../../../ui-utils/client/lib/modal'; +import { SideNav } from '../../../ui-utils/client/lib/SideNav'; + Template.permissionsRole.helpers({ role() { - return RocketChat.models.Roles.findOne({ + return Roles.findOne({ _id: FlowRouter.getParam('name'), }) || {}; }, @@ -29,7 +34,7 @@ Template.permissionsRole.helpers({ }, hasPermission() { - return RocketChat.authz.hasAllPermission('access-permissions'); + return hasAllPermission('access-permissions'); }, protected() { @@ -110,10 +115,11 @@ Template.permissionsRole.helpers({ }); Template.permissionsRole.events({ - 'click .remove-user'(e, instance) { + async 'click .remove-user'(e, instance) { e.preventDefault(); 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', @@ -145,6 +151,7 @@ Template.permissionsRole.events({ const roleData = { description: e.currentTarget.elements.description.value, scope: e.currentTarget.elements.scope.value, + mandatory2fa: e.currentTarget.elements.mandatory2fa.checked, }; if (this._id) { @@ -219,9 +226,9 @@ Template.permissionsRole.events({ }); Template.permissionsRole.onCreated(function() { - this.searchRoom = new ReactiveVar; - this.searchUsername = new ReactiveVar; - this.usersInRole = new ReactiveVar; + this.searchRoom = new ReactiveVar(); + this.searchUsername = new ReactiveVar(); + this.usersInRole = new ReactiveVar(); this.limit = new ReactiveVar(50); this.ready = new ReactiveVar(true); this.subscribe('roles', FlowRouter.getParam('name')); @@ -236,10 +243,17 @@ Template.permissionsRole.onCreated(function() { const subscription = this.subscribe('usersInRole', FlowRouter.getParam('name'), this.searchRoom.get(), limit); this.ready.set(subscription.ready()); - this.usersInRole.set(RocketChat.models.Roles.findUsersInRole(FlowRouter.getParam('name'), this.searchRoom.get(), { + this.usersInRole.set(Roles.findUsersInRole(FlowRouter.getParam('name'), this.searchRoom.get(), { sort: { username: 1, }, })); }); }); + +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 0000000000000..a67eca871efbb --- /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/app/authorization/server/functions/addUserRoles.js b/app/authorization/server/functions/addUserRoles.js new file mode 100644 index 0000000000000..46302e81eb8d7 --- /dev/null +++ b/app/authorization/server/functions/addUserRoles.js @@ -0,0 +1,32 @@ +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { getRoles } from './getRoles'; +import { Users, Roles } from '../../../models'; + +export const addUserRoles = (userId, roleNames, scope) => { + if (!userId || !roleNames) { + return false; + } + + const user = Users.db.findOneById(userId); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + function: 'RocketChat.authz.addUserRoles', + }); + } + + roleNames = [].concat(roleNames); + const existingRoleNames = _.pluck(getRoles(), '_id'); + const invalidRoleNames = _.difference(roleNames, existingRoleNames); + + if (!_.isEmpty(invalidRoleNames)) { + for (const role of invalidRoleNames) { + Roles.createOrUpdate(role); + } + } + + Roles.addUserRoles(userId, roleNames, scope); + + return true; +}; diff --git a/app/authorization/server/functions/canAccessRoom.js b/app/authorization/server/functions/canAccessRoom.js new file mode 100644 index 0000000000000..118149ada069a --- /dev/null +++ b/app/authorization/server/functions/canAccessRoom.js @@ -0,0 +1,29 @@ +import { hasPermission } from './hasPermission'; +import { settings } from '../../../settings'; +import Subscriptions from '../../../models/server/models/Subscriptions'; + +export const roomAccessValidators = [ + function(room, user = {}) { + if (room && room.t === 'c') { + if (!user._id && settings.get('Accounts_AllowAnonymousRead') === true) { + return true; + } + + return hasPermission(user._id, 'view-c-room'); + } + }, + function(room, user) { + if (!room || !user) { + return; + } + + const subscription = Subscriptions.findOneByRoomIdAndUserId(room._id, user._id); + if (subscription) { + return true; + } + }, +]; + +export const canAccessRoom = (room, user, extraData) => roomAccessValidators.some((validator) => validator(room, user, extraData)); + +export const addRoomAccessValidator = (validator) => roomAccessValidators.push(validator.bind(this)); diff --git a/app/authorization/server/functions/canSendMessage.js b/app/authorization/server/functions/canSendMessage.js new file mode 100644 index 0000000000000..7ca6a41d36463 --- /dev/null +++ b/app/authorization/server/functions/canSendMessage.js @@ -0,0 +1,44 @@ +import { Meteor } from 'meteor/meteor'; +import { TAPi18n } from 'meteor/tap:i18n'; +import { Random } from 'meteor/random'; + +import { canAccessRoom } from './canAccessRoom'; +import { hasPermission } from './hasPermission'; +import { Notifications } from '../../../notifications'; +import { Rooms, Subscriptions } from '../../../models'; + + +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.ro === true) { + if (!hasPermission(Meteor.userId(), 'post-readonly', room._id)) { + // Unless the user was manually unmuted + if (!(room.unmuted || []).includes(username)) { + Notifications.notifyUser(Meteor.userId(), 'message', { + _id: Random.id(), + rid: room._id, + ts: new Date(), + msg: TAPi18n.__('room_is_read_only'), + }); + + throw new Meteor.Error('You can\'t send messages because the room is readonly.'); + } + } + } + + 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 0000000000000..9d20c72d29a94 --- /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 0000000000000..27c369acf9ffd --- /dev/null +++ b/app/authorization/server/functions/getUsersInRole.js @@ -0,0 +1,3 @@ +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 0000000000000..90226dd849824 --- /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 0000000000000..879aeaa012414 --- /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/app/authorization/server/functions/removeUserFromRoles.js b/app/authorization/server/functions/removeUserFromRoles.js new file mode 100644 index 0000000000000..b08c2778addba --- /dev/null +++ b/app/authorization/server/functions/removeUserFromRoles.js @@ -0,0 +1,33 @@ +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { getRoles } from './getRoles'; +import { Users, Roles } from '../../../models'; + +export const removeUserFromRoles = (userId, roleNames, scope) => { + if (!userId || !roleNames) { + return false; + } + + const user = Users.findOneById(userId); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + function: 'RocketChat.authz.removeUserFromRoles', + }); + } + + roleNames = [].concat(roleNames); + const existingRoleNames = _.pluck(getRoles(), '_id'); + const invalidRoleNames = _.difference(roleNames, existingRoleNames); + + if (!_.isEmpty(invalidRoleNames)) { + throw new Meteor.Error('error-invalid-role', 'Invalid role', { + function: 'RocketChat.authz.removeUserFromRoles', + }); + } + + Roles.removeUserRoles(userId, roleNames, scope); + + return true; +}; diff --git a/app/authorization/server/index.js b/app/authorization/server/index.js new file mode 100644 index 0000000000000..a4d74beba1678 --- /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/app/authorization/server/methods/addPermissionToRole.js b/app/authorization/server/methods/addPermissionToRole.js new file mode 100644 index 0000000000000..91af0a77400fc --- /dev/null +++ b/app/authorization/server/methods/addPermissionToRole.js @@ -0,0 +1,17 @@ +import { Meteor } from 'meteor/meteor'; + +import { Permissions } from '../../../models'; +import { hasPermission } from '../functions/hasPermission'; + +Meteor.methods({ + 'authorization:addPermissionToRole'(permission, role) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'access-permissions')) { + throw new Meteor.Error('error-action-not-allowed', 'Adding permission is not allowed', { + method: 'authorization:addPermissionToRole', + action: 'Adding_permission', + }); + } + + return Permissions.addRole(permission, role); + }, +}); diff --git a/app/authorization/server/methods/addUserToRole.js b/app/authorization/server/methods/addUserToRole.js new file mode 100644 index 0000000000000..1c53476a93bfc --- /dev/null +++ b/app/authorization/server/methods/addUserToRole.js @@ -0,0 +1,59 @@ +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { Users, Roles } from '../../../models'; +import { settings } from '../../../settings'; +import { Notifications } from '../../../notifications'; +import { hasPermission } from '../functions/hasPermission'; + +Meteor.methods({ + 'authorization:addUserToRole'(roleName, username, scope) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'access-permissions')) { + throw new Meteor.Error('error-action-not-allowed', 'Accessing permissions is not allowed', { + method: 'authorization:addUserToRole', + action: 'Accessing_permissions', + }); + } + + if (!roleName || !_.isString(roleName) || !username || !_.isString(username)) { + throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', { + method: 'authorization:addUserToRole', + }); + } + + if (roleName === 'admin' && !hasPermission(Meteor.userId(), 'assign-admin-role')) { + throw new Meteor.Error('error-action-not-allowed', 'Assigning admin is not allowed', { + method: 'authorization:addUserToRole', + action: 'Assign_admin', + }); + } + + const user = Users.findOneByUsernameIgnoringCase(username, { + fields: { + _id: 1, + }, + }); + + if (!user || !user._id) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'authorization:addUserToRole', + }); + } + + const add = Roles.addUserRoles(user._id, roleName, scope); + + if (settings.get('UI_DisplayRoles')) { + Notifications.notifyLogged('roles-change', { + type: 'added', + _id: roleName, + u: { + _id: user._id, + username, + }, + scope, + }); + } + + return add; + }, +}); diff --git a/app/authorization/server/methods/deleteRole.js b/app/authorization/server/methods/deleteRole.js new file mode 100644 index 0000000000000..a11c56516e11f --- /dev/null +++ b/app/authorization/server/methods/deleteRole.js @@ -0,0 +1,40 @@ +import { Meteor } from 'meteor/meteor'; + +import * as Models from '../../../models'; +import { hasPermission } from '../functions/hasPermission'; + +Meteor.methods({ + 'authorization:deleteRole'(roleName) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'access-permissions')) { + throw new Meteor.Error('error-action-not-allowed', 'Accessing permissions is not allowed', { + method: 'authorization:deleteRole', + action: 'Accessing_permissions', + }); + } + + const role = Models.Roles.findOne(roleName); + if (!role) { + throw new Meteor.Error('error-invalid-role', 'Invalid role', { + method: 'authorization:deleteRole', + }); + } + + if (role.protected) { + throw new Meteor.Error('error-delete-protected-role', 'Cannot delete a protected role', { + method: 'authorization:deleteRole', + }); + } + + const roleScope = role.scope || 'Users'; + const model = Models[roleScope]; + const existingUsers = model && model.findUsersInRoles && model.findUsersInRoles(roleName); + + if (existingUsers && existingUsers.count() > 0) { + throw new Meteor.Error('error-role-in-use', 'Cannot delete role because it\'s in use', { + method: 'authorization:deleteRole', + }); + } + + return Models.Roles.remove(role.name); + }, +}); diff --git a/app/authorization/server/methods/removeRoleFromPermission.js b/app/authorization/server/methods/removeRoleFromPermission.js new file mode 100644 index 0000000000000..11c4b3db1f110 --- /dev/null +++ b/app/authorization/server/methods/removeRoleFromPermission.js @@ -0,0 +1,17 @@ +import { Meteor } from 'meteor/meteor'; + +import { Permissions } from '../../../models'; +import { hasPermission } from '../functions/hasPermission'; + +Meteor.methods({ + 'authorization:removeRoleFromPermission'(permission, role) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'access-permissions')) { + throw new Meteor.Error('error-action-not-allowed', 'Accessing permissions is not allowed', { + method: 'authorization:removeRoleFromPermission', + action: 'Accessing_permissions', + }); + } + + return Permissions.removeRole(permission, role); + }, +}); diff --git a/packages/rocketchat-authorization/server/methods/removeUserFromRole.js b/app/authorization/server/methods/removeUserFromRole.js similarity index 76% rename from packages/rocketchat-authorization/server/methods/removeUserFromRole.js rename to app/authorization/server/methods/removeUserFromRole.js index 94a50b4a309d5..79aab87ba21c5 100644 --- a/packages/rocketchat-authorization/server/methods/removeUserFromRole.js +++ b/app/authorization/server/methods/removeUserFromRole.js @@ -1,10 +1,14 @@ import { Meteor } from 'meteor/meteor'; -import { RocketChat } from 'meteor/rocketchat:lib'; import _ from 'underscore'; +import { Roles } from '../../../models'; +import { settings } from '../../../settings'; +import { Notifications } from '../../../notifications'; +import { hasPermission } from '../functions/hasPermission'; + Meteor.methods({ 'authorization:removeUserFromRole'(roleName, username, scope) { - if (!Meteor.userId() || !RocketChat.authz.hasPermission(Meteor.userId(), 'access-permissions')) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'access-permissions')) { throw new Meteor.Error('error-action-not-allowed', 'Access permissions is not allowed', { method: 'authorization:removeUserFromRole', action: 'Accessing_permissions', @@ -49,9 +53,9 @@ Meteor.methods({ } } - const remove = RocketChat.models.Roles.removeUserRoles(user._id, roleName, scope); - if (RocketChat.settings.get('UI_DisplayRoles')) { - RocketChat.Notifications.notifyLogged('roles-change', { + const remove = Roles.removeUserRoles(user._id, roleName, scope); + if (settings.get('UI_DisplayRoles')) { + Notifications.notifyLogged('roles-change', { type: 'removed', _id: roleName, u: { diff --git a/app/authorization/server/methods/saveRole.js b/app/authorization/server/methods/saveRole.js new file mode 100644 index 0000000000000..bbe217af78d6f --- /dev/null +++ b/app/authorization/server/methods/saveRole.js @@ -0,0 +1,37 @@ +import { Meteor } from 'meteor/meteor'; + +import { Roles } from '../../../models'; +import { settings } from '../../../settings'; +import { Notifications } from '../../../notifications'; +import { hasPermission } from '../functions/hasPermission'; + +Meteor.methods({ + 'authorization:saveRole'(roleData) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'access-permissions')) { + throw new Meteor.Error('error-action-not-allowed', 'Accessing permissions is not allowed', { + method: 'authorization:saveRole', + action: 'Accessing_permissions', + }); + } + + if (!roleData.name) { + throw new Meteor.Error('error-role-name-required', 'Role name is required', { + method: 'authorization:saveRole', + }); + } + + if (['Users', 'Subscriptions'].includes(roleData.scope) === false) { + roleData.scope = 'Users'; + } + + const update = Roles.createOrUpdate(roleData.name, roleData.scope, roleData.description, false, roleData.mandatory2fa); + if (settings.get('UI_DisplayRoles')) { + Notifications.notifyLogged('roles-change', { + type: 'changed', + _id: roleData.name, + }); + } + + return update; + }, +}); diff --git a/app/authorization/server/publications/permissions.js b/app/authorization/server/publications/permissions.js new file mode 100644 index 0000000000000..b99a9bbd5ae22 --- /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) { + // 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 0000000000000..379bca0fbc957 --- /dev/null +++ b/app/authorization/server/publications/roles.js @@ -0,0 +1,11 @@ +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/app/authorization/server/publications/usersInRole.js b/app/authorization/server/publications/usersInRole.js new file mode 100644 index 0000000000000..5d52c862f765a --- /dev/null +++ b/app/authorization/server/publications/usersInRole.js @@ -0,0 +1,25 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../functions/hasPermission'; +import { getUsersInRole } from '../functions/getUsersInRole'; + +Meteor.publish('usersInRole', function(roleName, scope, limit = 50) { + if (!this.userId) { + return this.ready(); + } + + if (!hasPermission(this.userId, 'access-permissions')) { + return this.error(new Meteor.Error('error-not-allowed', 'Not allowed', { + publish: 'usersInRole', + })); + } + + const options = { + limit, + sort: { + name: 1, + }, + }; + + return getUsersInRole(roleName, scope, options); +}); diff --git a/app/authorization/server/startup.js b/app/authorization/server/startup.js new file mode 100644 index 0000000000000..8d57052b66db2 --- /dev/null +++ b/app/authorization/server/startup.js @@ -0,0 +1,104 @@ +/* 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 0000000000000..b7e7f905bbce3 --- /dev/null +++ b/app/autolinker/client/client.js @@ -0,0 +1,75 @@ +import { Meteor } from 'meteor/meteor'; +import s from 'underscore.string'; +import Autolinker from 'autolinker'; + +import { settings } from '../../settings'; +import { callbacks } from '../../callbacks'; + +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 0000000000000..81e65d05c0d2e --- /dev/null +++ b/app/autolinker/server/settings.js @@ -0,0 +1,20 @@ +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 0000000000000..c1deaf68e6452 --- /dev/null +++ b/app/autotranslate/client/index.js @@ -0,0 +1,6 @@ +import './lib/actionButton'; +import './lib/tabBar'; +import './views/autoTranslateFlexTab.html'; +import './views/autoTranslateFlexTab'; + +export { AutoTranslate } from './lib/autotranslate'; diff --git a/app/autotranslate/client/lib/actionButton.js b/app/autotranslate/client/lib/actionButton.js new file mode 100644 index 0000000000000..d80d669f81b7e --- /dev/null +++ b/app/autotranslate/client/lib/actionButton.js @@ -0,0 +1,68 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; + +import { AutoTranslate } from './autotranslate'; +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'; + +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', + 'threads', + ], + 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({ msg, u }) { + return msg && msg.u && msg.u._id !== u._id && msg.translations && !msg.translations.original; + }, + order: 90, + }); + MessageAction.addButton({ + id: 'view-original', + icon: 'language', + label: 'View_original', + context: [ + 'message', + 'message-mobile', + 'threads', + ], + 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({ msg, u }) { + return msg && msg.u && msg.u._id !== u._id && msg.translations && msg.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 0000000000000..74d81891755fd --- /dev/null +++ b/app/autotranslate/client/lib/autotranslate.js @@ -0,0 +1,128 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; +import _ from 'underscore'; +import mem from 'mem'; + +import { Subscriptions, Messages } from '../../../models'; +import { callbacks } from '../../../callbacks'; +import { settings } from '../../../settings'; +import { hasAtLeastOnePermission } from '../../../authorization'; +import { CachedCollectionManager } from '../../../ui-cached-collection'; + +let userLanguage = 'en'; +let username = ''; + +Meteor.startup(() => Tracker.autorun(() => { + const user = Meteor.user(); + if (!user) { + return; + } + userLanguage = user.language || 'en'; + username = user.username; +})); + +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) || userLanguage || 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 !== 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 0000000000000..f527022009aab --- /dev/null +++ b/app/autotranslate/client/lib/tabBar.js @@ -0,0 +1,22 @@ +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 90% rename from packages/rocketchat-autotranslate/client/views/autoTranslateFlexTab.js rename to app/autotranslate/client/views/autoTranslateFlexTab.js index f30e2253c2630..3add779473b39 100644 --- a/packages/rocketchat-autotranslate/client/views/autoTranslateFlexTab.js +++ b/app/autotranslate/client/views/autoTranslateFlexTab.js @@ -2,11 +2,12 @@ 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, t } from 'meteor/rocketchat:ui'; import _ from 'underscore'; import toastr from 'toastr'; +import { ChatSubscription, Subscriptions, Messages } from '../../../models'; +import { t, handleError } from '../../../utils'; + Template.autoTranslateFlexTab.helpers({ autoTranslate() { const sub = ChatSubscription.findOne({ @@ -43,7 +44,7 @@ Template.autoTranslateFlexTab.helpers({ let language = _.findWhere(supportedLanguages, { language: autoTranslateLanguage }); if (language) { return language.language; - } else if (autoTranslateLanguage.indexOf('-') !== -1) { + } if (autoTranslateLanguage.indexOf('-') !== -1) { language = _.findWhere(supportedLanguages, { language: autoTranslateLanguage.substr(0, 2) }); return language && language.language; } @@ -63,7 +64,7 @@ Template.autoTranslateFlexTab.helpers({ let language = _.findWhere(supportedLanguages, { language: targetLanguage }); if (language) { return language.name; - } else if (targetLanguage.indexOf('-') !== -1) { + } if (targetLanguage.indexOf('-') !== -1) { language = _.findWhere(supportedLanguages, { language: targetLanguage.substr(0, 2) }); return language && language.name; } @@ -97,7 +98,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) { @@ -123,7 +124,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; @@ -133,7 +134,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 0000000000000..13c77caf4c94b --- /dev/null +++ b/app/autotranslate/server/autotranslate.js @@ -0,0 +1,263 @@ +import { Meteor } from 'meteor/meteor'; +import { HTTP } from 'meteor/http'; +import _ from 'underscore'; +import s from 'underscore.string'; + +import { settings } from '../../settings'; +import { callbacks } from '../../callbacks'; +import { Subscriptions, Messages } from '../../models'; +import { Markdown } from '../../markdown/server'; + +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 || 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; + } + + if (this.supportedLanguages[target]) { + return this.supportedLanguages[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 }); + } + } + } + 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 0000000000000..a3692f29c7221 --- /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 0000000000000..2f44877a7e1c4 --- /dev/null +++ b/app/autotranslate/server/methods/getSupportedLanguages.js @@ -0,0 +1,23 @@ +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 0000000000000..c7a9adcc208a0 --- /dev/null +++ b/app/autotranslate/server/methods/saveSettings.js @@ -0,0 +1,44 @@ +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 0000000000000..26ea7cf301a11 --- /dev/null +++ b/app/autotranslate/server/methods/translateMessage.js @@ -0,0 +1,13 @@ +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 0000000000000..64ce0028fa872 --- /dev/null +++ b/app/autotranslate/server/permissions.js @@ -0,0 +1,11 @@ +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 0000000000000..02931fd38771b --- /dev/null +++ b/app/autotranslate/server/settings.js @@ -0,0 +1,8 @@ +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 }, secret: true }); +}); diff --git a/app/bigbluebutton/index.js b/app/bigbluebutton/index.js new file mode 100644 index 0000000000000..ba58589ba3d7e --- /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 0000000000000..e88f4df9bf1ca --- /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 0000000000000..de00f0d1ccf4d --- /dev/null +++ b/app/blockstack/server/loginHandler.js @@ -0,0 +1,54 @@ +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; + +import { updateOrCreateUser } from './userHandler'; +import { handleAccessToken } from './tokenHandler'; +import { logger } from './logger'; +import { settings } from '../../settings'; +import { Users } from '../../models'; +import { setUserAvatar } from '../../lib'; + +// 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 0000000000000..ee4c7cc087dfe --- /dev/null +++ b/app/blockstack/server/routes.js @@ -0,0 +1,28 @@ +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 0000000000000..f5e79db649757 --- /dev/null +++ b/app/blockstack/server/settings.js @@ -0,0 +1,70 @@ +import _ from 'underscore'; +import { Meteor } from 'meteor/meteor'; +import { ServiceConfiguration } from 'meteor/service-configuration'; + +import { logger } from './logger'; +import { settings } from '../../settings'; + +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 100% rename from packages/rocketchat-blockstack/server/tokenHandler.js rename to app/blockstack/server/tokenHandler.js index 30686814b1132..32cd176086fbb 100644 --- a/packages/rocketchat-blockstack/server/tokenHandler.js +++ b/app/blockstack/server/tokenHandler.js @@ -1,8 +1,8 @@ import { decodeToken } from 'blockstack'; - import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; import { Match, check } from 'meteor/check'; + import { logger } from './logger'; // Handler extracts data from JSON and tokenised reponse. 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 8e23773b27353..8078d5358c7de 100644 --- a/packages/rocketchat-blockstack/server/userHandler.js +++ b/app/blockstack/server/userHandler.js @@ -1,8 +1,9 @@ import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; import { ServiceConfiguration } from 'meteor/service-configuration'; -import { RocketChat } from 'meteor/rocketchat:lib'; + import { logger } from './logger'; +import { generateUsernameSuggestion } from '../../lib'; // Updates or creates a user after we authenticate with Blockstack // Clones Accounts.updateOrCreateUserFromExternalService with some modifications @@ -53,7 +54,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 0000000000000..f5778a23b606f --- /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 0000000000000..e88fef7641d06 --- /dev/null +++ b/app/bot-helpers/server/index.js @@ -0,0 +1,166 @@ +import './settings'; +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { Users, Rooms } from '../../models'; +import { settings } from '../../settings'; +import { hasRole } from '../../authorization'; + +/** + * 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; + } if (typeof this[prop] === 'function') { + return this[prop](...params); + } + 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; + } + return this._allUsers.fetch(); + } + + get onlineUsers() { + if (!Object.keys(this.userFields).length) { + this.requestError(); + return false; + } + return this._onlineUsers.fetch(); + } + + get allUsernames() { + if (!this.userFields.hasOwnProperty('username')) { + this.requestError(); + return false; + } + return this._allUsers.fetch().map((user) => user.username); + } + + get onlineUsernames() { + if (!this.userFields.hasOwnProperty('username')) { + this.requestError(); + return false; + } + return this._onlineUsers.fetch().map((user) => user.username); + } + + get allNames() { + if (!this.userFields.hasOwnProperty('name')) { + this.requestError(); + return false; + } + return this._allUsers.fetch().map((user) => user.name); + } + + get onlineNames() { + if (!this.userFields.hasOwnProperty('name')) { + this.requestError(); + return false; + } + return this._onlineUsers.fetch().map((user) => user.name); + } + + get allIDs() { + if (!this.userFields.hasOwnProperty('_id') || !this.userFields.hasOwnProperty('username')) { + this.requestError(); + return false; + } + 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; + } + 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); + } + 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 0000000000000..dc4f5640c940b --- /dev/null +++ b/app/bot-helpers/server/settings.js @@ -0,0 +1,14 @@ +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/app/callbacks/client/index.js b/app/callbacks/client/index.js new file mode 100644 index 0000000000000..486af6f60697b --- /dev/null +++ b/app/callbacks/client/index.js @@ -0,0 +1,5 @@ +import { callbacks } from '../lib/callbacks'; + +export { + callbacks, +}; diff --git a/app/callbacks/index.js b/app/callbacks/index.js new file mode 100644 index 0000000000000..a67eca871efbb --- /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/app/callbacks/lib/callbacks.js b/app/callbacks/lib/callbacks.js new file mode 100644 index 0000000000000..589764076924c --- /dev/null +++ b/app/callbacks/lib/callbacks.js @@ -0,0 +1,135 @@ +import { Meteor } from 'meteor/meteor'; +import { Random } from 'meteor/random'; +import _ from 'underscore'; + +/* +* Callback hooks provide an easy way to add extra steps to common operations. +* @namespace RocketChat.callbacks +*/ + +export const callbacks = {}; + +if (Meteor.isServer) { + callbacks.showTime = true; + callbacks.showTotalTime = true; +} else { + callbacks.showTime = false; + callbacks.showTotalTime = false; +} + + +/* +* Callback priorities +*/ + +callbacks.priority = { + HIGH: -1000, + MEDIUM: 0, + LOW: 1000, +}; + +const getHooks = (hookName) => callbacks[hookName] || []; + +/* +* Add a callback function to a hook +* @param {String} hook - The name of the hook +* @param {Function} callback - The callback function +*/ + +callbacks.add = function(hook, callback, priority, id = Random.id()) { + if (!_.isNumber(priority)) { + priority = callbacks.priority.MEDIUM; + } + callback.priority = priority; + callback.id = id; + callbacks[hook] = getHooks(hook); + + if (callbacks.showTime === true) { + const err = new Error(); + callback.stack = err.stack; + } + + if (callbacks[hook].find((cb) => cb.id === callback.id)) { + return; + } + callbacks[hook].push(callback); + callbacks[hook] = _.sortBy(callbacks[hook], function(callback) { + return callback.priority || callbacks.priority.MEDIUM; + }); +}; + + +/* +* Remove a callback from a hook +* @param {string} hook - The name of the hook +* @param {string} id - The callback's id +*/ + +callbacks.remove = function(hook, id) { + callbacks[hook] = getHooks(hook).filter((callback) => callback.id !== id); +}; + +callbacks.runItem = function({ callback, result, constant /* , hook */ }) { + return callback(result, constant); +}; + +/* +* Successively run all of a hook's callbacks on an item +* @param {String} hook - The name of the hook +* @param {Object} item - The post, comment, modifier, etc. on which to run the callbacks +* @param {Object} [constant] - An optional constant that will be passed along to each callback +* @returns {Object} Returns the item after it's been through all the callbacks for this hook +*/ + +callbacks.run = function(hook, item, constant) { + const callbackItems = callbacks[hook]; + if (!callbackItems || !callbackItems.length) { + return item; + } + + let totalTime = 0; + const result = callbackItems.reduce(function(result, callback) { + const time = callbacks.showTime === true || callbacks.showTotalTime === true ? Date.now() : 0; + + const callbackResult = callbacks.runItem({ hook, callback, result, constant, time }); + + if (callbacks.showTime === true || callbacks.showTotalTime === true) { + const currentTime = Date.now() - time; + totalTime += currentTime; + if (callbacks.showTime === true) { + if (!Meteor.isServer) { + let stack = callback.stack && typeof callback.stack.split === 'function' && callback.stack.split('\n'); + stack = stack && stack[2] && (stack[2].match(/\(.+\)/) || [])[0]; + console.log(String(currentTime), hook, callback.id, stack); + } + } + } + return typeof callbackResult === 'undefined' ? result : callbackResult; + }, item); + + if (callbacks.showTotalTime === true) { + if (!Meteor.isServer) { + console.log(`${ hook }:`, totalTime); + } + } + + return result; +}; + + +/* +* Successively run all of a hook's callbacks on an item, in async mode (only works on server) +* @param {String} hook - The name of the hook +* @param {Object} item - The post, comment, modifier, etc. on which to run the callbacks +* @param {Object} [constant] - An optional constant that will be passed along to each callback +*/ + +callbacks.runAsync = function(hook, item, constant) { + const callbackItems = callbacks[hook]; + if (Meteor.isServer && callbackItems && callbackItems.length) { + Meteor.defer(function() { + callbackItems.forEach((callback) => callback(item, constant)); + }); + } + return item; +}; diff --git a/app/callbacks/server/index.js b/app/callbacks/server/index.js new file mode 100644 index 0000000000000..486af6f60697b --- /dev/null +++ b/app/callbacks/server/index.js @@ -0,0 +1,5 @@ +import { callbacks } from '../lib/callbacks'; + +export { + callbacks, +}; diff --git a/app/cas/client/cas_client.js b/app/cas/client/cas_client.js new file mode 100644 index 0000000000000..7046f4d024eda --- /dev/null +++ b/app/cas/client/cas_client.js @@ -0,0 +1,77 @@ +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; +import { Random } from 'meteor/random'; + +import { settings } from '../../settings'; + +const openCenteredPopup = function(url, width, height) { + const screenX = typeof window.screenX !== 'undefined' ? window.screenX : window.screenLeft; + const screenY = typeof window.screenY !== 'undefined' ? window.screenY : window.screenTop; + const outerWidth = typeof window.outerWidth !== 'undefined' ? window.outerWidth : document.body.clientWidth; + const outerHeight = typeof window.outerHeight !== 'undefined' ? window.outerHeight : document.body.clientHeight - 22; + // XXX what is the 22? + + // Use `outerWidth - width` and `outerHeight - height` for help in + // positioning the popup centered relative to the current window + const left = screenX + (outerWidth - width) / 2; + const top = screenY + (outerHeight - height) / 2; + const features = `width=${ width },height=${ height },left=${ left },top=${ top },scrollbars=yes`; + + const newwindow = window.open(url, 'Login', features); + if (newwindow.focus) { + newwindow.focus(); + } + + return newwindow; +}; + +Meteor.loginWithCas = function(options, callback) { + options = options || {}; + + const credentialToken = Random.id(); + 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; + } + + const appUrl = Meteor.absoluteUrl().replace(/\/$/, '') + __meteor_runtime_config__.ROOT_URL_PATH_PREFIX; + // check if the provided CAS URL already has some parameters + const delim = login_url.split('?').length > 1 ? '&' : '?'; + const loginUrl = `${ login_url }${ delim }service=${ appUrl }/_cas/${ credentialToken }`; + + const popup = openCenteredPopup( + loginUrl, + popup_width || 800, + popup_height || 600 + ); + + + const checkPopupOpen = setInterval(function() { + let popupClosed; + try { + // Fix for #328 - added a second test criteria (popup.closed === undefined) + // to humour this Android quirk: + // http://code.google.com/p/android/issues/detail?id=21061 + popupClosed = popup.closed || popup.closed === undefined; + } catch (e) { + // For some unknown reason, IE9 (and others?) sometimes (when + // the popup closes too quickly?) throws "SCRIPT16386: No such + // interface supported" when trying to read 'popup.closed'. Try + // again in 100ms. + return; + } + + if (popupClosed) { + clearInterval(checkPopupOpen); + + // check auth on server. + Accounts.callLoginMethod({ + methodArguments: [{ cas: { credentialToken } }], + userCallback: callback, + }); + } + }, 100); +}; 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 0000000000000..0a6ec78e520e0 --- /dev/null +++ b/app/cas/server/cas_rocketchat.js @@ -0,0 +1,69 @@ +import { Meteor } from 'meteor/meteor'; +import { ServiceConfiguration } from 'meteor/service-configuration'; + +import { Logger } from '../../logger'; +import { settings } from '../../settings'; + +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/app/cas/server/cas_server.js b/app/cas/server/cas_server.js new file mode 100644 index 0000000000000..410fd4f16e06a --- /dev/null +++ b/app/cas/server/cas_server.js @@ -0,0 +1,268 @@ +import url from 'url'; + +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; +import { Random } from 'meteor/random'; +import { WebApp } from 'meteor/webapp'; +import { RoutePolicy } from 'meteor/routepolicy'; +import _ from 'underscore'; +import fiber from 'fibers'; +import CAS from 'cas'; + +import { logger } from './cas_rocketchat'; +import { settings } from '../../settings'; +import { Rooms, Subscriptions, CredentialTokens } from '../../models'; +import { _setRealName } from '../../lib'; + +RoutePolicy.declare('/_cas/', 'network'); + +const closePopup = function(res) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + const content = ''; + res.end(content, 'utf-8'); +}; + +const casTicket = function(req, token, callback) { + // get configuration + if (!settings.get('CAS_enabled')) { + logger.error('Got ticket validation request, but CAS is not enabled'); + callback(); + } + + // get ticket and validate. + const parsedUrl = url.parse(req.url, true); + const ticketId = parsedUrl.query.ticket; + 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 }`); + + const cas = new CAS({ + base_url: baseUrl, + version: cas_version, + service: `${ appUrl }/_cas/${ token }`, + }); + + cas.validate(ticketId, Meteor.bindEnvironment(function(err, status, username, details) { + if (err) { + logger.error(`error when trying to validate: ${ err.message }`); + } else if (status) { + logger.info(`Validated user: ${ username }`); + const user_info = { username }; + + // CAS 2.0 attributes handling + if (details && details.attributes) { + _.extend(user_info, { attributes: details.attributes }); + } + CredentialTokens.create(token, user_info); + } else { + logger.error(`Unable to validate ticket: ${ ticketId }`); + } + // logger.debug("Receveied response: " + JSON.stringify(details, null , 4)); + + callback(); + })); +}; + +const middleware = function(req, res, next) { + // Make sure to catch any exceptions because otherwise we'd crash + // the runner + try { + const barePath = req.url.substring(0, req.url.indexOf('?')); + const splitPath = barePath.split('/'); + + // Any non-cas request will continue down the default + // middlewares. + if (splitPath[1] !== '_cas') { + next(); + return; + } + + // get auth token + const credentialToken = splitPath[2]; + if (!credentialToken) { + closePopup(res); + return; + } + + // validate ticket + casTicket(req, credentialToken, function() { + closePopup(res); + }); + } catch (err) { + logger.error(`Unexpected error : ${ err.message }`); + closePopup(res); + } +}; + +// Listen to incoming OAuth http requests +WebApp.connectHandlers.use(function(req, res, next) { + // Need to create a fiber since we're using synchronous http calls and nothing + // else is wrapping this in a fiber automatically + fiber(function() { + middleware(req, res, next); + }).run(); +}); + +/* + * Register a server-side login handle. + * It is call after Accounts.callLoginMethod() is call from client. + * + */ +Accounts.registerLoginHandler(function(options) { + if (!options.cas) { + return undefined; + } + + 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 = 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 = { + username: result.username, + }; + + // We need these + const int_attrs = { + email: undefined, + name: undefined, + username: undefined, + rooms: undefined, + }; + + // Import response attributes + if (cas_version >= 2.0) { + // Clean & import external attributes + _.each(result.attributes, function(value, ext_name) { + if (value) { + ext_attrs[ext_name] = value[0]; + } + }); + } + + // Source internal attributes + if (syncUserDataFieldMap) { + // Our mapping table: key(int_attr) -> value(ext_attr) + // Spoken: Source this internal attribute from these external attributes + const attr_map = JSON.parse(syncUserDataFieldMap); + + _.each(attr_map, function(source, int_name) { + // Source is our String to interpolate + if (_.isString(source)) { + let replacedValue = source; + _.each(ext_attrs, function(value, ext_name) { + replacedValue = replacedValue.replace(`%${ ext_name }%`, ext_attrs[ext_name]); + }); + + if (source !== replacedValue) { + int_attrs[int_name] = replacedValue; + logger.debug(`Sourced internal attribute: ${ int_name } = ${ replacedValue }`); + } else { + logger.debug(`Sourced internal attribute: ${ int_name } skipped.`); + } + } + }); + } + + // Search existing user by its external service id + logger.debug(`Looking up user by id: ${ result.username }`); + // First, look for a user that has logged in from CAS with this username before + let user = Meteor.users.findOne({ 'services.cas.external_id': result.username }); + if (!user) { + // If that user was not found, check if there's any CAS user that is currently using that username on Rocket.Chat + // With this, CAS login will continue to work if the user is renamed on both sides and also if the user is renamed only on Rocket.Chat. + const username = new RegExp(`^${ result.username }$`, 'i'); + user = Meteor.users.findOne({ 'services.cas.external_id': { $exists: true }, username }); + if (user) { + // Update the user's external_id to reflect this new username. + Meteor.users.update(user, { $set: { 'services.cas.external_id': result.username } }); + } + } + + if (user) { + logger.debug(`Using existing user for '${ result.username }' with id: ${ user._id }`); + if (sync_enabled) { + logger.debug('Syncing user attributes'); + // Update name + if (int_attrs.name) { + _setRealName(user._id, int_attrs.name); + } + + // Update email + if (int_attrs.email) { + Meteor.users.update(user, { $set: { emails: [{ address: int_attrs.email, verified: true }] } }); + } + } + } else { + // Define new user + const newUser = { + username: result.username, + active: true, + globalRoles: ['user'], + emails: [], + services: { + cas: { + external_id: result.username, + version: cas_version, + attrs: int_attrs, + }, + }, + }; + + // Add User.name + if (int_attrs.name) { + _.extend(newUser, { + name: int_attrs.name, + }); + } + + // Add email + if (int_attrs.email) { + _.extend(newUser, { + emails: [{ address: int_attrs.email, verified: true }], + }); + } + + // Create the user + logger.debug(`User "${ result.username }" does not exist yet, creating it`); + const userId = Accounts.insertUserDoc({}, newUser); + + // Fetch and use it + user = Meteor.users.findOne(userId); + logger.debug(`Created new user for '${ result.username }' with id: ${ user._id }`); + // logger.debug(JSON.stringify(user, undefined, 4)); + + logger.debug(`Joining user to attribute channels: ${ int_attrs.rooms }`); + if (int_attrs.rooms) { + _.each(int_attrs.rooms.split(','), function(room_name) { + if (room_name) { + let room = Rooms.findOneByNameAndType(room_name, 'c'); + if (!room) { + room = Rooms.createWithIdTypeAndName(Random.id(), 'c', room_name); + } + + if (!Subscriptions.findOneByRoomIdAndUserId(room._id, userId)) { + Subscriptions.createWithRoomAndUser(room, user, { + ts: new Date(), + open: true, + alert: true, + unread: 1, + userMentions: 1, + groupMentions: 0, + }); + } + } + }); + } + } + + return { userId: user._id }; +}); diff --git a/app/cas/server/index.js b/app/cas/server/index.js new file mode 100644 index 0000000000000..0ad22d77b198d --- /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 0000000000000..60986fc628be6 --- /dev/null +++ b/app/channel-settings-mail-messages/client/lib/startup.js @@ -0,0 +1,20 @@ +// 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 9cdfbe698a9bd..6b79bef0a0616 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 85% rename from packages/rocketchat-channel-settings-mail-messages/client/views/mailMessagesInstructions.js rename to app/channel-settings-mail-messages/client/views/mailMessagesInstructions.js index 1e8dd5ff2ff28..a141dc7bc5afb 100644 --- a/packages/rocketchat-channel-settings-mail-messages/client/views/mailMessagesInstructions.js +++ b/app/channel-settings-mail-messages/client/views/mailMessagesInstructions.js @@ -4,38 +4,16 @@ 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 { t, ChatRoom } from 'meteor/rocketchat:ui'; import { Deps } from 'meteor/deps'; import toastr from 'toastr'; -import resetSelection from '../resetSelection'; -/* - * Code from https://github.com/dleitee/valid.js - * Checks for email - * @params email - * @return boolean - */ -const isEmail = (email) => { - const sQtext = '[^\\x0d\\x22\\x5c]'; - const sDtext = '[^\\x0d\\x5b-\\x5d]'; - const sAtom = '[^\\x00-\\x20\\x22\\x28\\x29\\x2c\\x2e\\x3a-\\x3c\\x3e\\x40\\x5b-\\x5d]+'; - const sQuotedPair = '\\x5c[\\x00-\\x7f]'; - const sDomainLiteral = `\\x5b(${ sDtext }|${ sQuotedPair })*\\x5d`; - const sQuotedString = `\\x22(${ sQtext }|${ sQuotedPair })*\\x22`; - const sDomainRef = sAtom; - const sSubDomain = `(${ sDomainRef }|${ sDomainLiteral })`; - const sWord = `(${ sAtom }|${ sQuotedString })`; - const sDomain = `${ sSubDomain }(\\x2e${ sSubDomain })*`; - const sLocalPart = `${ sWord }(\\x2e${ sWord })*`; - const sAddrSpec = `${ sLocalPart }\\x40${ sDomain }`; - const sValidEmail = `^${ sAddrSpec }$`; - const reg = new RegExp(sValidEmail); - return reg.test(email); -}; +import { ChatRoom } from '../../../models'; +import { t, isEmail, handleError, roomTypes } from '../../../utils'; +import { settings } from '../../../settings'; +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(''); }; @@ -49,7 +27,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(); @@ -258,18 +236,18 @@ Template.mailMessagesInstructions.onCreated(function() { this.selectedUsers = new ReactiveVar([]); this.userFilter = new ReactiveVar(''); - const filter = { exceptions :[Meteor.user().username].concat(this.selectedUsers.get().map((u) => u.username)) }; + const filter = { exceptions: [Meteor.user().username].concat(this.selectedUsers.get().map((u) => u.username)) }; Deps.autorun(() => { filter.exceptions = [Meteor.user().username].concat(this.selectedUsers.get().map((u) => u.username)); }); this.ac = new AutoComplete( { - selector:{ + selector: { 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 0000000000000..a04875ad90da4 --- /dev/null +++ b/app/channel-settings-mail-messages/server/lib/startup.js @@ -0,0 +1,13 @@ +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 1ad34a81a0f8f..d911210a25fc6 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,13 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; -import { RocketChat } from 'meteor/rocketchat:lib'; import _ from 'underscore'; import moment from 'moment'; -import * as Mailer from 'meteor/rocketchat:mailer'; + +import { hasPermission } from '../../../authorization'; +import { Users, Messages } from '../../../models'; +import { settings } from '../../../settings'; +import { Message } from '../../../ui-utils'; +import * as Mailer from '../../../mailer'; Meteor.methods({ 'mailMessages'(data) { @@ -27,7 +31,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 +42,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.findOneByUsernameIgnoringCase(username); if (user && user.emails && user.emails[0] && user.emails[0].address) { emails.push(user.emails[0].address); } else { @@ -65,16 +69,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 0000000000000..9834b106c9d90 --- /dev/null +++ b/app/channel-settings/client/index.js @@ -0,0 +1,7 @@ +import './startup/messageTypes'; +import './startup/tabBar'; +import './startup/trackSettingsChange'; +import './views/channelSettings.html'; +import './views/channelSettings'; + +export { ChannelSettings } from './lib/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 f643d2c6674bb..26467705ec47a 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({}); } @@ -39,4 +38,4 @@ RocketChat.ChannelSettings = new class { }); return _.sortBy(allowedOptions, 'order'); } -}; +}(); diff --git a/app/channel-settings/client/startup/messageTypes.js b/app/channel-settings/client/startup/messageTypes.js new file mode 100644 index 0000000000000..5b73ce3ffba39 --- /dev/null +++ b/app/channel-settings/client/startup/messageTypes.js @@ -0,0 +1,55 @@ +import { Meteor } from 'meteor/meteor'; +import s from 'underscore.string'; + +import { MessageTypes } from '../../../ui-utils'; +import { t } from '../../../utils'; + +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 0000000000000..2a3d794c40466 --- /dev/null +++ b/app/channel-settings/client/startup/tabBar.js @@ -0,0 +1,15 @@ +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 0000000000000..4dc05e611fc10 --- /dev/null +++ b/app/channel-settings/client/startup/trackSettingsChange.js @@ -0,0 +1,48 @@ +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 98% rename from packages/rocketchat-channel-settings/client/stylesheets/channel-settings.css rename to app/channel-settings/client/stylesheets/channel-settings.css index dcf8e63bf85c5..396bf68ed351a 100644 --- a/packages/rocketchat-channel-settings/client/stylesheets/channel-settings.css +++ b/app/channel-settings/client/stylesheets/channel-settings.css @@ -1,4 +1,4 @@ -html.rtl .flex-tab { +.rtl .flex-tab { direction: rtl; & .channel-settings { diff --git a/packages/rocketchat-channel-settings/client/views/channelSettings.html b/app/channel-settings/client/views/channelSettings.html similarity index 95% rename from packages/rocketchat-channel-settings/client/views/channelSettings.html rename to app/channel-settings/client/views/channelSettings.html index 7cbb2c38f6092..7e736cbeee8c5 100644 --- a/packages/rocketchat-channel-settings/client/views/channelSettings.html +++ b/app/channel-settings/client/views/channelSettings.html @@ -28,7 +28,7 @@
@@ -54,7 +54,7 @@
@@ -330,18 +330,21 @@
\ No newline at end of file + diff --git a/packages/rocketchat-livechat/client/views/app/analytics/livechatAnalytics.js b/app/livechat/client/views/app/analytics/livechatAnalytics.js similarity index 97% rename from packages/rocketchat-livechat/client/views/app/analytics/livechatAnalytics.js rename to app/livechat/client/views/app/analytics/livechatAnalytics.js index 7f534af0d56e9..894b9940c4c7a 100644 --- a/packages/rocketchat-livechat/client/views/app/analytics/livechatAnalytics.js +++ b/app/livechat/client/views/app/analytics/livechatAnalytics.js @@ -1,12 +1,14 @@ -/* globals popover */ import { Meteor } from 'meteor/meteor'; import { ReactiveVar } from 'meteor/reactive-var'; import { Tracker } from 'meteor/tracker'; import { Template } from 'meteor/templating'; - import moment from 'moment'; + +import { handleError } from '../../../../../utils'; +import { popover } from '../../../../../ui-utils'; import { drawLineChart } from '../../../lib/chartHandler'; import { setDateRange, updateDateRange } from '../../../lib/dateHandler'; +import './livechatAnalytics.html'; let templateInstance; // current template instance/context let chartContext; // stores context of current chart, used to clean when redrawing @@ -171,9 +173,7 @@ Template.livechatAnalytics.onRendered(() => { updateAnalyticsOverview(); updateAnalyticsChart(); } - }); - }); Template.livechatAnalytics.events({ diff --git a/packages/rocketchat-livechat/client/views/app/analytics/livechatAnalyticsCustomDaterange.html b/app/livechat/client/views/app/analytics/livechatAnalyticsCustomDaterange.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/analytics/livechatAnalyticsCustomDaterange.html rename to app/livechat/client/views/app/analytics/livechatAnalyticsCustomDaterange.html diff --git a/packages/rocketchat-livechat/client/views/app/analytics/livechatAnalyticsCustomDaterange.js b/app/livechat/client/views/app/analytics/livechatAnalyticsCustomDaterange.js similarity index 88% rename from packages/rocketchat-livechat/client/views/app/analytics/livechatAnalyticsCustomDaterange.js rename to app/livechat/client/views/app/analytics/livechatAnalyticsCustomDaterange.js index f904ef5db7455..d2c11e9cc449f 100644 --- a/packages/rocketchat-livechat/client/views/app/analytics/livechatAnalyticsCustomDaterange.js +++ b/app/livechat/client/views/app/analytics/livechatAnalyticsCustomDaterange.js @@ -1,7 +1,10 @@ -/* globals popover */ import { Template } from 'meteor/templating'; import moment from 'moment'; + +import { handleError } from '../../../../../utils'; +import { popover } from '../../../../../ui-utils'; import { setDateRange } from '../../../lib/dateHandler'; +import './livechatAnalyticsCustomDaterange.html'; Template.livechatAnalyticsCustomDaterange.helpers({ diff --git a/packages/rocketchat-livechat/client/views/app/analytics/livechatAnalyticsDaterange.html b/app/livechat/client/views/app/analytics/livechatAnalyticsDaterange.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/analytics/livechatAnalyticsDaterange.html rename to app/livechat/client/views/app/analytics/livechatAnalyticsDaterange.html diff --git a/packages/rocketchat-livechat/client/views/app/analytics/livechatAnalyticsDaterange.js b/app/livechat/client/views/app/analytics/livechatAnalyticsDaterange.js similarity index 90% rename from packages/rocketchat-livechat/client/views/app/analytics/livechatAnalyticsDaterange.js rename to app/livechat/client/views/app/analytics/livechatAnalyticsDaterange.js index d18e5a05572ed..fa0f851a357e6 100644 --- a/packages/rocketchat-livechat/client/views/app/analytics/livechatAnalyticsDaterange.js +++ b/app/livechat/client/views/app/analytics/livechatAnalyticsDaterange.js @@ -1,11 +1,13 @@ -/* globals popover */ import { Template } from 'meteor/templating'; import moment from 'moment'; + +import { popover } from '../../../../../ui-utils'; import { setDateRange } from '../../../lib/dateHandler'; +import './livechatAnalyticsDaterange.html'; Template.livechatAnalyticsDaterange.helpers({ bold(prop) { - return (prop === Template.currentData().daterange.get().value) ? 'rc-popover__item--bold' : ''; + return prop === Template.currentData().daterange.get().value ? 'rc-popover__item--bold' : ''; }, }); diff --git a/packages/rocketchat-livechat/client/views/app/analytics/livechatRealTimeMonitoring.html b/app/livechat/client/views/app/analytics/livechatRealTimeMonitoring.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/analytics/livechatRealTimeMonitoring.html rename to app/livechat/client/views/app/analytics/livechatRealTimeMonitoring.html diff --git a/packages/rocketchat-livechat/client/views/app/analytics/livechatRealTimeMonitoring.js b/app/livechat/client/views/app/analytics/livechatRealTimeMonitoring.js similarity index 96% rename from packages/rocketchat-livechat/client/views/app/analytics/livechatRealTimeMonitoring.js rename to app/livechat/client/views/app/analytics/livechatRealTimeMonitoring.js index a1e02fd97feab..d45da0d4c100e 100644 --- a/packages/rocketchat-livechat/client/views/app/analytics/livechatRealTimeMonitoring.js +++ b/app/livechat/client/views/app/analytics/livechatRealTimeMonitoring.js @@ -2,8 +2,14 @@ import { Mongo } from 'meteor/mongo'; import { Template } from 'meteor/templating'; import moment from 'moment'; import { ReactiveVar } from 'meteor/reactive-var'; + import { drawLineChart, drawDoughnutChart, updateChart } from '../../../lib/chartHandler'; import { getTimingsChartData, getAgentStatusData, getConversationsOverviewData, getTimingsOverviewData } from '../../../lib/dataHandler'; +import { LivechatMonitoring } from '../../../collections/LivechatMonitoring'; +import { AgentUsers } from '../../../collections/AgentUsers'; +import { LivechatDepartment } from '../../../collections/LivechatDepartment'; +import './livechatRealTimeMonitoring.html'; + let chartContexts = {}; // stores context of current chart, used to clean when redrawing let templateInstance; @@ -192,7 +198,7 @@ const updateTimingsOverview = () => { const displayDepartmentChart = (val) => { const elem = document.getElementsByClassName('lc-chats-per-dept-chart-section')[0]; - elem.style.display = (val) ? 'block' : 'none'; + elem.style.display = val ? 'block' : 'none'; }; const updateVisitorsCount = () => { diff --git a/packages/rocketchat-livechat/client/views/app/integrations/livechatIntegrationFacebook.html b/app/livechat/client/views/app/integrations/livechatIntegrationFacebook.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/integrations/livechatIntegrationFacebook.html rename to app/livechat/client/views/app/integrations/livechatIntegrationFacebook.html diff --git a/packages/rocketchat-livechat/client/views/app/integrations/livechatIntegrationFacebook.js b/app/livechat/client/views/app/integrations/livechatIntegrationFacebook.js similarity index 96% rename from packages/rocketchat-livechat/client/views/app/integrations/livechatIntegrationFacebook.js rename to app/livechat/client/views/app/integrations/livechatIntegrationFacebook.js index d25a5ae524ba2..f76f0733cc1fc 100644 --- a/packages/rocketchat-livechat/client/views/app/integrations/livechatIntegrationFacebook.js +++ b/app/livechat/client/views/app/integrations/livechatIntegrationFacebook.js @@ -2,6 +2,10 @@ import { Meteor } from 'meteor/meteor'; import { ReactiveVar } from 'meteor/reactive-var'; import { Template } from 'meteor/templating'; +import { modal } from '../../../../../ui-utils'; +import { t, handleError } from '../../../../../utils'; +import './livechatIntegrationFacebook.html'; + Template.livechatIntegrationFacebook.helpers({ pages() { return Template.instance().pages.get(); diff --git a/packages/rocketchat-livechat/client/views/app/integrations/livechatIntegrationWebhook.html b/app/livechat/client/views/app/integrations/livechatIntegrationWebhook.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/integrations/livechatIntegrationWebhook.html rename to app/livechat/client/views/app/integrations/livechatIntegrationWebhook.html diff --git a/packages/rocketchat-livechat/client/views/app/integrations/livechatIntegrationWebhook.js b/app/livechat/client/views/app/integrations/livechatIntegrationWebhook.js similarity index 94% rename from packages/rocketchat-livechat/client/views/app/integrations/livechatIntegrationWebhook.js rename to app/livechat/client/views/app/integrations/livechatIntegrationWebhook.js index db3fc1f14524a..c1470419aca99 100644 --- a/packages/rocketchat-livechat/client/views/app/integrations/livechatIntegrationWebhook.js +++ b/app/livechat/client/views/app/integrations/livechatIntegrationWebhook.js @@ -1,4 +1,3 @@ -/* globals LivechatIntegration */ import { Meteor } from 'meteor/meteor'; import { ReactiveVar } from 'meteor/reactive-var'; import { Template } from 'meteor/templating'; @@ -6,6 +5,11 @@ import _ from 'underscore'; import s from 'underscore.string'; import toastr from 'toastr'; +import { modal } from '../../../../../ui-utils'; +import { t, handleError } from '../../../../../utils'; +import { LivechatIntegration } from '../../../collections/LivechatIntegration'; +import './livechatIntegrationWebhook.html'; + Template.livechatIntegrationWebhook.helpers({ webhookUrl() { const setting = LivechatIntegration.findOne('Livechat_webhookUrl'); diff --git a/packages/rocketchat-livechat/client/views/app/livechatAppearance.html b/app/livechat/client/views/app/livechatAppearance.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/livechatAppearance.html rename to app/livechat/client/views/app/livechatAppearance.html diff --git a/app/livechat/client/views/app/livechatAppearance.js b/app/livechat/client/views/app/livechatAppearance.js new file mode 100644 index 0000000000000..307abd3f30a77 --- /dev/null +++ b/app/livechat/client/views/app/livechatAppearance.js @@ -0,0 +1,413 @@ +/* eslint new-cap: ["error", { "newIsCapExceptions": ["jscolor"] }]*/ +import { Meteor } from 'meteor/meteor'; +import { Mongo } from 'meteor/mongo'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { Random } from 'meteor/random'; +import { Template } from 'meteor/templating'; +import s from 'underscore.string'; +import moment from 'moment'; +import toastr from 'toastr'; + +import { t, handleError } from '../../../../utils'; +import './livechatAppearance.html'; + +const LivechatAppearance = new Mongo.Collection('livechatAppearance'); + +Template.livechatAppearance.helpers({ + previewState() { + return Template.instance().previewState.get(); + }, + showOnline() { + return Template.instance().previewState.get().indexOf('offline') === -1; + }, + showOfflineForm() { + const state = Template.instance().previewState.get(); + return state === 'opened-offline' || state === 'closed-offline'; + }, + showOfflineSuccess() { + return Template.instance().previewState.get() === 'offline-success'; + }, + showOfflineUnavailable() { + return Template.instance().previewState.get() === 'offline-unavailable'; + }, + color() { + return Template.instance().color.get(); + }, + showAgentEmail() { + return Template.instance().showAgentEmail.get(); + }, + title() { + return Template.instance().title.get(); + }, + colorOffline() { + return Template.instance().colorOffline.get(); + }, + titleOffline() { + return Template.instance().titleOffline.get(); + }, + offlineMessage() { + return Template.instance().offlineMessage.get(); + }, + sampleOfflineMessage() { + return Template.instance().offlineMessage.get().replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1
$2'); + }, + offlineSuccessMessage() { + return Template.instance().offlineSuccessMessage.get(); + }, + sampleOfflineSuccessMessage() { + return Template.instance().offlineSuccessMessage.get().replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1
$2'); + }, + showAgentEmailFormTrueChecked() { + if (Template.instance().showAgentEmail.get()) { + return 'checked'; + } + }, + showAgentEmailFormFalseChecked() { + if (!Template.instance().showAgentEmail.get()) { + return 'checked'; + } + }, + displayOfflineFormTrueChecked() { + if (Template.instance().displayOfflineForm.get()) { + return 'checked'; + } + }, + displayOfflineFormFalseChecked() { + if (!Template.instance().displayOfflineForm.get()) { + return 'checked'; + } + }, + offlineUnavailableMessage() { + return Template.instance().offlineUnavailableMessage.get(); + }, + sampleOfflineUnavailableMessage() { + return Template.instance().offlineUnavailableMessage.get().replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1
$2'); + }, + emailOffline() { + return Template.instance().offlineEmail.get(); + }, + conversationFinishedMessage() { + return Template.instance().conversationFinishedMessage.get(); + }, + registrationFormEnabled() { + if (Template.instance().registrationFormEnabled.get()) { + return 'checked'; + } + }, + registrationFormNameFieldEnabled() { + if (Template.instance().registrationFormNameFieldEnabled.get()) { + return 'checked'; + } + }, + registrationFormEmailFieldEnabled() { + if (Template.instance().registrationFormEmailFieldEnabled.get()) { + return 'checked'; + } + }, + registrationFormMessage() { + return Template.instance().registrationFormMessage.get(); + }, + sampleColor() { + if (Template.instance().previewState.get().indexOf('offline') !== -1) { + return Template.instance().colorOffline.get(); + } + return Template.instance().color.get(); + }, + sampleTitle() { + if (Template.instance().previewState.get().indexOf('offline') !== -1) { + return Template.instance().titleOffline.get(); + } + return Template.instance().title.get(); + }, + sampleData() { + return { + messages: [ + { + _id: Random.id(), + u: { + username: 'guest', + }, + time: moment(this.ts).format('LT'), + date: moment(this.ts).format('LL'), + body: 'Hello', + sequential: null, + }, + { + _id: Random.id(), + u: { + username: 'rocketchat-agent', + }, + time: moment(this.ts).format('LT'), + date: moment(this.ts).format('LL'), + body: 'Hey, what can I help you with?', + sequential: null, + }, + { + _id: Random.id(), + u: { + username: 'guest', + }, + time: moment(this.ts).format('LT'), + date: moment(this.ts).format('LL'), + body: 'I\'m looking for informations about your product.', + sequential: null, + }, + { + _id: Random.id(), + u: { + username: 'rocketchat-agent', + }, + time: moment(this.ts).format('LT'), + date: moment(this.ts).format('LL'), + body: 'Our product is open source, you can do what you want with it! =D', + sequential: null, + }, + { + _id: Random.id(), + u: { + username: 'guest', + }, + time: moment(this.ts).format('LT'), + date: moment(this.ts).format('LL'), + body: 'Yay, thanks. That\'s awesome.', + sequential: null, + }, + { + _id: Random.id(), + u: { + username: 'rocketchat-agent', + }, + time: moment(this.ts).format('LT'), + date: moment(this.ts).format('LL'), + body: 'You\'re welcome.', + sequential: null, + }, + ], + }; + }, +}); + +Template.livechatAppearance.onCreated(function() { + this.subscribe('livechat:appearance'); + + this.previewState = new ReactiveVar('opened'); + + this.title = new ReactiveVar(null); + this.color = new ReactiveVar(null); + + this.showAgentEmail = new ReactiveVar(null); + this.displayOfflineForm = new ReactiveVar(null); + this.offlineUnavailableMessage = new ReactiveVar(null); + this.offlineMessage = new ReactiveVar(null); + this.offlineSuccessMessage = new ReactiveVar(null); + this.titleOffline = new ReactiveVar(null); + this.colorOffline = new ReactiveVar(null); + this.offlineEmail = new ReactiveVar(null); + this.conversationFinishedMessage = new ReactiveVar(null); + this.registrationFormEnabled = new ReactiveVar(null); + this.registrationFormNameFieldEnabled = new ReactiveVar(null); + this.registrationFormEmailFieldEnabled = new ReactiveVar(null); + this.registrationFormMessage = new ReactiveVar(null); + + this.autorun(() => { + const setting = LivechatAppearance.findOne('Livechat_title'); + this.title.set(setting && setting.value); + }); + this.autorun(() => { + const setting = LivechatAppearance.findOne('Livechat_title_color'); + this.color.set(setting && setting.value); + }); + this.autorun(() => { + const setting = LivechatAppearance.findOne('Livechat_show_agent_email'); + this.showAgentEmail.set(setting && setting.value); + }); + this.autorun(() => { + const setting = LivechatAppearance.findOne('Livechat_display_offline_form'); + this.displayOfflineForm.set(setting && setting.value); + }); + this.autorun(() => { + const setting = LivechatAppearance.findOne('Livechat_offline_form_unavailable'); + this.offlineUnavailableMessage.set(setting && setting.value); + }); + this.autorun(() => { + const setting = LivechatAppearance.findOne('Livechat_offline_message'); + this.offlineMessage.set(setting && setting.value); + }); + this.autorun(() => { + const setting = LivechatAppearance.findOne('Livechat_offline_success_message'); + this.offlineSuccessMessage.set(setting && setting.value); + }); + this.autorun(() => { + const setting = LivechatAppearance.findOne('Livechat_offline_title'); + this.titleOffline.set(setting && setting.value); + }); + this.autorun(() => { + const setting = LivechatAppearance.findOne('Livechat_offline_title_color'); + this.colorOffline.set(setting && setting.value); + }); + this.autorun(() => { + const setting = LivechatAppearance.findOne('Livechat_offline_email'); + this.offlineEmail.set(setting && setting.value); + }); + this.autorun(() => { + const setting = LivechatAppearance.findOne('Livechat_conversation_finished_message'); + this.conversationFinishedMessage.set(setting && setting.value); + }); + this.autorun(() => { + const setting = LivechatAppearance.findOne('Livechat_registration_form_message'); + this.registrationFormMessage.set(setting && setting.value); + }); + this.autorun(() => { + const setting = LivechatAppearance.findOne('Livechat_registration_form'); + this.registrationFormEnabled.set(setting && setting.value); + }); + this.autorun(() => { + const setting = LivechatAppearance.findOne('Livechat_name_field_registration_form'); + this.registrationFormNameFieldEnabled.set(setting && setting.value); + }); + this.autorun(() => { + const setting = LivechatAppearance.findOne('Livechat_email_field_registration_form'); + this.registrationFormEmailFieldEnabled.set(setting && setting.value); + }); +}); + +Template.livechatAppearance.events({ + 'change .preview-mode'(e, instance) { + instance.previewState.set(e.currentTarget.value); + }, + 'change .js-input-check'(e, instance) { + instance[e.currentTarget.name].set(e.currentTarget.checked); + }, + 'change .preview-settings, keyup .preview-settings'(e, instance) { + let { value } = e.currentTarget; + if (e.currentTarget.type === 'radio') { + value = value === 'true'; + } + instance[e.currentTarget.name].set(value); + }, + 'click .reset-settings'(e, instance) { + e.preventDefault(); + + const settingTitle = LivechatAppearance.findOne('Livechat_title'); + instance.title.set(settingTitle && settingTitle.value); + + const settingTitleColor = LivechatAppearance.findOne('Livechat_title_color'); + instance.color.set(settingTitleColor && settingTitleColor.value); + + const settingShowAgentEmail = LivechatAppearance.findOne('Livechat_show_agent_email'); + instance.showAgentEmail.set(settingShowAgentEmail && settingShowAgentEmail.value); + + const settingDiplayOffline = LivechatAppearance.findOne('Livechat_display_offline_form'); + instance.displayOfflineForm.set(settingDiplayOffline && settingDiplayOffline.value); + + const settingFormUnavailable = LivechatAppearance.findOne('Livechat_offline_form_unavailable'); + instance.offlineUnavailableMessage.set(settingFormUnavailable && settingFormUnavailable.value); + + const settingOfflineMessage = LivechatAppearance.findOne('Livechat_offline_message'); + instance.offlineMessage.set(settingOfflineMessage && settingOfflineMessage.value); + + const settingOfflineSuccess = LivechatAppearance.findOne('Livechat_offline_success_message'); + instance.offlineSuccessMessage.set(settingOfflineSuccess && settingOfflineSuccess.value); + + const settingOfflineTitle = LivechatAppearance.findOne('Livechat_offline_title'); + instance.titleOffline.set(settingOfflineTitle && settingOfflineTitle.value); + + const settingOfflineTitleColor = LivechatAppearance.findOne('Livechat_offline_title_color'); + instance.colorOffline.set(settingOfflineTitleColor && settingOfflineTitleColor.value); + + const settingConversationFinishedMessage = LivechatAppearance.findOne('Livechat_conversation_finished_message'); + instance.conversationFinishedMessage.set(settingConversationFinishedMessage && settingConversationFinishedMessage.value); + + const settingRegistrationFormEnabled = LivechatAppearance.findOne('Livechat_registration_form'); + instance.registrationFormEnabled.set(settingRegistrationFormEnabled && settingRegistrationFormEnabled.value); + + const settingRegistrationFormNameFieldEnabled = LivechatAppearance.findOne('Livechat_name_field_registration_form'); + instance.registrationFormNameFieldEnabled.set(settingRegistrationFormNameFieldEnabled && settingRegistrationFormNameFieldEnabled.value); + + const settingRegistrationFormEmailFieldEnabled = LivechatAppearance.findOne('Livechat_email_field_registration_form'); + instance.registrationFormEmailFieldEnabled.set(settingRegistrationFormEmailFieldEnabled && settingRegistrationFormEmailFieldEnabled.value); + + const settingRegistrationFormMessage = LivechatAppearance.findOne('Livechat_registration_form_message'); + instance.registrationFormMessage.set(settingRegistrationFormMessage && settingRegistrationFormMessage.value); + }, + 'submit .rocket-form'(e, instance) { + e.preventDefault(); + const settings = [ + { + _id: 'Livechat_title', + value: s.trim(instance.title.get()), + }, + { + _id: 'Livechat_title_color', + value: instance.color.get(), + }, + { + _id: 'Livechat_show_agent_email', + value: instance.showAgentEmail.get(), + }, + { + _id: 'Livechat_display_offline_form', + value: instance.displayOfflineForm.get(), + }, + { + _id: 'Livechat_offline_form_unavailable', + value: s.trim(instance.offlineUnavailableMessage.get()), + }, + { + _id: 'Livechat_offline_message', + value: s.trim(instance.offlineMessage.get()), + }, + { + _id: 'Livechat_offline_success_message', + value: s.trim(instance.offlineSuccessMessage.get()), + }, + { + _id: 'Livechat_offline_title', + value: s.trim(instance.titleOffline.get()), + }, + { + _id: 'Livechat_offline_title_color', + value: instance.colorOffline.get(), + }, + { + _id: 'Livechat_offline_email', + value: instance.$('#emailOffline').val(), + }, + { + _id: 'Livechat_conversation_finished_message', + value: s.trim(instance.conversationFinishedMessage.get()), + }, + { + _id: 'Livechat_registration_form', + value: instance.registrationFormEnabled.get(), + }, + { + _id: 'Livechat_name_field_registration_form', + value: instance.registrationFormNameFieldEnabled.get(), + }, + { + _id: 'Livechat_email_field_registration_form', + value: instance.registrationFormEmailFieldEnabled.get(), + }, + { + _id: 'Livechat_registration_form_message', + value: s.trim(instance.registrationFormMessage.get()), + }, + ]; + + Meteor.call('livechat:saveAppearance', settings, (err/* , success*/) => { + if (err) { + return handleError(err); + } + toastr.success(t('Settings_updated')); + }); + }, +}); + +Template.livechatAppearance.onRendered(function() { + Meteor.setTimeout(() => { + $('.colorpicker-input').each((index, el) => { + new jscolor(el); + }); + }, 500); +}); diff --git a/packages/rocketchat-livechat/client/views/app/livechatCurrentChats.html b/app/livechat/client/views/app/livechatCurrentChats.html similarity index 88% rename from packages/rocketchat-livechat/client/views/app/livechatCurrentChats.html rename to app/livechat/client/views/app/livechatCurrentChats.html index 4606ad8e4f095..7771bbcb18884 100644 --- a/packages/rocketchat-livechat/client/views/app/livechatCurrentChats.html +++ b/app/livechat/client/views/app/livechatCurrentChats.html @@ -9,12 +9,7 @@
- + {{> inputAutocomplete settings=agentAutocompleteSettings id="agent" class="rc-input__element" autocomplete="off"}}
@@ -57,11 +52,11 @@ {{#if isClosed}} {{else}} -   - {{/if}} - {{else}} +   + {{/if}} + {{else}}   - {{/requiresPermission}} + {{/requiresPermission}} {{/each}} diff --git a/app/livechat/client/views/app/livechatCurrentChats.js b/app/livechat/client/views/app/livechatCurrentChats.js new file mode 100644 index 0000000000000..4190b37b7548f --- /dev/null +++ b/app/livechat/client/views/app/livechatCurrentChats.js @@ -0,0 +1,145 @@ +import _ from 'underscore'; +import moment from 'moment'; +import { Meteor } from 'meteor/meteor'; +import { Mongo } from 'meteor/mongo'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { Template } from 'meteor/templating'; + +import { modal } from '../../../../ui-utils/client'; +import { t, handleError } from '../../../../utils/client'; +import './livechatCurrentChats.html'; + +const LivechatRoom = new Mongo.Collection('livechatRoom'); + +Template.livechatCurrentChats.helpers({ + livechatRoom() { + return LivechatRoom.find({ t: 'l' }, { sort: { ts: -1 } }); + }, + startedAt() { + return moment(this.ts).format('L LTS'); + }, + lastMessage() { + return moment(this.lm).format('L LTS'); + }, + servedBy() { + return this.servedBy && this.servedBy.username; + }, + status() { + return this.open ? t('Opened') : t('Closed'); + }, + isClosed() { + return !this.open; + }, + agentAutocompleteSettings() { + return { + limit: 10, + inputDelay: 300, + rules: [{ + collection: 'UserAndRoom', + subscription: 'userAutocomplete', + field: 'username', + template: Template.userSearch, + noMatchTemplate: Template.userSearchEmpty, + matchAll: true, + selector(match) { + return { term: match }; + }, + sort: 'username', + }], + }; + }, +}); + +Template.livechatCurrentChats.events({ + 'click .row-link'() { + FlowRouter.go('live', { id: this._id }); + }, + 'click .load-more'(event, instance) { + instance.limit.set(instance.limit.get() + 20); + }, + 'submit form'(event, instance) { + event.preventDefault(); + + const filter = {}; + $(':input', event.currentTarget).each(function() { + if (this.name) { + filter[this.name] = $(this).val(); + } + }); + + if (!_.isEmpty(filter.from)) { + filter.from = moment(filter.from, moment.localeData().longDateFormat('L')).toDate(); + } else { + delete filter.from; + } + + if (!_.isEmpty(filter.to)) { + filter.to = moment(filter.to, moment.localeData().longDateFormat('L')).toDate(); + } else { + delete filter.to; + } + + if (!_.isEmpty(instance.selectedAgent.get())) { + filter.agent = instance.selectedAgent.get(); + } + + instance.filter.set(filter); + instance.limit.set(20); + }, + 'click .remove-livechat-room'(event) { + event.preventDefault(); + event.stopPropagation(); + + modal.open({ + title: t('Are_you_sure'), + type: 'warning', + showCancelButton: true, + confirmButtonColor: '#DD6B55', + confirmButtonText: t('Yes'), + cancelButtonText: t('Cancel'), + closeOnConfirm: false, + html: false, + }, () => { + Meteor.call('livechat:removeRoom', this._id, function(error/* , result*/) { + if (error) { + return handleError(error); + } + modal.open({ + title: t('Deleted'), + text: t('Room_has_been_deleted'), + type: 'success', + timer: 1000, + showConfirmButton: false, + }); + }); + }); + }, + 'autocompleteselect input[id=agent]'(event, template, agent) { + template.selectedAgent.set(agent._id); + }, + + 'input [id=agent]'(event, template) { + const input = event.currentTarget; + if (input.value === '') { + template.selectedAgent.set(); + } + }, +}); + +Template.livechatCurrentChats.onCreated(function() { + this.limit = new ReactiveVar(20); + this.filter = new ReactiveVar({}); + this.selectedAgent = new ReactiveVar(); + this.autorun(() => { + this.subscribe('livechat:rooms', this.filter.get(), 0, this.limit.get()); + }); +}); + +Template.livechatCurrentChats.onRendered(function() { + this.$('.input-daterange').datepicker({ + autoclose: true, + todayHighlight: true, + format: moment.localeData().longDateFormat('L').toLowerCase(), + }); +}); diff --git a/packages/rocketchat-livechat/client/views/app/livechatCustomFieldForm.html b/app/livechat/client/views/app/livechatCustomFieldForm.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/livechatCustomFieldForm.html rename to app/livechat/client/views/app/livechatCustomFieldForm.html diff --git a/packages/rocketchat-livechat/client/views/app/livechatCustomFieldForm.js b/app/livechat/client/views/app/livechatCustomFieldForm.js similarity index 91% rename from packages/rocketchat-livechat/client/views/app/livechatCustomFieldForm.js rename to app/livechat/client/views/app/livechatCustomFieldForm.js index e5182a0a62193..c3ac8eb67b166 100644 --- a/packages/rocketchat-livechat/client/views/app/livechatCustomFieldForm.js +++ b/app/livechat/client/views/app/livechatCustomFieldForm.js @@ -4,6 +4,10 @@ import { FlowRouter } from 'meteor/kadira:flow-router'; import { Template } from 'meteor/templating'; import toastr from 'toastr'; +import { t, handleError } from '../../../../utils'; +import { LivechatCustomField } from '../../collections/LivechatCustomField'; +import './livechatCustomFieldForm.html'; + Template.livechatCustomFieldForm.helpers({ customField() { return Template.instance().customField.get(); diff --git a/packages/rocketchat-livechat/client/views/app/livechatCustomFields.html b/app/livechat/client/views/app/livechatCustomFields.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/livechatCustomFields.html rename to app/livechat/client/views/app/livechatCustomFields.html diff --git a/packages/rocketchat-livechat/client/views/app/livechatCustomFields.js b/app/livechat/client/views/app/livechatCustomFields.js similarity index 84% rename from packages/rocketchat-livechat/client/views/app/livechatCustomFields.js rename to app/livechat/client/views/app/livechatCustomFields.js index ba1fba1727c7b..7d23b002c84e3 100644 --- a/packages/rocketchat-livechat/client/views/app/livechatCustomFields.js +++ b/app/livechat/client/views/app/livechatCustomFields.js @@ -2,6 +2,11 @@ import { Meteor } from 'meteor/meteor'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { Template } from 'meteor/templating'; +import { modal } from '../../../../ui-utils'; +import { t, handleError } from '../../../../utils'; +import { LivechatCustomField } from '../../collections/LivechatCustomField'; +import './livechatCustomFields.html'; + Template.livechatCustomFields.helpers({ customFields() { return LivechatCustomField.find(); diff --git a/packages/rocketchat-livechat/client/views/app/livechatDashboard.html b/app/livechat/client/views/app/livechatDashboard.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/livechatDashboard.html rename to app/livechat/client/views/app/livechatDashboard.html diff --git a/packages/rocketchat-livechat/client/views/app/livechatDepartmentForm.html b/app/livechat/client/views/app/livechatDepartmentForm.html similarity index 83% rename from packages/rocketchat-livechat/client/views/app/livechatDepartmentForm.html rename to app/livechat/client/views/app/livechatDepartmentForm.html index efcddc4b0a254..897a4bc8a1cee 100644 --- a/packages/rocketchat-livechat/client/views/app/livechatDepartmentForm.html +++ b/app/livechat/client/views/app/livechatDepartmentForm.html @@ -30,6 +30,19 @@
+
+ +
+ +
+
+
+ +
+ + +
+

{{_ "Agents"}}

diff --git a/packages/rocketchat-livechat/client/views/app/livechatDepartmentForm.js b/app/livechat/client/views/app/livechatDepartmentForm.js similarity index 81% rename from packages/rocketchat-livechat/client/views/app/livechatDepartmentForm.js rename to app/livechat/client/views/app/livechatDepartmentForm.js index b6c202e745339..a319d728e7c9a 100644 --- a/packages/rocketchat-livechat/client/views/app/livechatDepartmentForm.js +++ b/app/livechat/client/views/app/livechatDepartmentForm.js @@ -5,6 +5,12 @@ import { Template } from 'meteor/templating'; import _ from 'underscore'; import toastr from 'toastr'; +import { t, handleError } from '../../../../utils'; +import { AgentUsers } from '../../collections/AgentUsers'; +import { LivechatDepartment } from '../../collections/LivechatDepartment'; +import { LivechatDepartmentAgents } from '../../collections/LivechatDepartmentAgents'; +import './livechatDepartmentForm.html'; + Template.livechatDepartmentForm.helpers({ department() { return Template.instance().department.get(); @@ -23,6 +29,10 @@ Template.livechatDepartmentForm.helpers({ const department = Template.instance().department.get(); return department.showOnRegistration === value || (department.showOnRegistration === undefined && value === true); }, + showOnOfflineForm(value) { + const department = Template.instance().department.get(); + return department.showOnOfflineForm === value || (department.showOnOfflineForm === undefined && value === true); + }, }); Template.livechatDepartmentForm.events({ @@ -35,6 +45,8 @@ Template.livechatDepartmentForm.events({ const name = instance.$('input[name=name]').val(); const description = instance.$('textarea[name=description]').val(); const showOnRegistration = instance.$('input[name=showOnRegistration]:checked').val(); + const email = instance.$('input[name=email]').val(); + const showOnOfflineForm = instance.$('input[name=showOnOfflineForm]:checked').val(); if (enabled !== '1' && enabled !== '0') { return toastr.error(t('Please_select_enabled_yes_or_no')); @@ -44,6 +56,10 @@ Template.livechatDepartmentForm.events({ return toastr.error(t('Please_fill_a_name')); } + if (email.trim() === '' && showOnOfflineForm === '1') { + return toastr.error(t('Please_fill_an_email')); + } + const oldBtnValue = $btn.html(); $btn.html(t('Saving')); @@ -52,6 +68,8 @@ Template.livechatDepartmentForm.events({ name: name.trim(), description: description.trim(), showOnRegistration: showOnRegistration === '1', + showOnOfflineForm: showOnOfflineForm === '1', + email: email.trim(), }; const departmentAgents = []; diff --git a/packages/rocketchat-livechat/client/views/app/livechatDepartments.html b/app/livechat/client/views/app/livechatDepartments.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/livechatDepartments.html rename to app/livechat/client/views/app/livechatDepartments.html diff --git a/app/livechat/client/views/app/livechatDepartments.js b/app/livechat/client/views/app/livechatDepartments.js new file mode 100644 index 0000000000000..b984e565063cc --- /dev/null +++ b/app/livechat/client/views/app/livechatDepartments.js @@ -0,0 +1,54 @@ +import { Meteor } from 'meteor/meteor'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { Template } from 'meteor/templating'; + +import { modal } from '../../../../ui-utils'; +import { t, handleError } from '../../../../utils'; +import { LivechatDepartment } from '../../collections/LivechatDepartment'; +import './livechatDepartments.html'; + +Template.livechatDepartments.helpers({ + departments() { + return LivechatDepartment.find(); + }, +}); + +Template.livechatDepartments.events({ + 'click .remove-department'(e/* , instance*/) { + e.preventDefault(); + e.stopPropagation(); + + modal.open({ + title: t('Are_you_sure'), + type: 'warning', + showCancelButton: true, + confirmButtonColor: '#DD6B55', + confirmButtonText: t('Yes'), + cancelButtonText: t('Cancel'), + closeOnConfirm: false, + html: false, + }, () => { + Meteor.call('livechat:removeDepartment', this._id, function(error/* , result*/) { + if (error) { + return handleError(error); + } + modal.open({ + title: t('Removed'), + text: t('Department_removed'), + type: 'success', + timer: 1000, + showConfirmButton: false, + }); + }); + }); + }, + + 'click .department-info'(e/* , instance*/) { + e.preventDefault(); + FlowRouter.go('livechat-department-edit', { _id: this._id }); + }, +}); + +Template.livechatDepartments.onCreated(function() { + this.subscribe('livechat:departments'); +}); diff --git a/app/livechat/client/views/app/livechatInstallation.html b/app/livechat/client/views/app/livechatInstallation.html new file mode 100644 index 0000000000000..3aced66ab5961 --- /dev/null +++ b/app/livechat/client/views/app/livechatInstallation.html @@ -0,0 +1,18 @@ + diff --git a/app/livechat/client/views/app/livechatInstallation.js b/app/livechat/client/views/app/livechatInstallation.js new file mode 100644 index 0000000000000..45aea09287d1e --- /dev/null +++ b/app/livechat/client/views/app/livechatInstallation.js @@ -0,0 +1,37 @@ +import { Template } from 'meteor/templating'; +import s from 'underscore.string'; + +import { settings } from '../../../../settings'; +import './livechatInstallation.html'; + +const latestVersion = '1.0.0'; + +Template.livechatInstallation.helpers({ + oldScript() { + const siteUrl = s.rtrim(settings.get('Site_Url'), '/'); + return ` + +`; + }, + + script() { + const siteUrl = s.rtrim(settings.get('Site_Url'), '/'); + return ` + +`; + }, +}); diff --git a/packages/rocketchat-livechat/client/views/app/livechatNotSubscribed.html b/app/livechat/client/views/app/livechatNotSubscribed.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/livechatNotSubscribed.html rename to app/livechat/client/views/app/livechatNotSubscribed.html diff --git a/packages/rocketchat-livechat/client/views/app/livechatOfficeHours.html b/app/livechat/client/views/app/livechatOfficeHours.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/livechatOfficeHours.html rename to app/livechat/client/views/app/livechatOfficeHours.html diff --git a/app/livechat/client/views/app/livechatOfficeHours.js b/app/livechat/client/views/app/livechatOfficeHours.js new file mode 100644 index 0000000000000..34de2ee6d02ad --- /dev/null +++ b/app/livechat/client/views/app/livechatOfficeHours.js @@ -0,0 +1,165 @@ +import { Meteor } from 'meteor/meteor'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { Template } from 'meteor/templating'; +import { TAPi18n } from 'meteor/tap:i18n'; +import toastr from 'toastr'; +import moment from 'moment'; + +import { t, handleError } from '../../../../utils'; +import { settings } from '../../../../settings'; +import { LivechatOfficeHour } from '../../collections/livechatOfficeHour'; +import './livechatOfficeHours.html'; + +Template.livechatOfficeHours.helpers({ + days() { + return LivechatOfficeHour.find({}, { sort: { code: 1 } }); + }, + startName(day) { + return `${ day.day }_start`; + }, + finishName(day) { + return `${ day.day }_finish`; + }, + openName(day) { + return `${ day.day }_open`; + }, + start(day) { + return Template.instance().dayVars[day.day].start.get(); + }, + finish(day) { + return Template.instance().dayVars[day.day].finish.get(); + }, + name(day) { + return TAPi18n.__(day.day); + }, + open(day) { + return Template.instance().dayVars[day.day].open.get(); + }, + enableOfficeHoursTrueChecked() { + if (Template.instance().enableOfficeHours.get()) { + return 'checked'; + } + }, + enableOfficeHoursFalseChecked() { + if (!Template.instance().enableOfficeHours.get()) { + return 'checked'; + } + }, +}); + +Template.livechatOfficeHours.events({ + 'change .preview-settings, keydown .preview-settings'(e, instance) { + const temp = e.currentTarget.name.split('_'); + + const newTime = moment(e.currentTarget.value, 'HH:mm'); + + // check if start and stop do not cross + if (temp[1] === 'start') { + if (newTime.isSameOrBefore(moment(instance.dayVars[temp[0]].finish.get(), 'HH:mm'))) { + instance.dayVars[temp[0]].start.set(e.currentTarget.value); + } else { + e.currentTarget.value = instance.dayVars[temp[0]].start.get(); + } + } else if (temp[1] === 'finish') { + if (newTime.isSameOrAfter(moment(instance.dayVars[temp[0]].start.get(), 'HH:mm'))) { + instance.dayVars[temp[0]].finish.set(e.currentTarget.value); + } else { + e.currentTarget.value = instance.dayVars[temp[0]].finish.get(); + } + } + }, + 'change .dayOpenCheck input'(e, instance) { + const temp = e.currentTarget.name.split('_'); + instance.dayVars[temp[0]][temp[1]].set(e.target.checked); + }, + 'change .preview-settings, keyup .preview-settings'(e, instance) { + let { value } = e.currentTarget; + if (e.currentTarget.type === 'radio') { + value = value === 'true'; + instance[e.currentTarget.name].set(value); + } + }, + 'submit .rocket-form'(e, instance) { + e.preventDefault(); + + // convert all times to utc then update them in db + for (const d in instance.dayVars) { + if (instance.dayVars.hasOwnProperty(d)) { + const day = instance.dayVars[d]; + const start_utc = moment(day.start.get(), 'HH:mm').utc().format('HH:mm'); + const finish_utc = moment(day.finish.get(), 'HH:mm').utc().format('HH:mm'); + + Meteor.call('livechat:saveOfficeHours', d, start_utc, finish_utc, day.open.get(), function(err /* ,result*/) { + if (err) { + return handleError(err); + } + }); + } + } + + settings.set('Livechat_enable_office_hours', instance.enableOfficeHours.get(), (err/* , success*/) => { + if (err) { + return handleError(err); + } + toastr.success(t('Office_hours_updated')); + }); + }, +}); + +Template.livechatOfficeHours.onCreated(function() { + this.dayVars = { + Monday: { + start: new ReactiveVar('08:00'), + finish: new ReactiveVar('20:00'), + open: new ReactiveVar(true), + }, + Tuesday: { + start: new ReactiveVar('00:00'), + finish: new ReactiveVar('00:00'), + open: new ReactiveVar(true), + }, + Wednesday: { + start: new ReactiveVar('00:00'), + finish: new ReactiveVar('00:00'), + open: new ReactiveVar(true), + }, + Thursday: { + start: new ReactiveVar('00:00'), + finish: new ReactiveVar('00:00'), + open: new ReactiveVar(true), + }, + Friday: { + start: new ReactiveVar('00:00'), + finish: new ReactiveVar('00:00'), + open: new ReactiveVar(true), + }, + Saturday: { + start: new ReactiveVar('00:00'), + finish: new ReactiveVar('00:00'), + open: new ReactiveVar(false), + }, + Sunday: { + start: new ReactiveVar('00:00'), + finish: new ReactiveVar('00:00'), + open: new ReactiveVar(false), + }, + }; + + this.autorun(() => { + this.subscribe('livechat:officeHour'); + + if (this.subscriptionsReady()) { + LivechatOfficeHour.find().forEach(function(d) { + Template.instance().dayVars[d.day].start.set(moment.utc(d.start, 'HH:mm').local().format('HH:mm')); + Template.instance().dayVars[d.day].finish.set(moment.utc(d.finish, 'HH:mm').local().format('HH:mm')); + Template.instance().dayVars[d.day].open.set(d.open); + }); + } + }); + + this.enableOfficeHours = new ReactiveVar(null); + + this.autorun(() => { + this.enableOfficeHours.set(settings.get('Livechat_enable_office_hours')); + }); +}); diff --git a/packages/rocketchat-livechat/client/views/app/livechatQueue.html b/app/livechat/client/views/app/livechatQueue.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/livechatQueue.html rename to app/livechat/client/views/app/livechatQueue.html diff --git a/app/livechat/client/views/app/livechatQueue.js b/app/livechat/client/views/app/livechatQueue.js new file mode 100644 index 0000000000000..873bcc07bb926 --- /dev/null +++ b/app/livechat/client/views/app/livechatQueue.js @@ -0,0 +1,72 @@ +import { Meteor } from 'meteor/meteor'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { Template } from 'meteor/templating'; + +import { settings } from '../../../../settings'; +import { hasRole } from '../../../../authorization'; +import { Users } from '../../../../models'; +import { LivechatDepartment } from '../../collections/LivechatDepartment'; +import { LivechatQueueUser } from '../../collections/LivechatQueueUser'; +import { AgentUsers } from '../../collections/AgentUsers'; +import './livechatQueue.html'; + +Template.livechatQueue.helpers({ + departments() { + return LivechatDepartment.find({ + enabled: true, + }, { + sort: { + name: 1, + }, + }); + }, + + users() { + const users = []; + + const showOffline = Template.instance().showOffline.get(); + + LivechatQueueUser.find({ + departmentId: this._id, + }, { + sort: { + count: 1, + order: 1, + username: 1, + }, + }).forEach((user) => { + const options = { fields: { _id: 1 } }; + const userFilter = { _id: user.agentId, status: { $ne: 'offline' } }; + const agentFilter = { _id: user.agentId, statusLivechat: 'available' }; + + if (showOffline[this._id] || (Meteor.users.findOne(userFilter, options) && AgentUsers.findOne(agentFilter, options))) { + users.push(user); + } + }); + + return users; + }, + + hasPermission() { + const user = Users.findOne(Meteor.userId(), { fields: { statusLivechat: 1 } }); + return hasRole(Meteor.userId(), 'livechat-manager') || (user.statusLivechat === 'available' && settings.get('Livechat_show_queue_list_link')); + }, +}); + +Template.livechatQueue.events({ + 'click .show-offline'(event, instance) { + const showOffline = instance.showOffline.get(); + + showOffline[this._id] = event.currentTarget.checked; + + instance.showOffline.set(showOffline); + }, +}); + +Template.livechatQueue.onCreated(function() { + this.showOffline = new ReactiveVar({}); + + this.subscribe('livechat:queue'); + this.subscribe('livechat:agents'); + this.subscribe('livechat:departments'); +}); diff --git a/app/livechat/client/views/app/livechatReadOnly.html b/app/livechat/client/views/app/livechatReadOnly.html new file mode 100644 index 0000000000000..9b2f6d484a2b2 --- /dev/null +++ b/app/livechat/client/views/app/livechatReadOnly.html @@ -0,0 +1,16 @@ + diff --git a/app/livechat/client/views/app/livechatReadOnly.js b/app/livechat/client/views/app/livechatReadOnly.js new file mode 100644 index 0000000000000..ec74925be8c6d --- /dev/null +++ b/app/livechat/client/views/app/livechatReadOnly.js @@ -0,0 +1,56 @@ +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { FlowRouter } from 'meteor/kadira:flow-router'; + +import { ChatRoom } from '../../../../models'; +import { LivechatInquiry } from '../../../lib/LivechatInquiry'; +import { call } from '../../../../ui-utils/client'; +import { settings } from '../../../../settings'; +import './livechatReadOnly.html'; + +Template.livechatReadOnly.helpers({ + inquiryOpen() { + const inquiry = Template.instance().inquiry.get(); + return inquiry || FlowRouter.go('/home'); + }, + + roomOpen() { + const room = Template.instance().room.get(); + return room && room.open === true; + }, + + guestPool() { + return settings.get('Livechat_Routing_Method') === 'Guest_Pool'; + }, +}); + +Template.livechatReadOnly.events({ + async 'click .js-take-it'(event, instance) { + event.preventDefault(); + event.stopPropagation(); + + const inquiry = instance.inquiry.get(); + const { _id } = inquiry; + await call('livechat:takeInquiry', _id); + }, +}); + +Template.livechatReadOnly.onCreated(function() { + this.rid = Template.currentData().rid; + this.room = new ReactiveVar(); + this.inquiry = new ReactiveVar(); + + this.autorun(() => { + const inquiry = LivechatInquiry.findOne({ agents: Meteor.userId(), status: 'open', rid: this.rid }); + this.inquiry.set(inquiry); + + if (inquiry) { + this.subscribe('livechat:inquiry', inquiry._id); + } + }); + + this.autorun(() => { + this.room.set(ChatRoom.findOne({ _id: Template.currentData().rid }, { fields: { open: 1 } })); + }); +}); diff --git a/packages/rocketchat-livechat/client/views/app/livechatTriggers.html b/app/livechat/client/views/app/livechatTriggers.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/livechatTriggers.html rename to app/livechat/client/views/app/livechatTriggers.html diff --git a/app/livechat/client/views/app/livechatTriggers.js b/app/livechat/client/views/app/livechatTriggers.js new file mode 100644 index 0000000000000..0cde3fd547909 --- /dev/null +++ b/app/livechat/client/views/app/livechatTriggers.js @@ -0,0 +1,83 @@ +import { Meteor } from 'meteor/meteor'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { Template } from 'meteor/templating'; + +import { modal } from '../../../../ui-utils'; +import { t, handleError } from '../../../../utils'; +import { LivechatTrigger } from '../../collections/LivechatTrigger'; +import './livechatTriggers.html'; + +Template.livechatTriggers.helpers({ + triggers() { + return LivechatTrigger.find(); + }, +}); + +Template.livechatTriggers.events({ + 'click .remove-trigger'(e/* , instance*/) { + e.preventDefault(); + e.stopPropagation(); + + modal.open({ + title: t('Are_you_sure'), + type: 'warning', + showCancelButton: true, + confirmButtonColor: '#DD6B55', + confirmButtonText: t('Yes'), + cancelButtonText: t('Cancel'), + closeOnConfirm: false, + html: false, + }, () => { + Meteor.call('livechat:removeTrigger', this._id, function(error/* , result*/) { + if (error) { + return handleError(error); + } + modal.open({ + title: t('Removed'), + text: t('Trigger_removed'), + type: 'success', + timer: 1000, + showConfirmButton: false, + }); + }); + }); + }, + + 'click .trigger-info'(e/* , instance*/) { + e.preventDefault(); + FlowRouter.go('livechat-trigger-edit', { _id: this._id }); + }, + + 'click .delete-trigger'(e/* , instance*/) { + e.preventDefault(); + + modal.open({ + title: t('Are_you_sure'), + type: 'warning', + showCancelButton: true, + confirmButtonColor: '#DD6B55', + confirmButtonText: t('Yes'), + cancelButtonText: t('Cancel'), + closeOnConfirm: false, + html: false, + }, () => { + Meteor.call('livechat:removeTrigger', this._id, function(error/* , result*/) { + if (error) { + return handleError(error); + } + + modal.open({ + title: t('Removed'), + text: t('Trigger_removed'), + type: 'success', + timer: 1000, + showConfirmButton: false, + }); + }); + }); + }, +}); + +Template.livechatTriggers.onCreated(function() { + this.subscribe('livechat:triggers'); +}); diff --git a/packages/rocketchat-livechat/client/views/app/livechatTriggersForm.html b/app/livechat/client/views/app/livechatTriggersForm.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/livechatTriggersForm.html rename to app/livechat/client/views/app/livechatTriggersForm.html diff --git a/packages/rocketchat-livechat/client/views/app/livechatTriggersForm.js b/app/livechat/client/views/app/livechatTriggersForm.js similarity index 94% rename from packages/rocketchat-livechat/client/views/app/livechatTriggersForm.js rename to app/livechat/client/views/app/livechatTriggersForm.js index 45beac6c6a10e..d8e34be287152 100644 --- a/packages/rocketchat-livechat/client/views/app/livechatTriggersForm.js +++ b/app/livechat/client/views/app/livechatTriggersForm.js @@ -3,6 +3,10 @@ import { FlowRouter } from 'meteor/kadira:flow-router'; import { Template } from 'meteor/templating'; import toastr from 'toastr'; +import { t, handleError } from '../../../../utils'; +import { LivechatTrigger } from '../../collections/LivechatTrigger'; +import './livechatTriggersForm.html'; + Template.livechatTriggersForm.helpers({ name() { const trigger = LivechatTrigger.findOne(FlowRouter.getParam('_id')); diff --git a/packages/rocketchat-livechat/client/views/app/livechatUsers.html b/app/livechat/client/views/app/livechatUsers.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/livechatUsers.html rename to app/livechat/client/views/app/livechatUsers.html diff --git a/packages/rocketchat-livechat/client/views/app/livechatUsers.js b/app/livechat/client/views/app/livechatUsers.js similarity index 95% rename from packages/rocketchat-livechat/client/views/app/livechatUsers.js rename to app/livechat/client/views/app/livechatUsers.js index f523bafafea79..2843ecd6d807e 100644 --- a/packages/rocketchat-livechat/client/views/app/livechatUsers.js +++ b/app/livechat/client/views/app/livechatUsers.js @@ -3,6 +3,12 @@ import { Mongo } from 'meteor/mongo'; import { Template } from 'meteor/templating'; import _ from 'underscore'; import toastr from 'toastr'; + +import { modal } from '../../../../ui-utils'; +import { t, handleError } from '../../../../utils'; +import { AgentUsers } from '../../collections/AgentUsers'; +import './livechatUsers.html'; + let ManagerUsers; Meteor.startup(function() { diff --git a/packages/rocketchat-livechat/client/views/app/tabbar/externalSearch.html b/app/livechat/client/views/app/tabbar/externalSearch.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/tabbar/externalSearch.html rename to app/livechat/client/views/app/tabbar/externalSearch.html diff --git a/packages/rocketchat-livechat/client/views/app/tabbar/externalSearch.js b/app/livechat/client/views/app/tabbar/externalSearch.js similarity index 75% rename from packages/rocketchat-livechat/client/views/app/tabbar/externalSearch.js rename to app/livechat/client/views/app/tabbar/externalSearch.js index 6d4a403a9664a..e98b788f62234 100644 --- a/packages/rocketchat-livechat/client/views/app/tabbar/externalSearch.js +++ b/app/livechat/client/views/app/tabbar/externalSearch.js @@ -1,8 +1,11 @@ import { Template } from 'meteor/templating'; +import { LivechatExternalMessage } from '../../../../lib/LivechatExternalMessage'; +import './externalSearch.html'; + Template.externalSearch.helpers({ messages() { - return RocketChat.models.LivechatExternalMessage.findByRoomId(this.rid, { ts: 1 }); + return LivechatExternalMessage.findByRoomId(this.rid, { ts: 1 }); }, }); diff --git a/packages/rocketchat-livechat/client/views/app/tabbar/visitorEdit.html b/app/livechat/client/views/app/tabbar/visitorEdit.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/tabbar/visitorEdit.html rename to app/livechat/client/views/app/tabbar/visitorEdit.html diff --git a/packages/rocketchat-livechat/client/views/app/tabbar/visitorEdit.js b/app/livechat/client/views/app/tabbar/visitorEdit.js similarity index 90% rename from packages/rocketchat-livechat/client/views/app/tabbar/visitorEdit.js rename to app/livechat/client/views/app/tabbar/visitorEdit.js index 629250a1647b4..e417b833910f9 100644 --- a/packages/rocketchat-livechat/client/views/app/tabbar/visitorEdit.js +++ b/app/livechat/client/views/app/tabbar/visitorEdit.js @@ -1,9 +1,13 @@ -/* globals LivechatVisitor */ import { Meteor } from 'meteor/meteor'; import { ReactiveVar } from 'meteor/reactive-var'; import { Template } from 'meteor/templating'; import toastr from 'toastr'; +import { ChatRoom } from '../../../../../models'; +import { t } from '../../../../../utils'; +import { LivechatVisitor } from '../../../collections/LivechatVisitor'; +import './visitorEdit.html'; + Template.visitorEdit.helpers({ visitor() { return Template.instance().visitor.get(); diff --git a/packages/rocketchat-livechat/client/views/app/tabbar/visitorForward.html b/app/livechat/client/views/app/tabbar/visitorForward.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/tabbar/visitorForward.html rename to app/livechat/client/views/app/tabbar/visitorForward.html diff --git a/packages/rocketchat-livechat/client/views/app/tabbar/visitorForward.js b/app/livechat/client/views/app/tabbar/visitorForward.js similarity index 82% rename from packages/rocketchat-livechat/client/views/app/tabbar/visitorForward.js rename to app/livechat/client/views/app/tabbar/visitorForward.js index f874f91258a0e..ab7a6cf0d8485 100644 --- a/packages/rocketchat-livechat/client/views/app/tabbar/visitorForward.js +++ b/app/livechat/client/views/app/tabbar/visitorForward.js @@ -4,6 +4,12 @@ import { FlowRouter } from 'meteor/kadira:flow-router'; import { Template } from 'meteor/templating'; import toastr from 'toastr'; +import { ChatRoom } from '../../../../../models'; +import { t } from '../../../../../utils'; +import { LivechatDepartment } from '../../../collections/LivechatDepartment'; +import { AgentUsers } from '../../../collections/AgentUsers'; +import './visitorForward.html'; + Template.visitorForward.helpers({ visitor() { return Template.instance().visitor.get(); @@ -15,7 +21,13 @@ Template.visitorForward.helpers({ return LivechatDepartment.find({ enabled: true }); }, agents() { - return AgentUsers.find({ _id: { $ne: Meteor.userId() } }, { sort: { name: 1, username: 1 } }); + const query = { + _id: { $ne: Meteor.userId() }, + status: { $ne: 'offline' }, + statusLivechat: 'available', + }; + + return AgentUsers.find(query, { sort: { name: 1, username: 1 } }); }, agentName() { return this.name || this.username; diff --git a/packages/rocketchat-livechat/client/views/app/tabbar/visitorHistory.html b/app/livechat/client/views/app/tabbar/visitorHistory.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/tabbar/visitorHistory.html rename to app/livechat/client/views/app/tabbar/visitorHistory.html diff --git a/app/livechat/client/views/app/tabbar/visitorHistory.js b/app/livechat/client/views/app/tabbar/visitorHistory.js new file mode 100644 index 0000000000000..1767d3b0cf59b --- /dev/null +++ b/app/livechat/client/views/app/tabbar/visitorHistory.js @@ -0,0 +1,50 @@ +import { ReactiveVar } from 'meteor/reactive-var'; +import { Template } from 'meteor/templating'; +import { Mongo } from 'meteor/mongo'; +import moment from 'moment'; + +import { ChatRoom } from '../../../../../models'; +import './visitorHistory.html'; + +const visitorHistory = new Mongo.Collection('visitor_history'); + +Template.visitorHistory.helpers({ + historyLoaded() { + return !Template.instance().loadHistory.ready(); + }, + + previousChats() { + return visitorHistory.find({ + _id: { $ne: this.rid }, + 'v._id': Template.instance().visitorId.get(), + }, { + sort: { + ts: -1, + }, + }); + }, + + title() { + let title = moment(this.ts).format('L LTS'); + + if (this.label) { + title += ` - ${ this.label }`; + } + + return title; + }, +}); + +Template.visitorHistory.onCreated(function() { + const currentData = Template.currentData(); + this.visitorId = new ReactiveVar(); + + this.autorun(() => { + const room = ChatRoom.findOne({ _id: Template.currentData().rid }); + this.visitorId.set(room.v._id); + }); + + if (currentData && currentData.rid) { + this.loadHistory = this.subscribe('livechat:visitorHistory', { rid: currentData.rid }); + } +}); diff --git a/packages/rocketchat-livechat/client/views/app/tabbar/visitorInfo.html b/app/livechat/client/views/app/tabbar/visitorInfo.html similarity index 89% rename from packages/rocketchat-livechat/client/views/app/tabbar/visitorInfo.html rename to app/livechat/client/views/app/tabbar/visitorInfo.html index 9f5297ffd4cf1..b6649092fb82f 100644 --- a/packages/rocketchat-livechat/client/views/app/tabbar/visitorInfo.html +++ b/app/livechat/client/views/app/tabbar/visitorInfo.html @@ -38,6 +38,14 @@

{{username}}

{{/with}} + + {{#with department}} +
+
    + {{#if name}}
  • {{_ "Department"}}: {{name}}
  • {{/if}} +
+
+ {{/with}}
{{#if canSeeButtons}} @@ -46,10 +54,9 @@

{{username}}

{{#if roomOpen}} - {{/if}} - - {{#if guestPool}} - + {{#if guestPool}} + + {{/if}} {{/if}} {{/if}} diff --git a/app/livechat/client/views/app/tabbar/visitorInfo.js b/app/livechat/client/views/app/tabbar/visitorInfo.js new file mode 100644 index 0000000000000..50bfcaddd6b4f --- /dev/null +++ b/app/livechat/client/views/app/tabbar/visitorInfo.js @@ -0,0 +1,275 @@ +import { Meteor } from 'meteor/meteor'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { Session } from 'meteor/session'; +import { Template } from 'meteor/templating'; +import { TAPi18n } from 'meteor/tap:i18n'; +import _ from 'underscore'; +import s from 'underscore.string'; +import moment from 'moment'; +import UAParser from 'ua-parser-js'; + +import { modal } from '../../../../../ui-utils'; +import { ChatRoom, Rooms, Subscriptions } from '../../../../../models'; +import { settings } from '../../../../../settings'; +import { t, handleError, roomTypes } from '../../../../../utils'; +import { hasRole } from '../../../../../authorization'; +import { LivechatVisitor } from '../../../collections/LivechatVisitor'; +import { LivechatDepartment } from '../../../collections/LivechatDepartment'; +import './visitorInfo.html'; + +Template.visitorInfo.helpers({ + user() { + const user = Template.instance().user.get(); + if (user && user.userAgent) { + const ua = new UAParser(); + ua.setUA(user.userAgent); + + user.os = `${ ua.getOS().name } ${ ua.getOS().version }`; + if (['Mac OS', 'iOS'].indexOf(ua.getOS().name) !== -1) { + user.osIcon = 'icon-apple'; + } else { + user.osIcon = `icon-${ ua.getOS().name.toLowerCase() }`; + } + user.browser = `${ ua.getBrowser().name } ${ ua.getBrowser().version }`; + user.browserIcon = `icon-${ ua.getBrowser().name.toLowerCase() }`; + + user.status = roomTypes.getUserStatus('l', this.rid) || 'offline'; + } + return user; + }, + + room() { + return ChatRoom.findOne({ _id: this.rid }); + }, + + department() { + return LivechatDepartment.findOne({ _id: Template.instance().departmentId.get() }); + }, + + joinTags() { + return this.tags && this.tags.join(', '); + }, + + customFields() { + const fields = []; + let livechatData = {}; + const user = Template.instance().user.get(); + if (user) { + livechatData = _.extend(livechatData, user.livechatData); + } + + const data = Template.currentData(); + if (data && data.rid) { + const room = Rooms.findOne(data.rid); + if (room) { + livechatData = _.extend(livechatData, room.livechatData); + } + } + + if (!_.isEmpty(livechatData)) { + for (const _id in livechatData) { + if (livechatData.hasOwnProperty(_id)) { + const customFields = Template.instance().customFields.get(); + if (customFields) { + const field = _.findWhere(customFields, { _id }); + if (field && field.visibility !== 'hidden') { + fields.push({ label: field.label, value: livechatData[_id] }); + } + } + } + } + return fields; + } + }, + + createdAt() { + if (!this.createdAt) { + return ''; + } + return moment(this.createdAt).format('L LTS'); + }, + + lastLogin() { + if (!this.lastLogin) { + return ''; + } + return moment(this.lastLogin).format('L LTS'); + }, + + editing() { + return Template.instance().action.get() === 'edit'; + }, + + forwarding() { + return Template.instance().action.get() === 'forward'; + }, + + editDetails() { + const instance = Template.instance(); + const user = instance.user.get(); + return { + visitorId: user ? user._id : null, + roomId: this.rid, + save() { + instance.action.set(); + }, + cancel() { + instance.action.set(); + }, + }; + }, + + forwardDetails() { + const instance = Template.instance(); + const user = instance.user.get(); + return { + visitorId: user ? user._id : null, + roomId: this.rid, + save() { + instance.action.set(); + }, + cancel() { + instance.action.set(); + }, + }; + }, + + roomOpen() { + const room = ChatRoom.findOne({ _id: this.rid }); + + return room.open; + }, + + guestPool() { + return settings.get('Livechat_Routing_Method') === 'Guest_Pool'; + }, + + showDetail() { + if (Template.instance().action.get()) { + return 'hidden'; + } + }, + + canSeeButtons() { + if (hasRole(Meteor.userId(), 'livechat-manager')) { + return true; + } + + const data = Template.currentData(); + if (data && data.rid) { + const subscription = Subscriptions.findOne({ rid: data.rid }); + return subscription !== undefined; + } + return false; + }, +}); + +Template.visitorInfo.events({ + 'click .edit-livechat'(event, instance) { + event.preventDefault(); + + instance.action.set('edit'); + }, + 'click .close-livechat'(event) { + event.preventDefault(); + + const closeRoom = (comment) => Meteor.call('livechat:closeRoom', this.rid, comment, function(error/* , result*/) { + if (error) { + return handleError(error); + } + modal.open({ + title: t('Chat_closed'), + text: t('Chat_closed_successfully'), + type: 'success', + timer: 1000, + showConfirmButton: false, + }); + }); + + if (!settings.get('Livechat_request_comment_when_closing_conversation')) { + const comment = TAPi18n.__('Chat_closed_by_agent'); + return closeRoom(comment); + } + + // Setting for Ask_for_conversation_finished_message is set to true + modal.open({ + title: t('Closing_chat'), + type: 'input', + inputPlaceholder: t('Please_add_a_comment'), + showCancelButton: true, + closeOnConfirm: false, + }, (inputValue) => { + if (!inputValue) { + modal.showInputError(t('Please_add_a_comment_to_close_the_room')); + return false; + } + + if (s.trim(inputValue) === '') { + modal.showInputError(t('Please_add_a_comment_to_close_the_room')); + return false; + } + + return closeRoom(inputValue); + }); + }, + + 'click .return-inquiry'(event) { + event.preventDefault(); + + modal.open({ + title: t('Would_you_like_to_return_the_inquiry'), + type: 'warning', + showCancelButton: true, + confirmButtonColor: '#3085d6', + cancelButtonColor: '#d33', + confirmButtonText: t('Yes'), + }, () => { + Meteor.call('livechat:returnAsInquiry', this.rid, function(error/* , result*/) { + if (error) { + console.log(error); + } else { + Session.set('openedRoom'); + FlowRouter.go('/home'); + } + }); + }); + }, + + 'click .forward-livechat'(event, instance) { + event.preventDefault(); + + instance.action.set('forward'); + }, +}); + +Template.visitorInfo.onCreated(function() { + this.visitorId = new ReactiveVar(null); + this.customFields = new ReactiveVar([]); + this.action = new ReactiveVar(); + this.user = new ReactiveVar(); + this.departmentId = new ReactiveVar(null); + + Meteor.call('livechat:getCustomFields', (err, customFields) => { + if (customFields) { + this.customFields.set(customFields); + } + }); + + const currentData = Template.currentData(); + + if (currentData && currentData.rid) { + this.autorun(() => { + const room = Rooms.findOne({ _id: currentData.rid }); + this.visitorId.set(room && room.v && room.v._id); + this.departmentId.set(room && room.departmentId); + }); + + this.subscribe('livechat:visitorInfo', { rid: currentData.rid }); + this.subscribe('livechat:departments', this.departmentId.get()); + } + + this.autorun(() => { + this.user.set(LivechatVisitor.findOne({ _id: this.visitorId.get() })); + }); +}); diff --git a/packages/rocketchat-livechat/client/views/app/tabbar/visitorNavigation.html b/app/livechat/client/views/app/tabbar/visitorNavigation.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/tabbar/visitorNavigation.html rename to app/livechat/client/views/app/tabbar/visitorNavigation.html diff --git a/packages/rocketchat-livechat/client/views/app/tabbar/visitorNavigation.js b/app/livechat/client/views/app/tabbar/visitorNavigation.js similarity index 87% rename from packages/rocketchat-livechat/client/views/app/tabbar/visitorNavigation.js rename to app/livechat/client/views/app/tabbar/visitorNavigation.js index 1a37193f6bc62..9c8360c4be20d 100644 --- a/packages/rocketchat-livechat/client/views/app/tabbar/visitorNavigation.js +++ b/app/livechat/client/views/app/tabbar/visitorNavigation.js @@ -2,6 +2,10 @@ import { Mongo } from 'meteor/mongo'; import { Template } from 'meteor/templating'; import moment from 'moment'; +import { ChatRoom } from '../../../../../models'; +import { t } from '../../../../../utils'; +import './visitorNavigation.html'; + const visitorNavigationHistory = new Mongo.Collection('visitor_navigation_history'); Template.visitorNavigation.helpers({ diff --git a/packages/rocketchat-livechat/client/views/app/triggers/livechatTriggerAction.html b/app/livechat/client/views/app/triggers/livechatTriggerAction.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/triggers/livechatTriggerAction.html rename to app/livechat/client/views/app/triggers/livechatTriggerAction.html diff --git a/packages/rocketchat-livechat/client/views/app/triggers/livechatTriggerAction.js b/app/livechat/client/views/app/triggers/livechatTriggerAction.js similarity index 85% rename from packages/rocketchat-livechat/client/views/app/triggers/livechatTriggerAction.js rename to app/livechat/client/views/app/triggers/livechatTriggerAction.js index 9169d74b0d975..c68add9710103 100644 --- a/packages/rocketchat-livechat/client/views/app/triggers/livechatTriggerAction.js +++ b/app/livechat/client/views/app/triggers/livechatTriggerAction.js @@ -1,12 +1,15 @@ import { ReactiveVar } from 'meteor/reactive-var'; import { Template } from 'meteor/templating'; +import { settings } from '../../../../../settings'; +import './livechatTriggerAction.html'; + Template.livechatTriggerAction.helpers({ hiddenValue(current) { if (this.name === undefined && Template.instance().firstAction) { Template.instance().firstAction = false; return ''; - } else if (this.name !== current) { + } if (this.name !== current) { return 'hidden'; } }, @@ -17,7 +20,7 @@ Template.livechatTriggerAction.helpers({ return !!(this.params && this.params.sender === current); }, disableIfGuestPool() { - return RocketChat.settings.get('Livechat_Routing_Method') === 'Guest_Pool'; + return settings.get('Livechat_Routing_Method') === 'Guest_Pool'; }, }); diff --git a/packages/rocketchat-livechat/client/views/app/triggers/livechatTriggerCondition.html b/app/livechat/client/views/app/triggers/livechatTriggerCondition.html similarity index 100% rename from packages/rocketchat-livechat/client/views/app/triggers/livechatTriggerCondition.html rename to app/livechat/client/views/app/triggers/livechatTriggerCondition.html diff --git a/packages/rocketchat-livechat/client/views/app/triggers/livechatTriggerCondition.js b/app/livechat/client/views/app/triggers/livechatTriggerCondition.js similarity index 91% rename from packages/rocketchat-livechat/client/views/app/triggers/livechatTriggerCondition.js rename to app/livechat/client/views/app/triggers/livechatTriggerCondition.js index 5e0aa9b068c7b..02c8ce64a449c 100644 --- a/packages/rocketchat-livechat/client/views/app/triggers/livechatTriggerCondition.js +++ b/app/livechat/client/views/app/triggers/livechatTriggerCondition.js @@ -1,11 +1,12 @@ import { Template } from 'meteor/templating'; +import './livechatTriggerCondition.html'; Template.livechatTriggerCondition.helpers({ hiddenValue(current) { if (this.name === undefined && Template.instance().firstCondition) { Template.instance().firstCondition = false; return ''; - } else if (this.name !== current) { + } if (this.name !== current) { return 'hidden'; } }, diff --git a/packages/rocketchat-livechat/client/views/sideNav/livechat.html b/app/livechat/client/views/sideNav/livechat.html similarity index 100% rename from packages/rocketchat-livechat/client/views/sideNav/livechat.html rename to app/livechat/client/views/sideNav/livechat.html diff --git a/app/livechat/client/views/sideNav/livechat.js b/app/livechat/client/views/sideNav/livechat.js new file mode 100644 index 0000000000000..8e2056f58e304 --- /dev/null +++ b/app/livechat/client/views/sideNav/livechat.js @@ -0,0 +1,128 @@ +import { Meteor } from 'meteor/meteor'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { Session } from 'meteor/session'; +import { Template } from 'meteor/templating'; + +import { ChatSubscription, Users } from '../../../../models'; +import { KonchatNotification } from '../../../../ui'; +import { settings } from '../../../../settings'; +import { hasRole } from '../../../../authorization'; +import { t, handleError, getUserPreference } from '../../../../utils'; +import { LivechatInquiry } from '../../../lib/LivechatInquiry'; +import './livechat.html'; + +Template.livechat.helpers({ + isActive() { + const query = { + t: 'l', + f: { $ne: true }, + open: true, + rid: Session.get('openedRoom'), + }; + + const options = { fields: { _id: 1 } }; + + if (ChatSubscription.findOne(query, options)) { + return 'active'; + } + }, + + rooms() { + const query = { + t: 'l', + open: true, + }; + + const user = Users.findOne(Meteor.userId(), { + fields: { 'settings.preferences.sidebarShowUnread': 1 }, + }); + + if (getUserPreference(user, 'sidebarShowUnread')) { + query.alert = { $ne: true }; + } + + return ChatSubscription.find(query, { + sort: { + t: 1, + fname: 1, + }, + }); + }, + + inquiries() { + // get all inquiries of the department + const inqs = LivechatInquiry.find({ + agents: Meteor.userId(), + status: 'open', + }, { + sort: { + ts: 1, + }, + }); + + // for notification sound + inqs.forEach((inq) => { + KonchatNotification.newRoom(inq.rid); + }); + + return inqs; + }, + + guestPool() { + return settings.get('Livechat_Routing_Method') === 'Guest_Pool'; + }, + + available() { + const statusLivechat = Template.instance().statusLivechat.get(); + + return { + status: statusLivechat === 'available' ? 'status-online' : '', + icon: statusLivechat === 'available' ? 'icon-toggle-on' : 'icon-toggle-off', + hint: statusLivechat === 'available' ? t('Available') : t('Not_Available'), + }; + }, + + isLivechatAvailable() { + return Template.instance().statusLivechat.get() === 'available'; + }, + + showQueueLink() { + if (settings.get('Livechat_Routing_Method') !== 'Least_Amount') { + return false; + } + return hasRole(Meteor.userId(), 'livechat-manager') || (Template.instance().statusLivechat.get() === 'available' && settings.get('Livechat_show_queue_list_link')); + }, + + activeLivechatQueue() { + FlowRouter.watchPathChange(); + if (FlowRouter.current().route.name === 'livechat-queue') { + return 'active'; + } + }, +}); + +Template.livechat.events({ + 'click .livechat-status'() { + Meteor.call('livechat:changeLivechatStatus', (err /* , results*/) => { + if (err) { + return handleError(err); + } + }); + }, +}); + +Template.livechat.onCreated(function() { + this.statusLivechat = new ReactiveVar(); + + this.autorun(() => { + if (Meteor.userId()) { + const user = Users.findOne(Meteor.userId(), { fields: { statusLivechat: 1 } }); + this.statusLivechat.set(user.statusLivechat); + } else { + this.statusLivechat.set(); + } + }); + + this.subscribe('livechat:inquiry'); +}); diff --git a/packages/rocketchat-livechat/client/views/sideNav/livechatFlex.html b/app/livechat/client/views/sideNav/livechatFlex.html similarity index 100% rename from packages/rocketchat-livechat/client/views/sideNav/livechatFlex.html rename to app/livechat/client/views/sideNav/livechatFlex.html diff --git a/app/livechat/client/views/sideNav/livechatFlex.js b/app/livechat/client/views/sideNav/livechatFlex.js new file mode 100644 index 0000000000000..f8692b40284eb --- /dev/null +++ b/app/livechat/client/views/sideNav/livechatFlex.js @@ -0,0 +1,25 @@ +import { Template } from 'meteor/templating'; + +import { SideNav, Layout } from '../../../../ui-utils'; +import { t } from '../../../../utils'; +import './livechatFlex.html'; + +Template.livechatFlex.helpers({ + menuItem(name, icon, section) { + return { + name: t(name), + icon, + pathSection: section, + darken: true, + }; + }, + embeddedVersion() { + return Layout.isEmbedded(); + }, +}); + +Template.livechatFlex.events({ + 'click [data-action="close"]'() { + SideNav.closeFlex(); + }, +}); diff --git a/app/livechat/imports/server/rest/departments.js b/app/livechat/imports/server/rest/departments.js new file mode 100644 index 0000000000000..2c6b0b85e7f99 --- /dev/null +++ b/app/livechat/imports/server/rest/departments.js @@ -0,0 +1,110 @@ +import { check } from 'meteor/check'; + +import { API } from '../../../../api'; +import { hasPermission } from '../../../../authorization'; +import { LivechatDepartment, LivechatDepartmentAgents } from '../../../../models'; +import { Livechat } from '../../../server/lib/Livechat'; + +API.v1.addRoute('livechat/department', { authRequired: true }, { + get() { + if (!hasPermission(this.userId, 'view-livechat-manager')) { + return API.v1.unauthorized(); + } + + return API.v1.success({ + departments: LivechatDepartment.find().fetch(), + }); + }, + post() { + if (!hasPermission(this.userId, 'view-livechat-manager')) { + return API.v1.unauthorized(); + } + + try { + check(this.bodyParams, { + department: Object, + agents: Array, + }); + + const department = Livechat.saveDepartment(null, this.bodyParams.department, this.bodyParams.agents); + + if (department) { + return API.v1.success({ + department, + agents: LivechatDepartmentAgents.find({ departmentId: department._id }).fetch(), + }); + } + + API.v1.failure(); + } catch (e) { + return API.v1.failure(e); + } + }, +}); + +API.v1.addRoute('livechat/department/:_id', { authRequired: true }, { + get() { + if (!hasPermission(this.userId, 'view-livechat-manager')) { + return API.v1.unauthorized(); + } + + try { + check(this.urlParams, { + _id: String, + }); + + return API.v1.success({ + department: LivechatDepartment.findOneById(this.urlParams._id), + agents: LivechatDepartmentAgents.find({ departmentId: this.urlParams._id }).fetch(), + }); + } catch (e) { + return API.v1.failure(e.error); + } + }, + put() { + if (!hasPermission(this.userId, 'view-livechat-manager')) { + return API.v1.unauthorized(); + } + + try { + check(this.urlParams, { + _id: String, + }); + + check(this.bodyParams, { + department: Object, + agents: Array, + }); + + if (Livechat.saveDepartment(this.urlParams._id, this.bodyParams.department, this.bodyParams.agents)) { + return API.v1.success({ + department: LivechatDepartment.findOneById(this.urlParams._id), + agents: LivechatDepartmentAgents.find({ departmentId: this.urlParams._id }).fetch(), + }); + } + + return API.v1.failure(); + } catch (e) { + return API.v1.failure(e.error); + } + }, + delete() { + if (!hasPermission(this.userId, 'view-livechat-manager')) { + return API.v1.unauthorized(); + } + + try { + check(this.urlParams, { + _id: String, + }); + + if (Livechat.removeDepartment(this.urlParams._id)) { + return API.v1.success(); + } + + return API.v1.failure(); + } catch (e) { + return API.v1.failure(e.error); + } + }, +}); diff --git a/app/livechat/imports/server/rest/facebook.js b/app/livechat/imports/server/rest/facebook.js new file mode 100644 index 0000000000000..ab2283a51e02d --- /dev/null +++ b/app/livechat/imports/server/rest/facebook.js @@ -0,0 +1,96 @@ +import crypto from 'crypto'; + +import { Random } from 'meteor/random'; + +import { API } from '../../../../api'; +import { Rooms, Users, LivechatVisitors } from '../../../../models'; +import { settings } from '../../../../settings'; +import { Livechat } from '../../../server/lib/Livechat'; + +/** + * @api {post} /livechat/facebook Send Facebook message + * @apiName Facebook + * @apiGroup Livechat + * + * @apiParam {String} mid Facebook message id + * @apiParam {String} page Facebook pages id + * @apiParam {String} token Facebook user's token + * @apiParam {String} first_name Facebook user's first name + * @apiParam {String} last_name Facebook user's last name + * @apiParam {String} [text] Facebook message text + * @apiParam {String} [attachments] Facebook message attachments + */ +API.v1.addRoute('livechat/facebook', { + post() { + if (!this.bodyParams.text && !this.bodyParams.attachments) { + return { + success: false, + }; + } + + if (!this.request.headers['x-hub-signature']) { + return { + success: false, + }; + } + + if (!settings.get('Livechat_Facebook_Enabled')) { + return { + success: false, + error: 'Integration disabled', + }; + } + + // validate if request come from omni + const signature = crypto.createHmac('sha1', settings.get('Livechat_Facebook_API_Secret')).update(JSON.stringify(this.request.body)).digest('hex'); + if (this.request.headers['x-hub-signature'] !== `sha1=${ signature }`) { + return { + success: false, + error: 'Invalid signature', + }; + } + + const sendMessage = { + message: { + _id: this.bodyParams.mid, + }, + roomInfo: { + facebook: { + page: this.bodyParams.page, + }, + }, + }; + let visitor = LivechatVisitors.getVisitorByToken(this.bodyParams.token); + if (visitor) { + const rooms = Rooms.findOpenByVisitorToken(visitor.token).fetch(); + if (rooms && rooms.length > 0) { + sendMessage.message.rid = rooms[0]._id; + } else { + sendMessage.message.rid = Random.id(); + } + sendMessage.message.token = visitor.token; + } else { + sendMessage.message.rid = Random.id(); + sendMessage.message.token = this.bodyParams.token; + + const userId = Livechat.registerGuest({ + token: sendMessage.message.token, + name: `${ this.bodyParams.first_name } ${ this.bodyParams.last_name }`, + }); + + visitor = Users.findOneById(userId); + } + + sendMessage.message.msg = this.bodyParams.text; + sendMessage.guest = visitor; + + try { + return { + sucess: true, + message: Livechat.sendMessage(sendMessage), + }; + } catch (e) { + console.error('Error using Facebook ->', e); + } + }, +}); diff --git a/packages/rocketchat-livechat/imports/server/rest/sms.js b/app/livechat/imports/server/rest/sms.js similarity index 79% rename from packages/rocketchat-livechat/imports/server/rest/sms.js rename to app/livechat/imports/server/rest/sms.js index 216696fff343b..c0f6f9f1804d2 100644 --- a/packages/rocketchat-livechat/imports/server/rest/sms.js +++ b/app/livechat/imports/server/rest/sms.js @@ -1,10 +1,14 @@ import { Meteor } from 'meteor/meteor'; import { Random } from 'meteor/random'; -import LivechatVisitors from '../../../server/models/LivechatVisitors'; -RocketChat.API.v1.addRoute('livechat/sms-incoming/:service', { +import { Rooms, LivechatVisitors } from '../../../../models'; +import { API } from '../../../../api'; +import { SMS } from '../../../../sms'; +import { Livechat } from '../../../server/lib/Livechat'; + +API.v1.addRoute('livechat/sms-incoming/:service', { post() { - const SMSService = RocketChat.SMS.getService(this.urlParams.service); + const SMSService = SMS.getService(this.urlParams.service); const sms = SMSService.parse(this.bodyParams); @@ -22,7 +26,7 @@ RocketChat.API.v1.addRoute('livechat/sms-incoming/:service', { }; if (visitor) { - const rooms = RocketChat.models.Rooms.findOpenByVisitorToken(visitor.token).fetch(); + const rooms = Rooms.findOpenByVisitorToken(visitor.token).fetch(); if (rooms && rooms.length > 0) { sendMessage.message.rid = rooms[0]._id; @@ -34,7 +38,7 @@ RocketChat.API.v1.addRoute('livechat/sms-incoming/:service', { sendMessage.message.rid = Random.id(); sendMessage.message.token = Random.id(); - const visitorId = RocketChat.Livechat.registerGuest({ + const visitorId = Livechat.registerGuest({ username: sms.from.replace(/[^0-9]/g, ''), token: sendMessage.message.token, phone: { @@ -70,7 +74,7 @@ RocketChat.API.v1.addRoute('livechat/sms-incoming/:service', { }); try { - const message = SMSService.response.call(this, RocketChat.Livechat.sendMessage(sendMessage)); + const message = SMSService.response.call(this, Livechat.sendMessage(sendMessage)); Meteor.defer(() => { if (sms.extra) { diff --git a/app/livechat/imports/server/rest/upload.js b/app/livechat/imports/server/rest/upload.js new file mode 100644 index 0000000000000..fc0b4ca433827 --- /dev/null +++ b/app/livechat/imports/server/rest/upload.js @@ -0,0 +1,105 @@ +import { Meteor } from 'meteor/meteor'; +import Busboy from 'busboy'; +import filesize from 'filesize'; + +import { settings } from '../../../../settings'; +import { Settings, Rooms, LivechatVisitors } from '../../../../models'; +import { fileUploadIsValidContentType } from '../../../../utils'; +import { FileUpload } from '../../../../file-upload'; +import { API } from '../../../../api'; + +let maxFileSize; + +settings.get('FileUpload_MaxFileSize', function(key, value) { + try { + maxFileSize = parseInt(value); + } catch (e) { + maxFileSize = Settings.findOneById('FileUpload_MaxFileSize').packageValue; + } +}); + +API.v1.addRoute('livechat/upload/:rid', { + post() { + if (!this.request.headers['x-visitor-token']) { + return API.v1.unauthorized(); + } + + const visitorToken = this.request.headers['x-visitor-token']; + const visitor = LivechatVisitors.getVisitorByToken(visitorToken); + + if (!visitor) { + return API.v1.unauthorized(); + } + + const room = Rooms.findOneOpenByRoomIdAndVisitorToken(this.urlParams.rid, visitorToken); + 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]; + + if (!fileUploadIsValidContentType(file.mimetype)) { + return API.v1.failure({ + reason: 'error-type-not-allowed', + }); + } + + // -1 maxFileSize means there is no limit + if (maxFileSize > -1 && file.fileBuffer.length > maxFileSize) { + return API.v1.failure({ + reason: 'error-size-not-allowed', + sizeAllowed: filesize(maxFileSize), + }); + } + + const fileStore = FileUpload.getStore('Uploads'); + + const details = { + name: file.filename, + size: file.fileBuffer.length, + type: file.mimetype, + rid: this.urlParams.rid, + visitorToken, + }; + + const uploadedFile = Meteor.wrapAsync(fileStore.insert.bind(fileStore))(details, file.fileBuffer); + + uploadedFile.description = fields.description; + + delete fields.description; + API.v1.success(Meteor.call('sendFileLivechatMessage', this.urlParams.rid, visitorToken, uploadedFile, fields)); + }, +}); diff --git a/app/livechat/imports/server/rest/users.js b/app/livechat/imports/server/rest/users.js new file mode 100644 index 0000000000000..5730b46f22890 --- /dev/null +++ b/app/livechat/imports/server/rest/users.js @@ -0,0 +1,147 @@ +import { check } from 'meteor/check'; +import _ from 'underscore'; + +import { hasPermission, getUsersInRole } from '../../../../authorization'; +import { API } from '../../../../api'; +import { Users } from '../../../../models'; +import { Livechat } from '../../../server/lib/Livechat'; + +API.v1.addRoute('livechat/users/:type', { authRequired: true }, { + get() { + if (!hasPermission(this.userId, 'view-livechat-manager')) { + return API.v1.unauthorized(); + } + + try { + check(this.urlParams, { + type: String, + }); + + let role; + if (this.urlParams.type === 'agent') { + role = 'livechat-agent'; + } else if (this.urlParams.type === 'manager') { + role = 'livechat-manager'; + } else { + throw new Error('Invalid type'); + } + + const users = getUsersInRole(role); + + return API.v1.success({ + users: users.fetch().map((user) => _.pick(user, '_id', 'username', 'name', 'status', 'statusLivechat')), + }); + } catch (e) { + return API.v1.failure(e.error); + } + }, + post() { + if (!hasPermission(this.userId, 'view-livechat-manager')) { + return API.v1.unauthorized(); + } + try { + check(this.urlParams, { + type: String, + }); + + check(this.bodyParams, { + username: String, + }); + + if (this.urlParams.type === 'agent') { + const user = Livechat.addAgent(this.bodyParams.username); + if (user) { + return API.v1.success({ user }); + } + } else if (this.urlParams.type === 'manager') { + const user = Livechat.addManager(this.bodyParams.username); + if (user) { + return API.v1.success({ user }); + } + } else { + throw new Error('Invalid type'); + } + + return API.v1.failure(); + } catch (e) { + return API.v1.failure(e.error); + } + }, +}); + +API.v1.addRoute('livechat/users/:type/:_id', { authRequired: true }, { + get() { + if (!hasPermission(this.userId, 'view-livechat-manager')) { + return API.v1.unauthorized(); + } + + try { + check(this.urlParams, { + type: String, + _id: String, + }); + + const user = Users.findOneById(this.urlParams._id); + + if (!user) { + return API.v1.failure('User not found'); + } + + let role; + + if (this.urlParams.type === 'agent') { + role = 'livechat-agent'; + } else if (this.urlParams.type === 'manager') { + role = 'livechat-manager'; + } else { + throw new Error('Invalid type'); + } + + if (user.roles.indexOf(role) !== -1) { + return API.v1.success({ + user: _.pick(user, '_id', 'username'), + }); + } + + return API.v1.success({ + user: null, + }); + } catch (e) { + return API.v1.failure(e.error); + } + }, + delete() { + if (!hasPermission(this.userId, 'view-livechat-manager')) { + return API.v1.unauthorized(); + } + + try { + check(this.urlParams, { + type: String, + _id: String, + }); + + const user = Users.findOneById(this.urlParams._id); + + if (!user) { + return API.v1.failure(); + } + + if (this.urlParams.type === 'agent') { + if (Livechat.removeAgent(user.username)) { + return API.v1.success(); + } + } else if (this.urlParams.type === 'manager') { + if (Livechat.removeManager(user.username)) { + return API.v1.success(); + } + } else { + throw new Error('Invalid type'); + } + + return API.v1.failure(); + } catch (e) { + return API.v1.failure(e.error); + } + }, +}); diff --git a/app/livechat/lib/Assets.js b/app/livechat/lib/Assets.js new file mode 100644 index 0000000000000..c7252b5406ae5 --- /dev/null +++ b/app/livechat/lib/Assets.js @@ -0,0 +1,29 @@ +import { Autoupdate } from 'meteor/autoupdate'; + +export const addServerUrlToIndex = (file) => file.replace('', ``); + +export const addServerUrlToHead = (head) => { + let baseUrl; + if (__meteor_runtime_config__.ROOT_URL_PATH_PREFIX && __meteor_runtime_config__.ROOT_URL_PATH_PREFIX.trim() !== '') { + baseUrl = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX; + } else { + baseUrl = '/'; + } + if (/\/$/.test(baseUrl) === false) { + baseUrl += '/'; + } + + return ` + + + + + ${ head } + + + + + `; +}; diff --git a/app/livechat/lib/LivechatExternalMessage.js b/app/livechat/lib/LivechatExternalMessage.js new file mode 100644 index 0000000000000..cf8eedae67c9d --- /dev/null +++ b/app/livechat/lib/LivechatExternalMessage.js @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; + +import { Base } from '../../models'; + +class LivechatExternalMessageClass extends Base { + constructor() { + super('livechat_external_message'); + + if (Meteor.isClient) { + this._initModel('livechat_external_message'); + } + } + + // FIND + findByRoomId(roomId, sort = { ts: -1 }) { + const query = { rid: roomId }; + + return this.find(query, { sort }); + } +} + +export const LivechatExternalMessage = new LivechatExternalMessageClass(); diff --git a/app/livechat/lib/LivechatInquiry.js b/app/livechat/lib/LivechatInquiry.js new file mode 100644 index 0000000000000..bce2b7aa4d3df --- /dev/null +++ b/app/livechat/lib/LivechatInquiry.js @@ -0,0 +1,106 @@ +import { Meteor } from 'meteor/meteor'; +import { Mongo } from 'meteor/mongo'; + +import { Base } from '../../models'; + +export let LivechatInquiry; + +if (Meteor.isClient) { + LivechatInquiry = new Mongo.Collection('rocketchat_livechat_inquiry'); +} + +if (Meteor.isServer) { + class LivechatInquiryClass extends Base { + constructor() { + super('livechat_inquiry'); + + this.tryEnsureIndex({ rid: 1 }); // room id corresponding to this inquiry + this.tryEnsureIndex({ name: 1 }); // name of the inquiry (client name for now) + this.tryEnsureIndex({ message: 1 }); // message sent by the client + this.tryEnsureIndex({ ts: 1 }); // timestamp + this.tryEnsureIndex({ agents: 1 }); // Id's of the agents who can see the inquiry (handle departments) + this.tryEnsureIndex({ status: 1 }); // 'open', 'taken' + } + + findOneById(inquiryId) { + return this.findOne({ _id: inquiryId }); + } + + /* + * mark the inquiry as taken + */ + takeInquiry(inquiryId) { + this.update({ + _id: inquiryId, + }, { + $set: { status: 'taken' }, + }); + } + + /* + * mark the inquiry as closed + */ + closeByRoomId(roomId, closeInfo) { + return this.update({ + rid: roomId, + }, { + $set: { + status: 'closed', + closer: closeInfo.closer, + closedBy: closeInfo.closedBy, + closedAt: closeInfo.closedAt, + 'metrics.chatDuration': closeInfo.chatDuration, + }, + }); + } + + /* + * mark inquiry as open + */ + openInquiry(inquiryId) { + return this.update({ + _id: inquiryId, + }, { + $set: { status: 'open' }, + }); + } + + /* + * mark inquiry as open and set agents + */ + openInquiryWithAgents(inquiryId, agentIds) { + return this.update({ + _id: inquiryId, + }, { + $set: { + status: 'open', + agents: agentIds, + }, + }); + } + + /* + * return the status of the inquiry (open or taken) + */ + getStatus(inquiryId) { + return this.findOne({ _id: inquiryId }).status; + } + + updateVisitorStatus(token, status) { + const query = { + 'v.token': token, + status: 'open', + }; + + const update = { + $set: { + 'v.status': status, + }, + }; + + return this.update(query, update); + } + } + + LivechatInquiry = new LivechatInquiryClass(); +} diff --git a/app/livechat/lib/LivechatRoomType.js b/app/livechat/lib/LivechatRoomType.js new file mode 100644 index 0000000000000..7a2a17defa4cd --- /dev/null +++ b/app/livechat/lib/LivechatRoomType.js @@ -0,0 +1,107 @@ +import { Session } from 'meteor/session'; + +import { LivechatInquiry } from './LivechatInquiry'; +import { ChatRoom } from '../../models'; +import { settings } from '../../settings'; +import { hasPermission } from '../../authorization'; +import { openRoom } from '../../ui-utils'; +import { RoomSettingsEnum, UiTextContext, RoomTypeRouteConfig, RoomTypeConfig } from '../../utils'; +import { getAvatarURL } from '../../utils/lib/getAvatarURL'; + +class LivechatRoomRoute extends RoomTypeRouteConfig { + constructor() { + super({ + name: 'live', + path: '/live/:id', + }); + } + + action(params) { + openRoom('l', params.id); + } + + link(sub) { + return { + id: sub.rid, + }; + } +} + +export default class LivechatRoomType extends RoomTypeConfig { + constructor() { + super({ + identifier: 'l', + order: 5, + icon: 'livechat', + label: 'Livechat', + route: new LivechatRoomRoute(), + }); + + this.notSubscribedTpl = 'livechatNotSubscribed'; + this.readOnlyTpl = 'livechatReadOnly'; + } + + findRoom(identifier) { + return ChatRoom.findOne({ _id: identifier }); + } + + roomName(roomData) { + return roomData.name || roomData.fname || roomData.label; + } + + condition() { + return settings.get('Livechat_enabled') && hasPermission('view-l-room'); + } + + canSendMessage(rid) { + const room = ChatRoom.findOne({ _id: rid }, { fields: { open: 1 } }); + return room && room.open === true; + } + + getUserStatus(rid) { + const room = Session.get(`roomData${ rid }`); + if (room) { + return room.v && room.v.status; + } + const inquiry = LivechatInquiry.findOne({ rid }); + return inquiry && inquiry.v && inquiry.v.status; + } + + allowRoomSettingChange(room, setting) { + switch (setting) { + case RoomSettingsEnum.JOIN_CODE: + return false; + default: + return true; + } + } + + getUiText(context) { + switch (context) { + case UiTextContext.HIDE_WARNING: + return 'Hide_Livechat_Warning'; + case UiTextContext.LEAVE_WARNING: + return 'Hide_Livechat_Warning'; + default: + return ''; + } + } + + readOnly(rid, user) { + const room = ChatRoom.findOne({ _id: rid }, { fields: { open: 1, servedBy: 1 } }); + if (!room || !room.open) { + return true; + } + + const inquiry = LivechatInquiry.findOne({ rid }, { fields: { status: 1 } }); + if (inquiry && inquiry.status === 'open') { + return true; + } + + return (!room.servedBy || room.servedBy._id !== user._id) && !hasPermission('view-livechat-rooms'); + } + + getAvatarPath(roomData) { + return getAvatarURL({ username: `@${ this.roomName(roomData) }` }); + } +} diff --git a/app/livechat/lib/messageTypes.js b/app/livechat/lib/messageTypes.js new file mode 100644 index 0000000000000..6bef53f4d6fa5 --- /dev/null +++ b/app/livechat/lib/messageTypes.js @@ -0,0 +1,55 @@ +import { Meteor } from 'meteor/meteor'; +import { TAPi18n } from 'meteor/tap:i18n'; +import { Livechat } from 'meteor/rocketchat:livechat'; + +import { MessageTypes } from '../../ui-utils'; +import { actionLinks } from '../../action-links'; +import { Notifications } from '../../notifications'; +import { Messages, Rooms } from '../../models'; +import { settings } from '../../settings'; + +MessageTypes.registerType({ + id: 'livechat_navigation_history', + system: true, + message: 'New_visitor_navigation', + data(message) { + if (!message.navigation || !message.navigation.page) { + return; + } + return { + history: `${ (message.navigation.page.title ? `${ message.navigation.page.title } - ` : '') + message.navigation.page.location.href }`, + }; + }, +}); + +MessageTypes.registerType({ + id: 'livechat_video_call', + system: true, + message: 'New_videocall_request', +}); + +actionLinks.register('createLivechatCall', function(message, params, instance) { + if (Meteor.isClient) { + instance.tabBar.open('video'); + } +}); + +actionLinks.register('denyLivechatCall', function(message/* , params*/) { + if (Meteor.isServer) { + const user = Meteor.user(); + + Messages.createWithTypeRoomIdMessageAndUser('command', message.rid, 'endCall', user); + Notifications.notifyRoom(message.rid, 'deleteMessage', { _id: message._id }); + + const language = user.language || settings.get('Language') || 'en'; + + Livechat.closeRoom({ + user, + room: Rooms.findOneById(message.rid), + comment: TAPi18n.__('Videocall_declined', { lng: language }), + }); + Meteor.defer(() => { + Messages.setHiddenById(message._id); + }); + } +}); diff --git a/app/livechat/server/agentStatus.js b/app/livechat/server/agentStatus.js new file mode 100644 index 0000000000000..aea4ed012841b --- /dev/null +++ b/app/livechat/server/agentStatus.js @@ -0,0 +1,10 @@ +import { UserPresenceMonitor } from 'meteor/konecty:user-presence'; + +import { Livechat } from './lib/Livechat'; +import { hasRole } from '../../authorization'; + +UserPresenceMonitor.onSetUserStatus((user, status) => { + if (hasRole(user._id, 'livechat-manager') || hasRole(user._id, 'livechat-agent')) { + Livechat.notifyAgentStatusChanged(user._id, status); + } +}); diff --git a/packages/rocketchat-livechat/server/api.js b/app/livechat/server/api.js similarity index 100% rename from packages/rocketchat-livechat/server/api.js rename to app/livechat/server/api.js diff --git a/app/livechat/server/api/lib/livechat.js b/app/livechat/server/api/lib/livechat.js new file mode 100644 index 0000000000000..67239db91bd98 --- /dev/null +++ b/app/livechat/server/api/lib/livechat.js @@ -0,0 +1,143 @@ +import { Meteor } from 'meteor/meteor'; +import { Random } from 'meteor/random'; +import _ from 'underscore'; + +import { Users, Rooms, LivechatVisitors, LivechatDepartment, LivechatTrigger } from '../../../../models'; +import { Livechat } from '../../lib/Livechat'; +import { settings as rcSettings } from '../../../../settings'; + +export function online() { + const onlineAgents = Livechat.getOnlineAgents(); + return (onlineAgents && onlineAgents.count() > 0) || rcSettings.get('Livechat_guest_pool_with_no_agents'); +} + +export function findTriggers() { + return LivechatTrigger.findEnabled().fetch().map((trigger) => _.pick(trigger, '_id', 'actions', 'conditions', 'runOnce')); +} + +export function findDepartments() { + return LivechatDepartment.findEnabledWithAgents().fetch().map((department) => _.pick(department, '_id', 'name', 'showOnRegistration', 'showOnOfflineForm')); +} + +export function findGuest(token) { + return LivechatVisitors.getVisitorByToken(token, { + fields: { + name: 1, + username: 1, + token: 1, + visitorEmails: 1, + department: 1, + }, + }); +} + +export function findRoom(token, rid) { + const fields = { + t: 1, + departmentId: 1, + servedBy: 1, + open: 1, + v: 1, + }; + + if (!rid) { + return Rooms.findLivechatByVisitorToken(token, fields); + } + + return Rooms.findLivechatByIdAndVisitorToken(rid, token, fields); +} + +export function findOpenRoom(token, departmentId) { + const options = { + fields: { + departmentId: 1, + servedBy: 1, + open: 1, + }, + }; + + let room; + const rooms = departmentId ? Rooms.findOpenByVisitorTokenAndDepartmentId(token, departmentId, options).fetch() : Rooms.findOpenByVisitorToken(token, options).fetch(); + if (rooms && rooms.length > 0) { + room = rooms[0]; + } + + return room; +} + +export function getRoom({ guest, rid, roomInfo, agent }) { + const token = guest && guest.token; + + const message = { + _id: Random.id(), + rid, + msg: '', + token, + ts: new Date(), + }; + + return Livechat.getRoom(guest, message, roomInfo, agent); +} + +export function findAgent(agentId) { + return Users.getAgentInfo(agentId); +} + +export function normalizeHttpHeaderData(headers = {}) { + const httpHeaders = Object.assign({}, headers); + return { httpHeaders }; +} +export function settings() { + const initSettings = Livechat.getInitSettings(); + const triggers = findTriggers(); + const departments = findDepartments(); + const sound = `${ Meteor.absoluteUrl() }sounds/chime.mp3`; + const emojis = Meteor.call('listEmojiCustom'); + + return { + enabled: initSettings.Livechat_enabled, + settings: { + registrationForm: initSettings.Livechat_registration_form, + allowSwitchingDepartments: initSettings.Livechat_allow_switching_departments, + nameFieldRegistrationForm: initSettings.Livechat_name_field_registration_form, + emailFieldRegistrationForm: initSettings.Livechat_email_field_registration_form, + displayOfflineForm: initSettings.Livechat_display_offline_form, + videoCall: initSettings.Livechat_videocall_enabled === true && initSettings.Jitsi_Enabled === true, + fileUpload: initSettings.Livechat_fileupload_enabled && initSettings.FileUpload_Enabled, + language: initSettings.Language, + transcript: initSettings.Livechat_enable_transcript, + historyMonitorType: initSettings.Livechat_history_monitor_type, + forceAcceptDataProcessingConsent: initSettings.Livechat_force_accept_data_processing_consent, + showConnecting: initSettings.Livechat_Show_Connecting, + }, + theme: { + title: initSettings.Livechat_title, + color: initSettings.Livechat_title_color, + offlineTitle: initSettings.Livechat_offline_title, + offlineColor: initSettings.Livechat_offline_title_color, + actionLinks: [ + { icon: 'icon-videocam', i18nLabel: 'Accept', method_id: 'createLivechatCall', params: '' }, + { icon: 'icon-cancel', i18nLabel: 'Decline', method_id: 'denyLivechatCall', params: '' }, + ], + }, + messages: { + offlineMessage: initSettings.Livechat_offline_message, + offlineSuccessMessage: initSettings.Livechat_offline_success_message, + offlineUnavailableMessage: initSettings.Livechat_offline_form_unavailable, + conversationFinishedMessage: initSettings.Livechat_conversation_finished_message, + transcriptMessage: initSettings.Livechat_transcript_message, + registrationFormMessage: initSettings.Livechat_registration_form_message, + dataProcessingConsentText: initSettings.Livechat_data_processing_consent_text, + }, + survey: { + items: ['satisfaction', 'agentKnowledge', 'agentResposiveness', 'agentFriendliness'], + values: ['1', '2', '3', '4', '5'], + }, + triggers, + departments, + resources: { + sound, + emojis, + }, + }; +} diff --git a/packages/rocketchat-livechat/server/api/rest.js b/app/livechat/server/api/rest.js similarity index 100% rename from packages/rocketchat-livechat/server/api/rest.js rename to app/livechat/server/api/rest.js diff --git a/app/livechat/server/api/v1/agent.js b/app/livechat/server/api/v1/agent.js new file mode 100644 index 0000000000000..a0cc1678c35a9 --- /dev/null +++ b/app/livechat/server/api/v1/agent.js @@ -0,0 +1,78 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; + +import { API } from '../../../../api'; +import { findRoom, findGuest, findAgent, findOpenRoom } from '../lib/livechat'; +import { Livechat } from '../../lib/Livechat'; + +API.v1.addRoute('livechat/agent.info/:rid/:token', { + get() { + try { + check(this.urlParams, { + rid: String, + token: String, + }); + + const visitor = findGuest(this.urlParams.token); + if (!visitor) { + throw new Meteor.Error('invalid-token'); + } + + const room = findRoom(this.urlParams.token, this.urlParams.rid); + if (!room) { + throw new Meteor.Error('invalid-room'); + } + + const agent = room && room.servedBy && findAgent(room.servedBy._id); + if (!agent) { + throw new Meteor.Error('invalid-agent'); + } + + return API.v1.success({ agent }); + } catch (e) { + return API.v1.failure(e); + } + }, +}); + +API.v1.addRoute('livechat/agent.next/:token', { + get() { + try { + check(this.urlParams, { + token: String, + }); + + check(this.queryParams, { + department: Match.Maybe(String), + }); + + const { token } = this.urlParams; + const room = findOpenRoom(token); + if (room) { + return API.v1.success(); + } + + let { department } = this.queryParams; + if (!department) { + const requireDeparment = Livechat.getRequiredDepartment(); + if (requireDeparment) { + department = requireDeparment._id; + } + } + + const agentData = Livechat.getNextAgent(department); + if (!agentData) { + throw new Meteor.Error('agent-not-found'); + } + + const agent = findAgent(agentData.agentId); + if (!agent) { + throw new Meteor.Error('invalid-agent'); + } + + return API.v1.success({ agent }); + } catch (e) { + return API.v1.failure(e); + } + }, +}); diff --git a/app/livechat/server/api/v1/config.js b/app/livechat/server/api/v1/config.js new file mode 100644 index 0000000000000..76951c7556056 --- /dev/null +++ b/app/livechat/server/api/v1/config.js @@ -0,0 +1,39 @@ +import { Match, check } from 'meteor/check'; + +import { Users } from '../../../../models'; +import { API } from '../../../../api'; +import { findGuest, settings, online, findOpenRoom } from '../lib/livechat'; + +API.v1.addRoute('livechat/config', { + get() { + try { + check(this.queryParams, { + token: Match.Maybe(String), + }); + + const config = settings(); + if (!config.enabled) { + return API.v1.success({ config: { enabled: false } }); + } + + const status = online(); + + const { token } = this.queryParams; + const guest = token && findGuest(token); + + let room; + let agent; + + if (guest) { + room = findOpenRoom(token); + agent = room && room.servedBy && Users.getAgentInfo(room.servedBy._id); + } + + Object.assign(config, { online: status, guest, room, agent }); + + return API.v1.success({ config }); + } catch (e) { + return API.v1.failure(e); + } + }, +}); diff --git a/app/livechat/server/api/v1/customField.js b/app/livechat/server/api/v1/customField.js new file mode 100644 index 0000000000000..bcdb1dd7899b6 --- /dev/null +++ b/app/livechat/server/api/v1/customField.js @@ -0,0 +1,66 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; + +import { API } from '../../../../api'; +import { findGuest } from '../lib/livechat'; +import { Livechat } from '../../lib/Livechat'; + +API.v1.addRoute('livechat/custom.field', { + post() { + try { + check(this.bodyParams, { + token: String, + key: String, + value: String, + overwrite: Boolean, + }); + + const { token, key, value, overwrite } = this.bodyParams; + + const guest = findGuest(token); + if (!guest) { + throw new Meteor.Error('invalid-token'); + } + + if (!Livechat.setCustomFields({ token, key, value, overwrite })) { + return API.v1.failure(); + } + + return API.v1.success({ field: { key, value, overwrite } }); + } catch (e) { + return API.v1.failure(e); + } + }, +}); + +API.v1.addRoute('livechat/custom.fields', { + post() { + check(this.bodyParams, { + token: String, + customFields: [ + Match.ObjectIncluding({ + key: String, + value: String, + overwrite: Boolean, + }), + ], + }); + + const { token } = this.bodyParams; + const guest = findGuest(token); + if (!guest) { + throw new Meteor.Error('invalid-token'); + } + + const fields = this.bodyParams.customFields.map((customField) => { + const data = Object.assign({ token }, customField); + if (!Livechat.setCustomFields(data)) { + return API.v1.failure(); + } + + return { Key: customField.key, value: customField.value, overwrite: customField.overwrite }; + }); + + return API.v1.success({ fields }); + }, +}); diff --git a/app/livechat/server/api/v1/message.js b/app/livechat/server/api/v1/message.js new file mode 100644 index 0000000000000..4ed05a8f619ca --- /dev/null +++ b/app/livechat/server/api/v1/message.js @@ -0,0 +1,303 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; +import { Random } from 'meteor/random'; + +import { Messages, Rooms, LivechatVisitors } from '../../../../models'; +import { hasPermission } from '../../../../authorization'; +import { API } from '../../../../api'; +import { loadMessageHistory } from '../../../../lib'; +import { findGuest, findRoom, normalizeHttpHeaderData } from '../lib/livechat'; +import { Livechat } from '../../lib/Livechat'; + +API.v1.addRoute('livechat/message', { + post() { + try { + check(this.bodyParams, { + _id: Match.Maybe(String), + token: String, + rid: String, + msg: String, + agent: Match.Maybe({ + agentId: String, + username: String, + }), + }); + + const { token, rid, agent, msg } = this.bodyParams; + + const guest = findGuest(token); + if (!guest) { + throw new Meteor.Error('invalid-token'); + } + + const room = findRoom(token, rid); + if (!room) { + throw new Meteor.Error('invalid-room'); + } + + if (!room.open) { + throw new Meteor.Error('room-closed'); + } + + const _id = this.bodyParams._id || Random.id(); + + const sendMessage = { + guest, + message: { + _id, + rid, + msg, + token, + }, + agent, + }; + + const result = Livechat.sendMessage(sendMessage); + if (result) { + const message = Messages.findOneById(_id); + return API.v1.success({ message }); + } + + return API.v1.failure(); + } catch (e) { + return API.v1.failure(e); + } + }, +}); + +API.v1.addRoute('livechat/message/:_id', { + get() { + try { + check(this.urlParams, { + _id: String, + }); + + check(this.queryParams, { + token: String, + rid: String, + }); + + const { token, rid } = this.queryParams; + const { _id } = this.urlParams; + + const guest = findGuest(token); + if (!guest) { + throw new Meteor.Error('invalid-token'); + } + + const room = findRoom(token, rid); + if (!room) { + throw new Meteor.Error('invalid-room'); + } + + const message = Messages.findOneById(_id); + if (!message) { + throw new Meteor.Error('invalid-message'); + } + + return API.v1.success({ message }); + } catch (e) { + return API.v1.failure(e.error); + } + }, + + put() { + try { + check(this.urlParams, { + _id: String, + }); + + check(this.bodyParams, { + token: String, + rid: String, + msg: String, + }); + + const { token, rid } = this.bodyParams; + const { _id } = this.urlParams; + + const guest = findGuest(token); + if (!guest) { + throw new Meteor.Error('invalid-token'); + } + + const room = findRoom(token, rid); + if (!room) { + throw new Meteor.Error('invalid-room'); + } + + const msg = Messages.findOneById(_id); + if (!msg) { + throw new Meteor.Error('invalid-message'); + } + + const result = Livechat.updateMessage({ guest, message: { _id: msg._id, msg: this.bodyParams.msg } }); + if (result) { + const message = Messages.findOneById(_id); + return API.v1.success({ message }); + } + + return API.v1.failure(); + } catch (e) { + return API.v1.failure(e.error); + } + }, + delete() { + try { + check(this.urlParams, { + _id: String, + }); + + check(this.bodyParams, { + token: String, + rid: String, + }); + + const { token, rid } = this.bodyParams; + const { _id } = this.urlParams; + + const guest = findGuest(token); + if (!guest) { + throw new Meteor.Error('invalid-token'); + } + + const room = findRoom(token, rid); + if (!room) { + throw new Meteor.Error('invalid-room'); + } + + const message = Messages.findOneById(_id); + if (!message) { + throw new Meteor.Error('invalid-message'); + } + + const result = Livechat.deleteMessage({ guest, message }); + if (result) { + return API.v1.success({ + message: { + _id, + ts: new Date().toISOString(), + }, + }); + } + + return API.v1.failure(); + } catch (e) { + return API.v1.failure(e.error); + } + }, +}); + +API.v1.addRoute('livechat/messages.history/:rid', { + get() { + try { + check(this.urlParams, { + rid: String, + }); + + const { rid } = this.urlParams; + const { token } = this.queryParams; + + if (!token) { + throw new Meteor.Error('error-token-param-not-provided', 'The required "token" query param is missing.'); + } + + const guest = findGuest(token); + if (!guest) { + throw new Meteor.Error('invalid-token'); + } + + const room = findRoom(token, rid); + if (!room) { + throw new Meteor.Error('invalid-room'); + } + + let ls = undefined; + if (this.queryParams.ls) { + ls = new Date(this.queryParams.ls); + } + + let end = undefined; + if (this.queryParams.end) { + end = new Date(this.queryParams.end); + } + + let limit = 20; + if (this.queryParams.limit) { + limit = parseInt(this.queryParams.limit); + } + + const messages = loadMessageHistory({ userId: guest._id, rid, end, limit, ls }); + return API.v1.success(messages); + } catch (e) { + return API.v1.failure(e.error); + } + }, +}); + +API.v1.addRoute('livechat/messages', { authRequired: true }, { + post() { + if (!hasPermission(this.userId, 'view-livechat-manager')) { + return API.v1.unauthorized(); + } + + if (!this.bodyParams.visitor) { + return API.v1.failure('Body param "visitor" is required'); + } + if (!this.bodyParams.visitor.token) { + return API.v1.failure('Body param "visitor.token" is required'); + } + if (!this.bodyParams.messages) { + return API.v1.failure('Body param "messages" is required'); + } + if (!(this.bodyParams.messages instanceof Array)) { + return API.v1.failure('Body param "messages" is not an array'); + } + if (this.bodyParams.messages.length === 0) { + return API.v1.failure('Body param "messages" is empty'); + } + + const visitorToken = this.bodyParams.visitor.token; + + let visitor = LivechatVisitors.getVisitorByToken(visitorToken); + let rid; + if (visitor) { + const rooms = Rooms.findOpenByVisitorToken(visitorToken).fetch(); + if (rooms && rooms.length > 0) { + rid = rooms[0]._id; + } else { + rid = Random.id(); + } + } else { + rid = Random.id(); + + const guest = this.bodyParams.visitor; + guest.connectionData = normalizeHttpHeaderData(this.request.headers); + + const visitorId = Livechat.registerGuest(guest); + visitor = LivechatVisitors.findOneById(visitorId); + } + + const sentMessages = this.bodyParams.messages.map((message) => { + const sendMessage = { + guest: visitor, + message: { + _id: Random.id(), + rid, + token: visitorToken, + msg: message.msg, + }, + }; + const sentMessage = Livechat.sendMessage(sendMessage); + return { + username: sentMessage.u.username, + msg: sentMessage.msg, + ts: sentMessage.ts, + }; + }); + + return API.v1.success({ + messages: sentMessages, + }); + }, +}); diff --git a/app/livechat/server/api/v1/offlineMessage.js b/app/livechat/server/api/v1/offlineMessage.js new file mode 100644 index 0000000000000..26e8794a7c341 --- /dev/null +++ b/app/livechat/server/api/v1/offlineMessage.js @@ -0,0 +1,27 @@ +import { Match, check } from 'meteor/check'; +import { TAPi18n } from 'meteor/tap:i18n'; + +import { API } from '../../../../api'; +import { Livechat } from '../../lib/Livechat'; + +API.v1.addRoute('livechat/offline.message', { + post() { + try { + check(this.bodyParams, { + name: String, + email: String, + message: String, + department: Match.Maybe(String), + }); + + const { name, email, message, department } = this.bodyParams; + if (!Livechat.sendOfflineMessage({ name, email, message, department })) { + return API.v1.failure({ message: TAPi18n.__('Error_sending_livechat_offline_message') }); + } + + return API.v1.success({ message: TAPi18n.__('Livechat_offline_message_sent') }); + } catch (e) { + return API.v1.failure(e); + } + }, +}); diff --git a/app/livechat/server/api/v1/pageVisited.js b/app/livechat/server/api/v1/pageVisited.js new file mode 100644 index 0000000000000..4b35cd3adb6e5 --- /dev/null +++ b/app/livechat/server/api/v1/pageVisited.js @@ -0,0 +1,47 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; +import _ from 'underscore'; + +import { API } from '../../../../api'; +import { findGuest, findRoom } from '../lib/livechat'; +import { Livechat } from '../../lib/Livechat'; + +API.v1.addRoute('livechat/page.visited', { + post() { + try { + check(this.bodyParams, { + token: String, + rid: String, + pageInfo: Match.ObjectIncluding({ + change: String, + title: String, + location: Match.ObjectIncluding({ + href: String, + }), + }), + }); + + const { token, rid, pageInfo } = this.bodyParams; + + const guest = findGuest(token); + if (!guest) { + throw new Meteor.Error('invalid-token'); + } + + const room = findRoom(token, rid); + if (!room) { + throw new Meteor.Error('invalid-room'); + } + + const obj = Livechat.savePageHistory(token, rid, pageInfo); + if (obj) { + const page = _.pick(obj, 'msg', 'navigation'); + return API.v1.success({ page }); + } + + return API.v1.success(); + } catch (e) { + return API.v1.failure(e); + } + }, +}); diff --git a/app/livechat/server/api/v1/room.js b/app/livechat/server/api/v1/room.js new file mode 100644 index 0000000000000..a024d52078f2f --- /dev/null +++ b/app/livechat/server/api/v1/room.js @@ -0,0 +1,174 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; +import { Random } from 'meteor/random'; +import { TAPi18n } from 'meteor/tap:i18n'; + +import { settings as rcSettings } from '../../../../settings'; +import { Messages, Rooms } from '../../../../models'; +import { API } from '../../../../api'; +import { findGuest, findRoom, getRoom, settings, findAgent } from '../lib/livechat'; +import { Livechat } from '../../lib/Livechat'; + +API.v1.addRoute('livechat/room', { + get() { + try { + check(this.queryParams, { + token: String, + rid: Match.Maybe(String), + agentId: Match.Maybe(String), + }); + + const { token } = this.queryParams; + const guest = findGuest(token); + if (!guest) { + throw new Meteor.Error('invalid-token'); + } + + let agent; + const { agentId } = this.queryParams; + const agentObj = agentId && findAgent(agentId); + if (agentObj) { + const { username } = agentObj; + agent = Object.assign({}, { agentId, username }); + } + + const rid = this.queryParams.rid || Random.id(); + const room = getRoom({ guest, rid, agent }); + + return API.v1.success(room); + } catch (e) { + return API.v1.failure(e); + } + }, +}); + +API.v1.addRoute('livechat/room.close', { + post() { + try { + check(this.bodyParams, { + rid: String, + token: String, + }); + + const { rid, token } = this.bodyParams; + + const visitor = findGuest(token); + if (!visitor) { + throw new Meteor.Error('invalid-token'); + } + + const room = findRoom(token, rid); + if (!room) { + throw new Meteor.Error('invalid-room'); + } + + if (!room.open) { + throw new Meteor.Error('room-closed'); + } + + const language = rcSettings.get('Language') || 'en'; + const comment = TAPi18n.__('Closed_by_visitor', { lng: language }); + + if (!Livechat.closeRoom({ visitor, room, comment })) { + return API.v1.failure(); + } + + return API.v1.success({ rid, comment }); + } catch (e) { + return API.v1.failure(e); + } + }, +}); + +API.v1.addRoute('livechat/room.transfer', { + post() { + try { + check(this.bodyParams, { + rid: String, + token: String, + department: String, + }); + + const { rid, token, department } = this.bodyParams; + + const guest = findGuest(token); + if (!guest) { + throw new Meteor.Error('invalid-token'); + } + + let room = findRoom(token, rid); + if (!room) { + throw new Meteor.Error('invalid-room'); + } + + // update visited page history to not expire + Messages.keepHistoryForToken(token); + + if (!Livechat.transfer(room, guest, { roomId: rid, departmentId: department })) { + return API.v1.failure(); + } + + room = findRoom(token, rid); + return API.v1.success({ room }); + } catch (e) { + return API.v1.failure(e); + } + }, +}); + +API.v1.addRoute('livechat/room.survey', { + post() { + try { + check(this.bodyParams, { + rid: String, + token: String, + data: [Match.ObjectIncluding({ + name: String, + value: String, + })], + }); + + const { rid, token, data } = this.bodyParams; + + const visitor = findGuest(token); + if (!visitor) { + throw new Meteor.Error('invalid-token'); + } + + const room = findRoom(token, rid); + if (!room) { + throw new Meteor.Error('invalid-room'); + } + + const config = settings(); + if (!config.survey || !config.survey.items || !config.survey.values) { + throw new Meteor.Error('invalid-livechat-config'); + } + + const updateData = {}; + for (const item of data) { + if ((config.survey.items.includes(item.name) && config.survey.values.includes(item.value)) || item.name === 'additionalFeedback') { + updateData[item.name] = item.value; + } + } + + if (Object.keys(updateData).length === 0) { + throw new Meteor.Error('invalid-data'); + } + + if (!Rooms.updateSurveyFeedbackById(room._id, updateData)) { + return API.v1.failure(); + } + + return API.v1.success({ rid, data: updateData }); + } catch (e) { + return API.v1.failure(e); + } + }, +}); + +API.v1.addRoute('livechat/room.forward', { authRequired: true }, { + post() { + API.v1.success(Meteor.runAsUser(this.userId, () => Meteor.call('livechat:transfer', this.bodyParams))); + }, +}); diff --git a/app/livechat/server/api/v1/transcript.js b/app/livechat/server/api/v1/transcript.js new file mode 100644 index 0000000000000..b93fd3b93ed85 --- /dev/null +++ b/app/livechat/server/api/v1/transcript.js @@ -0,0 +1,26 @@ +import { check } from 'meteor/check'; +import { TAPi18n } from 'meteor/tap:i18n'; + +import { API } from '../../../../api'; +import { Livechat } from '../../lib/Livechat'; + +API.v1.addRoute('livechat/transcript', { + post() { + try { + check(this.bodyParams, { + token: String, + rid: String, + email: String, + }); + + const { token, rid, email } = this.bodyParams; + if (!Livechat.sendTranscript({ token, rid, email })) { + return API.v1.failure({ message: TAPi18n.__('Error_sending_livechat_transcript') }); + } + + return API.v1.success({ message: TAPi18n.__('Livechat_transcript_sent') }); + } catch (e) { + return API.v1.failure(e); + } + }, +}); diff --git a/app/livechat/server/api/v1/videoCall.js b/app/livechat/server/api/v1/videoCall.js new file mode 100644 index 0000000000000..0aaa231da6545 --- /dev/null +++ b/app/livechat/server/api/v1/videoCall.js @@ -0,0 +1,53 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; +import { Random } from 'meteor/random'; + +import { Messages } from '../../../../models'; +import { settings as rcSettings } from '../../../../settings'; +import { API } from '../../../../api'; +import { findGuest, getRoom, settings } from '../lib/livechat'; + +API.v1.addRoute('livechat/video.call/:token', { + get() { + try { + check(this.urlParams, { + token: String, + }); + + check(this.queryParams, { + rid: Match.Maybe(String), + }); + + const { token } = this.urlParams; + + const guest = findGuest(token); + if (!guest) { + throw new Meteor.Error('invalid-token'); + } + + const rid = this.queryParams.rid || Random.id(); + const roomInfo = { jitsiTimeout: new Date(Date.now() + 3600 * 1000) }; + const { room } = getRoom({ guest, rid, roomInfo }); + const config = settings(); + if (!config.theme || !config.theme.actionLinks) { + throw new Meteor.Error('invalid-livechat-config'); + } + + Messages.createWithTypeRoomIdMessageAndUser('livechat_video_call', room._id, '', guest, { + actionLinks: config.theme.actionLinks, + }); + + const videoCall = { + rid, + domain: rcSettings.get('Jitsi_Domain'), + provider: 'jitsi', + room: rcSettings.get('Jitsi_URL_Room_Prefix') + rcSettings.get('uniqueID') + rid, + timeout: new Date(Date.now() + 3600 * 1000), + }; + + return API.v1.success({ videoCall }); + } catch (e) { + return API.v1.failure(e); + } + }, +}); diff --git a/app/livechat/server/api/v1/visitor.js b/app/livechat/server/api/v1/visitor.js new file mode 100644 index 0000000000000..6dcfd98da68ba --- /dev/null +++ b/app/livechat/server/api/v1/visitor.js @@ -0,0 +1,153 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; + +import { Rooms, LivechatVisitors, LivechatCustomField } from '../../../../models'; +import { hasPermission } from '../../../../authorization'; +import { API } from '../../../../api'; +import { findGuest, normalizeHttpHeaderData } from '../lib/livechat'; +import { Livechat } from '../../lib/Livechat'; + +API.v1.addRoute('livechat/visitor', { + post() { + try { + check(this.bodyParams, { + visitor: Match.ObjectIncluding({ + token: String, + name: Match.Maybe(String), + email: Match.Maybe(String), + department: Match.Maybe(String), + phone: Match.Maybe(String), + username: Match.Maybe(String), + customFields: Match.Maybe([ + Match.ObjectIncluding({ + key: String, + value: String, + overwrite: Boolean, + }), + ]), + }), + }); + + const { token, customFields } = this.bodyParams.visitor; + const guest = this.bodyParams.visitor; + + if (this.bodyParams.visitor.phone) { + guest.phone = { number: this.bodyParams.visitor.phone }; + } + + guest.connectionData = normalizeHttpHeaderData(this.request.headers); + const visitorId = Livechat.registerGuest(guest); + + let visitor = LivechatVisitors.getVisitorByToken(token); + // If it's updating an existing visitor, it must also update the roomInfo + const cursor = Rooms.findOpenByVisitorToken(token); + cursor.forEach((room) => { + Livechat.saveRoomInfo(room, visitor); + }); + + if (customFields && customFields instanceof Array) { + customFields.forEach((field) => { + const customField = LivechatCustomField.findOneById(field.key); + if (!customField) { + return; + } + const { key, value, overwrite } = field; + if (customField.scope === 'visitor' && !LivechatVisitors.updateLivechatDataByToken(token, key, value, overwrite)) { + return API.v1.failure(); + } + }); + } + + visitor = LivechatVisitors.findOneById(visitorId); + return API.v1.success({ visitor }); + } catch (e) { + return API.v1.failure(e); + } + }, +}); + +API.v1.addRoute('livechat/visitor/:token', { + get() { + try { + check(this.urlParams, { + token: String, + }); + + const visitor = LivechatVisitors.getVisitorByToken(this.urlParams.token); + return API.v1.success({ visitor }); + } catch (e) { + return API.v1.failure(e.error); + } + }, + delete() { + try { + check(this.urlParams, { + token: String, + }); + + const visitor = LivechatVisitors.getVisitorByToken(this.urlParams.token); + if (!visitor) { + throw new Meteor.Error('invalid-token'); + } + + const { _id } = visitor; + const result = Livechat.removeGuest(_id); + if (result) { + return API.v1.success({ + visitor: { + _id, + ts: new Date().toISOString(), + }, + }); + } + + return API.v1.failure(); + } catch (e) { + return API.v1.failure(e.error); + } + }, +}); + +API.v1.addRoute('livechat/visitor/:token/room', { authRequired: true }, { + get() { + if (!hasPermission(this.userId, 'view-livechat-manager')) { + return API.v1.unauthorized(); + } + + const rooms = Rooms.findOpenByVisitorToken(this.urlParams.token, { + fields: { + name: 1, + t: 1, + cl: 1, + u: 1, + usernames: 1, + servedBy: 1, + }, + }).fetch(); + return API.v1.success({ rooms }); + }, +}); + +API.v1.addRoute('livechat/visitor.status', { + post() { + try { + check(this.bodyParams, { + token: String, + status: String, + }); + + const { token, status } = this.bodyParams; + + const guest = findGuest(token); + if (!guest) { + throw new Meteor.Error('invalid-token'); + } + + Livechat.notifyGuestStatusChanged(token, status); + + return API.v1.success({ token, status }); + } catch (e) { + return API.v1.failure(e); + } + }, +}); diff --git a/app/livechat/server/config.js b/app/livechat/server/config.js new file mode 100644 index 0000000000000..cfc3e375d4eb2 --- /dev/null +++ b/app/livechat/server/config.js @@ -0,0 +1,432 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../settings'; + +Meteor.startup(function() { + settings.addGroup('Livechat'); + + settings.add('Livechat_enabled', false, { type: 'boolean', group: 'Livechat', public: true }); + + settings.add('Livechat_title', 'Rocket.Chat', { type: 'string', group: 'Livechat', public: true }); + settings.add('Livechat_title_color', '#C1272D', { + type: 'color', + editor: 'color', + allowedTypes: ['color', 'expression'], + group: 'Livechat', + public: true, + }); + + settings.add('Livechat_display_offline_form', true, { + type: 'boolean', + group: 'Livechat', + public: true, + section: 'Offline', + i18nLabel: 'Display_offline_form', + }); + + settings.add('Livechat_validate_offline_email', true, { + type: 'boolean', + group: 'Livechat', + public: true, + section: 'Offline', + i18nLabel: 'Validate_email_address', + }); + + settings.add('Livechat_offline_form_unavailable', '', { + type: 'string', + group: 'Livechat', + public: true, + section: 'Offline', + i18nLabel: 'Offline_form_unavailable_message', + }); + + settings.add('Livechat_offline_title', 'Leave a message', { + type: 'string', + group: 'Livechat', + public: true, + section: 'Offline', + i18nLabel: 'Title', + }); + settings.add('Livechat_offline_title_color', '#666666', { + type: 'color', + editor: 'color', + allowedTypes: ['color', 'expression'], + group: 'Livechat', + public: true, + section: 'Offline', + i18nLabel: 'Color', + }); + settings.add('Livechat_offline_message', '', { + type: 'string', + group: 'Livechat', + public: true, + section: 'Offline', + i18nLabel: 'Instructions', + i18nDescription: 'Instructions_to_your_visitor_fill_the_form_to_send_a_message', + }); + settings.add('Livechat_offline_email', '', { + type: 'string', + group: 'Livechat', + i18nLabel: 'Email_address_to_send_offline_messages', + section: 'Offline', + }); + settings.add('Livechat_offline_success_message', '', { + type: 'string', + group: 'Livechat', + public: true, + section: 'Offline', + i18nLabel: 'Offline_success_message', + }); + + settings.add('Livechat_allow_switching_departments', true, { type: 'boolean', group: 'Livechat', public: true, i18nLabel: 'Allow_switching_departments' }); + settings.add('Livechat_show_agent_email', true, { type: 'boolean', group: 'Livechat', public: true, i18nLabel: 'Show_agent_email' }); + + settings.add('Livechat_request_comment_when_closing_conversation', true, { + type: 'boolean', + group: 'Livechat', + public: true, + i18nLabel: 'Request_comment_when_closing_conversation', + }); + + settings.add('Livechat_conversation_finished_message', '', { + type: 'string', + group: 'Livechat', + public: true, + i18nLabel: 'Conversation_finished_message', + }); + + settings.add('Livechat_registration_form', true, { + type: 'boolean', + group: 'Livechat', + public: true, + i18nLabel: 'Show_preregistration_form', + }); + + settings.add('Livechat_name_field_registration_form', true, { + type: 'boolean', + group: 'Livechat', + public: true, + i18nLabel: 'Show_name_field', + }); + + settings.add('Livechat_email_field_registration_form', true, { + type: 'boolean', + group: 'Livechat', + public: true, + i18nLabel: 'Show_email_field', + }); + + settings.add('Livechat_guest_count', 1, { type: 'int', group: 'Livechat' }); + + settings.add('Livechat_Room_Count', 1, { + type: 'int', + group: 'Livechat', + i18nLabel: 'Livechat_room_count', + }); + + settings.add('Livechat_agent_leave_action', 'none', { + type: 'select', + group: 'Livechat', + values: [ + { key: 'none', i18nLabel: 'None' }, + { key: 'forward', i18nLabel: 'Forward' }, + { key: 'close', i18nLabel: 'Close' }, + ], + i18nLabel: 'How_to_handle_open_sessions_when_agent_goes_offline', + }); + + settings.add('Livechat_agent_leave_action_timeout', 60, { + type: 'int', + group: 'Livechat', + enableQuery: { _id: 'Livechat_agent_leave_action', value: { $ne: 'none' } }, + i18nLabel: 'How_long_to_wait_after_agent_goes_offline', + i18nDescription: 'Time_in_seconds', + }); + + settings.add('Livechat_agent_leave_comment', '', { + type: 'string', + group: 'Livechat', + enableQuery: { _id: 'Livechat_agent_leave_action', value: 'close' }, + i18nLabel: 'Comment_to_leave_on_closing_session', + }); + + settings.add('Livechat_webhookUrl', false, { + type: 'string', + group: 'Livechat', + section: 'CRM_Integration', + i18nLabel: 'Webhook_URL', + }); + + settings.add('Livechat_secret_token', '', { + type: 'string', + group: 'Livechat', + section: 'CRM_Integration', + i18nLabel: 'Secret_token', + secret: true, + }); + + settings.add('Livechat_webhook_on_close', false, { + type: 'boolean', + group: 'Livechat', + section: 'CRM_Integration', + i18nLabel: 'Send_request_on_chat_close', + }); + + settings.add('Livechat_webhook_on_offline_msg', false, { + type: 'boolean', + group: 'Livechat', + section: 'CRM_Integration', + i18nLabel: 'Send_request_on_offline_messages', + }); + + settings.add('Livechat_webhook_on_visitor_message', false, { + type: 'boolean', + group: 'Livechat', + section: 'CRM_Integration', + i18nLabel: 'Send_request_on_visitor_message', + }); + + settings.add('Livechat_webhook_on_agent_message', false, { + type: 'boolean', + group: 'Livechat', + section: 'CRM_Integration', + i18nLabel: 'Send_request_on_agent_message', + }); + + settings.add('Send_visitor_navigation_history_livechat_webhook_request', false, { + type: 'boolean', + group: 'Livechat', + section: 'CRM_Integration', + i18nLabel: 'Send_visitor_navigation_history_on_request', + i18nDescription: 'Feature_Depends_on_Livechat_Visitor_navigation_as_a_message_to_be_enabled', + enableQuery: { _id: 'Livechat_Visitor_navigation_as_a_message', value: true }, + }); + + settings.add('Livechat_webhook_on_capture', false, { + type: 'boolean', + group: 'Livechat', + section: 'CRM_Integration', + i18nLabel: 'Send_request_on_lead_capture', + }); + + settings.add('Livechat_lead_email_regex', '\\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\\.)+[A-Z]{2,4}\\b', { + type: 'string', + group: 'Livechat', + section: 'CRM_Integration', + i18nLabel: 'Lead_capture_email_regex', + }); + + settings.add('Livechat_lead_phone_regex', '((?:\\([0-9]{1,3}\\)|[0-9]{2})[ \\-]*?[0-9]{4,5}(?:[\\-\\s\\_]{1,2})?[0-9]{4}(?:(?=[^0-9])|$)|[0-9]{4,5}(?:[\\-\\s\\_]{1,2})?[0-9]{4}(?:(?=[^0-9])|$))', { + type: 'string', + group: 'Livechat', + section: 'CRM_Integration', + i18nLabel: 'Lead_capture_phone_regex', + }); + + settings.add('Livechat_Knowledge_Enabled', false, { + type: 'boolean', + group: 'Livechat', + section: 'Knowledge_Base', + public: true, + i18nLabel: 'Enabled', + }); + + settings.add('Livechat_Knowledge_Apiai_Key', '', { + type: 'string', + group: 'Livechat', + section: 'Knowledge_Base', + public: true, + i18nLabel: 'Apiai_Key', + secret: true, + }); + + settings.add('Livechat_Knowledge_Apiai_Language', 'en', { + type: 'string', + group: 'Livechat', + section: 'Knowledge_Base', + public: true, + i18nLabel: 'Apiai_Language', + }); + + settings.add('Livechat_history_monitor_type', 'url', { + type: 'select', + group: 'Livechat', + i18nLabel: 'Monitor_history_for_changes_on', + values: [ + { key: 'url', i18nLabel: 'Page_URL' }, + { key: 'title', i18nLabel: 'Page_title' }, + ], + }); + + settings.add('Livechat_Visitor_navigation_as_a_message', false, { + type: 'boolean', + group: 'Livechat', + public: true, + i18nLabel: 'Send_Visitor_navigation_history_as_a_message', + }); + + settings.add('Livechat_enable_office_hours', false, { + type: 'boolean', + group: 'Livechat', + public: true, + i18nLabel: 'Office_hours_enabled', + }); + + settings.add('Livechat_continuous_sound_notification_new_livechat_room', false, { + type: 'boolean', + group: 'Livechat', + public: true, + i18nLabel: 'Continuous_sound_notifications_for_new_livechat_room', + }); + + settings.add('Livechat_videocall_enabled', false, { + type: 'boolean', + group: 'Livechat', + public: true, + i18nLabel: 'Videocall_enabled', + i18nDescription: 'Beta_feature_Depends_on_Video_Conference_to_be_enabled', + enableQuery: { _id: 'Jitsi_Enabled', value: true }, + }); + + settings.add('Livechat_fileupload_enabled', true, { + type: 'boolean', + group: 'Livechat', + public: true, + i18nLabel: 'FileUpload_Enabled', + enableQuery: { _id: 'FileUpload_Enabled', value: true }, + }); + + settings.add('Livechat_enable_transcript', false, { + type: 'boolean', + group: 'Livechat', + public: true, + i18nLabel: 'Transcript_Enabled', + }); + + settings.add('Livechat_transcript_message', '', { + type: 'string', + group: 'Livechat', + public: true, + i18nLabel: 'Transcript_message', + enableQuery: { _id: 'Livechat_enable_transcript', value: true }, + }); + + settings.add('Livechat_registration_form_message', '', { + type: 'string', + group: 'Livechat', + public: true, + i18nLabel: 'Livechat_registration_form_message', + }); + + settings.add('Livechat_AllowedDomainsList', '', { + type: 'string', + group: 'Livechat', + public: true, + i18nLabel: 'Livechat_AllowedDomainsList', + i18nDescription: 'Domains_allowed_to_embed_the_livechat_widget', + }); + + settings.add('Livechat_Facebook_Enabled', false, { + type: 'boolean', + group: 'Livechat', + section: 'Facebook', + }); + + settings.add('Livechat_Facebook_API_Key', '', { + type: 'string', + group: 'Livechat', + section: 'Facebook', + i18nDescription: 'If_you_dont_have_one_send_an_email_to_omni_rocketchat_to_get_yours', + }); + + settings.add('Livechat_Facebook_API_Secret', '', { + type: 'string', + group: 'Livechat', + section: 'Facebook', + i18nDescription: 'If_you_dont_have_one_send_an_email_to_omni_rocketchat_to_get_yours', + }); + + settings.add('Livechat_RDStation_Token', '', { + type: 'string', + group: 'Livechat', + public: false, + section: 'RD Station', + i18nLabel: 'RDStation_Token', + }); + + settings.add('Livechat_Routing_Method', 'Least_Amount', { + type: 'select', + group: 'Livechat', + public: true, + section: 'Routing', + values: [ + { key: 'External', i18nLabel: 'External_Service' }, + { key: 'Least_Amount', i18nLabel: 'Least_Amount' }, + { key: 'Guest_Pool', i18nLabel: 'Guest_Pool' }, + ], + }); + + settings.add('Livechat_guest_pool_with_no_agents', false, { + type: 'boolean', + group: 'Livechat', + section: 'Routing', + i18nLabel: 'Accept_with_no_online_agents', + i18nDescription: 'Accept_incoming_livechat_requests_even_if_there_are_no_online_agents', + enableQuery: { _id: 'Livechat_Routing_Method', value: 'Guest_Pool' }, + }); + + settings.add('Livechat_show_queue_list_link', false, { + type: 'boolean', + group: 'Livechat', + public: true, + section: 'Routing', + i18nLabel: 'Show_queue_list_to_all_agents', + enableQuery: { _id: 'Livechat_Routing_Method', value: { $ne: 'External' } }, + }); + + settings.add('Livechat_External_Queue_URL', '', { + type: 'string', + group: 'Livechat', + public: false, + section: 'Routing', + i18nLabel: 'External_Queue_Service_URL', + i18nDescription: 'For_more_details_please_check_our_docs', + enableQuery: { _id: 'Livechat_Routing_Method', value: 'External' }, + }); + + settings.add('Livechat_External_Queue_Token', '', { + type: 'string', + group: 'Livechat', + public: false, + section: 'Routing', + i18nLabel: 'Secret_token', + enableQuery: { _id: 'Livechat_Routing_Method', value: 'External' }, + }); + + settings.add('Livechat_Allow_collect_and_store_HTTP_header_informations', false, { + type: 'boolean', + group: 'Livechat', + public: true, + i18nLabel: 'Allow_collect_and_store_HTTP_header_informations', + i18nDescription: 'Allow_collect_and_store_HTTP_header_informations_description', + }); + + settings.add('Livechat_force_accept_data_processing_consent', false, { + type: 'boolean', + group: 'Livechat', + public: true, + alert: 'Force_visitor_to_accept_data_processing_consent_enabled_alert', + i18nLabel: 'Force_visitor_to_accept_data_processing_consent', + i18nDescription: 'Force_visitor_to_accept_data_processing_consent_description', + }); + + settings.add('Livechat_data_processing_consent_text', '', { + type: 'string', + multiline: true, + group: 'Livechat', + public: true, + i18nLabel: 'Data_processing_consent_text', + i18nDescription: 'Data_processing_consent_text_description', + enableQuery: { _id: 'Livechat_force_accept_data_processing_consent', value: true }, + }); +}); diff --git a/app/livechat/server/hooks/RDStation.js b/app/livechat/server/hooks/RDStation.js new file mode 100644 index 0000000000000..bc60b1c1c9efe --- /dev/null +++ b/app/livechat/server/hooks/RDStation.js @@ -0,0 +1,61 @@ +import { HTTP } from 'meteor/http'; + +import { settings } from '../../../settings'; +import { callbacks } from '../../../callbacks'; +import { Livechat } from '../lib/Livechat'; + +function sendToRDStation(room) { + if (!settings.get('Livechat_RDStation_Token')) { + return room; + } + + const livechatData = Livechat.getLivechatRoomGuestInfo(room); + + if (!livechatData.visitor.email) { + return room; + } + + const email = Array.isArray(livechatData.visitor.email) ? livechatData.visitor.email[0].address : livechatData.visitor.email; + + const options = { + headers: { + 'Content-Type': 'application/json', + }, + data: { + token_rdstation: settings.get('Livechat_RDStation_Token'), + identificador: 'rocketchat-livechat', + client_id: livechatData.visitor._id, + email, + }, + }; + + options.data.nome = livechatData.visitor.name || livechatData.visitor.username; + + if (livechatData.visitor.phone) { + options.data.telefone = livechatData.visitor.phone; + } + + if (livechatData.tags) { + options.data.tags = livechatData.tags; + } + + Object.keys(livechatData.customFields || {}).forEach((field) => { + options.data[field] = livechatData.customFields[field]; + }); + + Object.keys(livechatData.visitor.customFields || {}).forEach((field) => { + options.data[field] = livechatData.visitor.customFields[field]; + }); + + try { + HTTP.call('POST', 'https://www.rdstation.com.br/api/1.3/conversions', options); + } catch (e) { + console.error('Error sending lead to RD Station ->', e); + } + + return room; +} + +callbacks.add('livechat.closeRoom', sendToRDStation, callbacks.priority.MEDIUM, 'livechat-rd-station-close-room'); + +callbacks.add('livechat.saveInfo', sendToRDStation, callbacks.priority.MEDIUM, 'livechat-rd-station-save-info'); diff --git a/app/livechat/server/hooks/externalMessage.js b/app/livechat/server/hooks/externalMessage.js new file mode 100644 index 0000000000000..91b9448be491e --- /dev/null +++ b/app/livechat/server/hooks/externalMessage.js @@ -0,0 +1,70 @@ +import { Meteor } from 'meteor/meteor'; +import { HTTP } from 'meteor/http'; +import _ from 'underscore'; + +import { settings } from '../../../settings'; +import { callbacks } from '../../../callbacks'; +import { SystemLogger } from '../../../logger'; +import { LivechatExternalMessage } from '../../lib/LivechatExternalMessage'; + +let knowledgeEnabled = false; +let apiaiKey = ''; +let apiaiLanguage = 'en'; +settings.get('Livechat_Knowledge_Enabled', function(key, value) { + knowledgeEnabled = value; +}); +settings.get('Livechat_Knowledge_Apiai_Key', function(key, value) { + apiaiKey = value; +}); +settings.get('Livechat_Knowledge_Apiai_Language', function(key, value) { + apiaiLanguage = value; +}); + +callbacks.add('afterSaveMessage', function(message, room) { + // skips this callback if the message was edited + if (!message || message.editedAt) { + return message; + } + + if (!knowledgeEnabled) { + return message; + } + + if (!(typeof room.t !== 'undefined' && room.t === 'l' && room.v && room.v.token)) { + return message; + } + + // if the message hasn't a token, it was not sent by the visitor, so ignore it + if (!message.token) { + return message; + } + + Meteor.defer(() => { + try { + const response = HTTP.post('https://api.api.ai/api/query?v=20150910', { + data: { + query: message.msg, + lang: apiaiLanguage, + sessionId: room._id, + }, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Bearer ${ apiaiKey }`, + }, + }); + + if (response.data && response.data.status.code === 200 && !_.isEmpty(response.data.result.fulfillment.speech)) { + LivechatExternalMessage.insert({ + rid: message.rid, + msg: response.data.result.fulfillment.speech, + orig: message._id, + ts: new Date(), + }); + } + } catch (e) { + SystemLogger.error('Error using Api.ai ->', e); + } + }); + + return message; +}, callbacks.priority.LOW, 'externalWebHook'); diff --git a/app/livechat/server/hooks/leadCapture.js b/app/livechat/server/hooks/leadCapture.js new file mode 100644 index 0000000000000..7dd2092246b67 --- /dev/null +++ b/app/livechat/server/hooks/leadCapture.js @@ -0,0 +1,47 @@ +import { callbacks } from '../../../callbacks'; +import { settings } from '../../../settings'; +import { LivechatVisitors } from '../../../models'; + +function validateMessage(message, room) { + // skips this callback if the message was edited + if (message.editedAt) { + return false; + } + + // message valid only if it is a livechat room + if (!(typeof room.t !== 'undefined' && room.t === 'l' && room.v && room.v.token)) { + return false; + } + + // if the message hasn't a token, it was NOT sent from the visitor, so ignore it + if (!message.token) { + return false; + } + + // if the message has a type means it is a special message (like the closing comment), so skips + if (message.t) { + return false; + } + + return true; +} + +callbacks.add('afterSaveMessage', function(message, room) { + if (!validateMessage(message, room)) { + return message; + } + + const phoneRegexp = new RegExp(settings.get('Livechat_lead_phone_regex'), 'g'); + const msgPhones = message.msg.match(phoneRegexp); + + const emailRegexp = new RegExp(settings.get('Livechat_lead_email_regex'), 'gi'); + const msgEmails = message.msg.match(emailRegexp); + + if (msgEmails || msgPhones) { + LivechatVisitors.saveGuestEmailPhoneById(room.v._id, msgEmails, msgPhones); + + callbacks.run('livechat.leadCapture', room); + } + + return message; +}, callbacks.priority.LOW, 'leadCapture'); diff --git a/app/livechat/server/hooks/markRoomResponded.js b/app/livechat/server/hooks/markRoomResponded.js new file mode 100644 index 0000000000000..4f629f901aaf4 --- /dev/null +++ b/app/livechat/server/hooks/markRoomResponded.js @@ -0,0 +1,32 @@ +import { Meteor } from 'meteor/meteor'; + +import { callbacks } from '../../../callbacks'; +import { Rooms } from '../../../models'; + +callbacks.add('afterSaveMessage', function(message, room) { + // skips this callback if the message was edited + if (!message || message.editedAt) { + return message; + } + + // check if room is yet awaiting for response + if (!(typeof room.t !== 'undefined' && room.t === 'l' && room.waitingResponse)) { + return message; + } + + // if the message has a token, it was sent by the visitor, so ignore it + if (message.token) { + return message; + } + + Meteor.defer(() => { + Rooms.setResponseByRoomId(room._id, { + user: { + _id: message.u._id, + username: message.u.username, + }, + }); + }); + + return message; +}, callbacks.priority.LOW, 'markRoomResponded'); diff --git a/app/livechat/server/hooks/offlineMessage.js b/app/livechat/server/hooks/offlineMessage.js new file mode 100644 index 0000000000000..91a0641c3fdcf --- /dev/null +++ b/app/livechat/server/hooks/offlineMessage.js @@ -0,0 +1,21 @@ +import { callbacks } from '../../../callbacks'; +import { settings } from '../../../settings'; +import { Livechat } from '../lib/Livechat'; + +callbacks.add('livechat.offlineMessage', (data) => { + if (!settings.get('Livechat_webhook_on_offline_msg')) { + return data; + } + + const postData = { + type: 'LivechatOfflineMessage', + sentAt: new Date(), + visitor: { + name: data.name, + email: data.email, + }, + message: data.message, + }; + + Livechat.sendRequest(postData); +}, callbacks.priority.MEDIUM, 'livechat-send-email-offline-message'); diff --git a/app/livechat/server/hooks/saveAnalyticsData.js b/app/livechat/server/hooks/saveAnalyticsData.js new file mode 100644 index 0000000000000..582fa0d9cc37c --- /dev/null +++ b/app/livechat/server/hooks/saveAnalyticsData.js @@ -0,0 +1,67 @@ +import { Meteor } from 'meteor/meteor'; + +import { callbacks } from '../../../callbacks'; +import { Rooms } from '../../../models'; + +callbacks.add('afterSaveMessage', function(message, room) { + // skips this callback if the message was edited + if (!message || message.editedAt) { + return message; + } + + // check if room is livechat + if (room.t !== 'l') { + return message; + } + + Meteor.defer(() => { + const now = new Date(); + let analyticsData; + + // if the message has a token, it was sent by the visitor + if (!message.token) { + const visitorLastQuery = room.metrics && room.metrics.v ? room.metrics.v.lq : room.ts; + const agentLastReply = room.metrics && room.metrics.servedBy ? room.metrics.servedBy.lr : room.ts; + const agentJoinTime = room.servedBy && room.servedBy.ts ? room.servedBy.ts : room.ts; + + const isResponseTt = room.metrics && room.metrics.response && room.metrics.response.tt; + const isResponseTotal = room.metrics && room.metrics.response && room.metrics.response.total; + + if (agentLastReply === room.ts) { // first response + const firstResponseDate = now; + const firstResponseTime = (now.getTime() - visitorLastQuery) / 1000; + const responseTime = (now.getTime() - visitorLastQuery) / 1000; + const avgResponseTime = ((isResponseTt ? room.metrics.response.tt : 0) + responseTime) / ((isResponseTotal ? room.metrics.response.total : 0) + 1); + + const firstReactionDate = now; + const firstReactionTime = (now.getTime() - agentJoinTime) / 1000; + const reactionTime = (now.getTime() - agentJoinTime) / 1000; + + analyticsData = { + firstResponseDate, + firstResponseTime, + responseTime, + avgResponseTime, + firstReactionDate, + firstReactionTime, + reactionTime, + }; + } else if (visitorLastQuery > agentLastReply) { // response, not first + const responseTime = (now.getTime() - visitorLastQuery) / 1000; + const avgResponseTime = ((isResponseTt ? room.metrics.response.tt : 0) + responseTime) / ((isResponseTotal ? room.metrics.response.total : 0) + 1); + + const reactionTime = (now.getTime() - visitorLastQuery) / 1000; + + analyticsData = { + responseTime, + avgResponseTime, + reactionTime, + }; + } // ignore, its continuing response + } + + Rooms.saveAnalyticsDataByRoomId(room, message, analyticsData); + }); + + return message; +}, callbacks.priority.LOW, 'saveAnalyticsData'); diff --git a/app/livechat/server/hooks/sendToCRM.js b/app/livechat/server/hooks/sendToCRM.js new file mode 100644 index 0000000000000..8e5dd50d80e5e --- /dev/null +++ b/app/livechat/server/hooks/sendToCRM.js @@ -0,0 +1,118 @@ +import { settings } from '../../../settings'; +import { callbacks } from '../../../callbacks'; +import { Messages, Rooms } from '../../../models'; +import { Livechat } from '../lib/Livechat'; + +const msgNavType = 'livechat_navigation_history'; + +const crmEnabled = () => { + const webhookUrl = settings.get('Livechat_webhookUrl'); + return webhookUrl !== '' && webhookUrl !== undefined; +}; + +const sendMessageType = (msgType) => { + const sendNavHistory = settings.get('Livechat_Visitor_navigation_as_a_message') && settings.get('Send_visitor_navigation_history_livechat_webhook_request'); + + return sendNavHistory && msgType === msgNavType; +}; + +function sendToCRM(type, room, includeMessages = true) { + if (crmEnabled() === false) { + return room; + } + + const postData = Livechat.getLivechatRoomGuestInfo(room); + + postData.type = type; + + postData.messages = []; + + let messages; + if (typeof includeMessages === 'boolean' && includeMessages) { + messages = Messages.findVisibleByRoomId(room._id, { sort: { ts: 1 } }); + } else if (includeMessages instanceof Array) { + messages = includeMessages; + } + + if (messages) { + messages.forEach((message) => { + if (message.t && !sendMessageType(message.t)) { + return; + } + const msg = { + _id: message._id, + username: message.u.username, + msg: message.msg, + ts: message.ts, + editedAt: message.editedAt, + }; + + if (message.u.username !== postData.visitor.username) { + msg.agentId = message.u._id; + } + + if (message.t === msgNavType) { + msg.navigation = message.navigation; + } + + postData.messages.push(msg); + }); + } + + const response = Livechat.sendRequest(postData); + + if (response && response.data && response.data.data) { + Rooms.saveCRMDataByRoomId(room._id, response.data.data); + } + + return room; +} + +callbacks.add('livechat.closeRoom', (room) => { + if (!settings.get('Livechat_webhook_on_close')) { + return room; + } + + return sendToCRM('LivechatSession', room); +}, callbacks.priority.MEDIUM, 'livechat-send-crm-close-room'); + +callbacks.add('livechat.saveInfo', (room) => { + // Do not send to CRM if the chat is still open + if (room.open) { + return room; + } + + return sendToCRM('LivechatEdit', room); +}, callbacks.priority.MEDIUM, 'livechat-send-crm-save-info'); + +callbacks.add('afterSaveMessage', function(message, room) { + // only call webhook if it is a livechat room + if (room.t !== 'l' || room.v == null || room.v.token == null) { + return message; + } + + // if the message has a token, it was sent from the visitor + // if not, it was sent from the agent + if (message.token) { + if (!settings.get('Livechat_webhook_on_visitor_message')) { + return message; + } + } else if (!settings.get('Livechat_webhook_on_agent_message')) { + return message; + } + // if the message has a type means it is a special message (like the closing comment), so skips + // unless the settings that handle with visitor navigation history are enabled + if (message.t && !sendMessageType(message.t)) { + return message; + } + + sendToCRM('Message', room, [message]); + return message; +}, callbacks.priority.MEDIUM, 'livechat-send-crm-message'); + +callbacks.add('livechat.leadCapture', (room) => { + if (!settings.get('Livechat_webhook_on_capture')) { + return room; + } + return sendToCRM('LeadCapture', room, false); +}, callbacks.priority.MEDIUM, 'livechat-send-crm-lead-capture'); diff --git a/app/livechat/server/hooks/sendToFacebook.js b/app/livechat/server/hooks/sendToFacebook.js new file mode 100644 index 0000000000000..30cafd9859a15 --- /dev/null +++ b/app/livechat/server/hooks/sendToFacebook.js @@ -0,0 +1,37 @@ +import { callbacks } from '../../../callbacks'; +import { settings } from '../../../settings'; +import OmniChannel from '../lib/OmniChannel'; + +callbacks.add('afterSaveMessage', function(message, room) { + // skips this callback if the message was edited + if (message.editedAt) { + return message; + } + + if (!settings.get('Livechat_Facebook_Enabled') || !settings.get('Livechat_Facebook_API_Key')) { + return message; + } + + // only send the sms by SMS if it is a livechat room with SMS set to true + if (!(typeof room.t !== 'undefined' && room.t === 'l' && room.facebook && room.v && room.v.token)) { + return message; + } + + // if the message has a token, it was sent from the visitor, so ignore it + if (message.token) { + return message; + } + + // if the message has a type means it is a special message (like the closing comment), so skips + if (message.t) { + return message; + } + + OmniChannel.reply({ + page: room.facebook.page.id, + token: room.v.token, + text: message.msg, + }); + + return message; +}, callbacks.priority.LOW, 'sendMessageToFacebook'); diff --git a/app/livechat/server/index.js b/app/livechat/server/index.js new file mode 100644 index 0000000000000..a81a5274b7821 --- /dev/null +++ b/app/livechat/server/index.js @@ -0,0 +1,91 @@ +import './livechat'; +import './startup'; +import './visitorStatus'; +import './agentStatus'; +import './permissions'; +import '../lib/messageTypes'; +import './config'; +import './roomType'; +import './hooks/externalMessage'; +import './hooks/leadCapture'; +import './hooks/markRoomResponded'; +import './hooks/offlineMessage'; +import './hooks/RDStation'; +import './hooks/saveAnalyticsData'; +import './hooks/sendToCRM'; +import './hooks/sendToFacebook'; +import './methods/addAgent'; +import './methods/addManager'; +import './methods/changeLivechatStatus'; +import './methods/closeByVisitor'; +import './methods/closeRoom'; +import './methods/facebook'; +import './methods/getCustomFields'; +import './methods/getAgentData'; +import './methods/getAgentOverviewData'; +import './methods/getAnalyticsChartData'; +import './methods/getAnalyticsOverviewData'; +import './methods/getInitialData'; +import './methods/getNextAgent'; +import './methods/loadHistory'; +import './methods/loginByToken'; +import './methods/pageVisited'; +import './methods/registerGuest'; +import './methods/removeAgent'; +import './methods/removeCustomField'; +import './methods/removeDepartment'; +import './methods/removeManager'; +import './methods/removeTrigger'; +import './methods/removeRoom'; +import './methods/saveAppearance'; +import './methods/saveCustomField'; +import './methods/saveDepartment'; +import './methods/saveInfo'; +import './methods/saveIntegration'; +import './methods/saveSurveyFeedback'; +import './methods/saveTrigger'; +import './methods/searchAgent'; +import './methods/sendMessageLivechat'; +import './methods/sendFileLivechatMessage'; +import './methods/sendOfflineMessage'; +import './methods/setCustomField'; +import './methods/setDepartmentForVisitor'; +import './methods/startVideoCall'; +import './methods/startFileUploadRoom'; +import './methods/transfer'; +import './methods/webhookTest'; +import './methods/setUpConnection'; +import './methods/takeInquiry'; +import './methods/returnAsInquiry'; +import './methods/saveOfficeHours'; +import './methods/sendTranscript'; +import './methods/getFirstRoomMessage'; +import '../lib/LivechatExternalMessage'; +import '../lib/LivechatInquiry'; +import './lib/Analytics'; +import './lib/QueueMethods'; +import './lib/OfficeClock'; +import './sendMessageBySMS'; +import './unclosedLivechats'; +import './publications/customFields'; +import './publications/departmentAgents'; +import './publications/externalMessages'; +import './publications/livechatAgents'; +import './publications/livechatAppearance'; +import './publications/livechatDepartments'; +import './publications/livechatIntegration'; +import './publications/livechatManagers'; +import './publications/livechatMonitoring'; +import './publications/livechatRooms'; +import './publications/livechatQueue'; +import './publications/livechatTriggers'; +import './publications/livechatVisitors'; +import './publications/visitorHistory'; +import './publications/visitorInfo'; +import './publications/visitorPageVisited'; +import './publications/livechatInquiries'; +import './publications/livechatOfficeHours'; +import './api'; +import './api/rest'; + +export { Livechat } from './lib/Livechat'; diff --git a/packages/rocketchat-livechat/server/lib/Analytics.js b/app/livechat/server/lib/Analytics.js similarity index 88% rename from packages/rocketchat-livechat/server/lib/Analytics.js rename to app/livechat/server/lib/Analytics.js index f0f718110765b..d5e5f7abf808a 100644 --- a/packages/rocketchat-livechat/server/lib/Analytics.js +++ b/app/livechat/server/lib/Analytics.js @@ -1,5 +1,7 @@ import moment from 'moment'; +import { Rooms } from '../../../models'; + /** * return readable time format from seconds * @param {Double} sec seconds @@ -118,28 +120,28 @@ export const Analytics = { * @returns {Integer} */ Total_conversations(date) { - return RocketChat.models.Rooms.getTotalConversationsBetweenDate('l', date); + return Rooms.getTotalConversationsBetweenDate('l', date); }, Avg_chat_duration(date) { let total = 0; let count = 0; - RocketChat.models.Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ metrics }) => { + Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ metrics }) => { if (metrics && metrics.chatDuration) { total += metrics.chatDuration; count++; } }); - const avgCD = (count) ? total / count : 0; + const avgCD = count ? total / count : 0; return Math.round(avgCD * 100) / 100; }, Total_messages(date) { let total = 0; - RocketChat.models.Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ msgs }) => { + Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ msgs }) => { if (msgs) { total += msgs; } @@ -157,14 +159,14 @@ export const Analytics = { Avg_first_response_time(date) { let frt = 0; let count = 0; - RocketChat.models.Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ metrics }) => { + Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ metrics }) => { if (metrics && metrics.response && metrics.response.ft) { frt += metrics.response.ft; count++; } }); - const avgFrt = (count) ? frt / count : 0; + const avgFrt = count ? frt / count : 0; return Math.round(avgFrt * 100) / 100; }, @@ -177,9 +179,9 @@ export const Analytics = { Best_first_response_time(date) { let maxFrt; - RocketChat.models.Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ metrics }) => { + Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ metrics }) => { if (metrics && metrics.response && metrics.response.ft) { - maxFrt = (maxFrt) ? Math.min(maxFrt, metrics.response.ft) : metrics.response.ft; + maxFrt = maxFrt ? Math.min(maxFrt, metrics.response.ft) : metrics.response.ft; } }); @@ -197,14 +199,14 @@ export const Analytics = { Avg_response_time(date) { let art = 0; let count = 0; - RocketChat.models.Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ metrics }) => { + Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ metrics }) => { if (metrics && metrics.response && metrics.response.avg) { art += metrics.response.avg; count++; } }); - const avgArt = (count) ? art / count : 0; + const avgArt = count ? art / count : 0; return Math.round(avgArt * 100) / 100; }, @@ -218,14 +220,14 @@ export const Analytics = { Avg_reaction_time(date) { let arnt = 0; let count = 0; - RocketChat.models.Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ metrics }) => { + Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ metrics }) => { if (metrics && metrics.reaction && metrics.reaction.ft) { arnt += metrics.reaction.ft; count++; } }); - const avgArnt = (count) ? arnt / count : 0; + const avgArnt = count ? arnt / count : 0; return Math.round(avgArnt * 100) / 100; }, @@ -274,7 +276,7 @@ export const Analytics = { totalMessages += msgs; const weekday = m.format('dddd'); // @string: Monday, Tuesday ... - totalMessagesOnWeekday.set(weekday, (totalMessagesOnWeekday.has(weekday)) ? (totalMessagesOnWeekday.get(weekday) + msgs) : msgs); + totalMessagesOnWeekday.set(weekday, totalMessagesOnWeekday.has(weekday) ? totalMessagesOnWeekday.get(weekday) + msgs : msgs); }; for (let m = moment(from); m.diff(to, 'days') <= 0; m.add(1, 'days')) { @@ -283,7 +285,7 @@ export const Analytics = { lt: moment(m).add(1, 'days'), }; - const result = RocketChat.models.Rooms.getAnalyticsMetricsBetweenDate('l', date); + const result = Rooms.getAnalyticsMetricsBetweenDate('l', date); totalConversations += result.count(); result.forEach(summarize(m)); @@ -301,11 +303,11 @@ export const Analytics = { lt: moment(h).add(1, 'hours'), }; - RocketChat.models.Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ + Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ msgs, }) => { const dayHour = h.format('H'); // @int : 0, 1, ... 23 - totalMessagesInHour.set(dayHour, (totalMessagesInHour.has(dayHour)) ? (totalMessagesInHour.get(dayHour) + msgs) : msgs); + totalMessagesInHour.set(dayHour, totalMessagesInHour.has(dayHour) ? totalMessagesInHour.get(dayHour) + msgs : msgs); }); } } @@ -329,7 +331,7 @@ export const Analytics = { value: (totalConversations / days).toFixed(2), }, { title: 'Busiest_time', - value: (busiestHour > 0) ? `${ moment(busiestHour, ['H']).format('hA') }-${ moment((parseInt(busiestHour) + 1) % 24, ['H']).format('hA') }` : '-', + value: busiestHour > 0 ? `${ moment(busiestHour, ['H']).format('hA') }-${ moment((parseInt(busiestHour) + 1) % 24, ['H']).format('hA') }` : '-', }]; return data; @@ -353,7 +355,7 @@ export const Analytics = { lt: to.add(1, 'days'), }; - RocketChat.models.Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ + Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ metrics, }) => { if (metrics && metrics.response && metrics.reaction) { @@ -391,7 +393,7 @@ export const Analytics = { * */ updateMap(map, key, value) { - map.set(key, map.has(key) ? (map.get(key) + value) : value); + map.set(key, map.has(key) ? map.get(key) + value : value); }, /** @@ -402,10 +404,10 @@ export const Analytics = { sortByValue(data, inv = false) { data.sort(function(a, b) { // sort array if (parseFloat(a.value) > parseFloat(b.value)) { - return (inv) ? -1 : 1; // if inv, reverse sort + return inv ? -1 : 1; // if inv, reverse sort } if (parseFloat(a.value) < parseFloat(b.value)) { - return (inv) ? 1 : -1; + return inv ? 1 : -1; } return 0; }); @@ -435,7 +437,7 @@ export const Analytics = { data: [], }; - RocketChat.models.Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ + Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ servedBy, }) => { if (servedBy) { @@ -485,7 +487,7 @@ export const Analytics = { data: [], }; - RocketChat.models.Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ + Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ metrics, servedBy, }) => { @@ -545,7 +547,7 @@ export const Analytics = { data: [], }; - RocketChat.models.Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ + Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ servedBy, msgs, }) => { @@ -589,7 +591,7 @@ export const Analytics = { data: [], }; - RocketChat.models.Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ + Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ metrics, servedBy, }) => { @@ -649,7 +651,7 @@ export const Analytics = { data: [], }; - RocketChat.models.Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ + Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ metrics, servedBy, }) => { @@ -701,7 +703,7 @@ export const Analytics = { data: [], }; - RocketChat.models.Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ + Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ metrics, servedBy, }) => { @@ -761,7 +763,7 @@ export const Analytics = { data: [], }; - RocketChat.models.Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ + Rooms.getAnalyticsMetricsBetweenDate('l', date).forEach(({ metrics, servedBy, }) => { diff --git a/app/livechat/server/lib/Livechat.js b/app/livechat/server/lib/Livechat.js new file mode 100644 index 0000000000000..2119a53f9084c --- /dev/null +++ b/app/livechat/server/lib/Livechat.js @@ -0,0 +1,951 @@ +import dns from 'dns'; + +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; +import { Random } from 'meteor/random'; +import { TAPi18n } from 'meteor/tap:i18n'; +import { HTTP } from 'meteor/http'; +import _ from 'underscore'; +import s from 'underscore.string'; +import moment from 'moment'; +import UAParser from 'ua-parser-js'; + +import { QueueMethods } from './QueueMethods'; +import { Analytics } from './Analytics'; +import { settings } from '../../../settings'; +import { callbacks } from '../../../callbacks'; +import { Users, Rooms, Messages, Subscriptions, Settings, LivechatDepartmentAgents, LivechatDepartment, LivechatCustomField, LivechatVisitors } from '../../../models'; +import { Logger } from '../../../logger'; +import { sendMessage, deleteMessage, updateMessage } from '../../../lib'; +import { addUserRoles, removeUserFromRoles } from '../../../authorization'; +import * as Mailer from '../../../mailer'; +import { LivechatInquiry } from '../../lib/LivechatInquiry'; + +export const Livechat = { + Analytics, + historyMonitorType: 'url', + + logger: new Logger('Livechat', { + sections: { + webhook: 'Webhook', + }, + }), + + getNextAgent(department) { + if (settings.get('Livechat_Routing_Method') === 'External') { + for (let i = 0; i < 10; i++) { + try { + const queryString = department ? `?departmentId=${ department }` : ''; + const result = HTTP.call('GET', `${ settings.get('Livechat_External_Queue_URL') }${ queryString }`, { + headers: { + 'User-Agent': 'RocketChat Server', + Accept: 'application/json', + 'X-RocketChat-Secret-Token': settings.get('Livechat_External_Queue_Token'), + }, + }); + + if (result && result.data && result.data.username) { + const agent = Users.findOneOnlineAgentByUsername(result.data.username); + + if (agent) { + return { + agentId: agent._id, + username: agent.username, + }; + } + } + } catch (e) { + console.error('Error requesting agent from external queue.', e); + break; + } + } + throw new Meteor.Error('no-agent-online', 'Sorry, no online agents'); + } else if (department) { + return LivechatDepartmentAgents.getNextAgentForDepartment(department); + } + return Users.getNextAgent(); + }, + getAgents(department) { + if (department) { + return LivechatDepartmentAgents.findByDepartmentId(department); + } + return Users.findAgents(); + }, + getOnlineAgents(department) { + if (department) { + return LivechatDepartmentAgents.getOnlineForDepartment(department); + } + return Users.findOnlineAgents(); + }, + getRequiredDepartment(onlineRequired = true) { + const departments = LivechatDepartment.findEnabledWithAgents(); + + return departments.fetch().find((dept) => { + if (!dept.showOnRegistration) { + return false; + } + if (!onlineRequired) { + return true; + } + const onlineAgents = LivechatDepartmentAgents.getOnlineForDepartment(dept._id); + return onlineAgents && onlineAgents.count() > 0; + }); + }, + getRoom(guest, message, roomInfo, agent) { + let room = Rooms.findOneById(message.rid); + let newRoom = false; + + if (room && !room.open) { + message.rid = Random.id(); + room = null; + } + + if (room == null) { + // if no department selected verify if there is at least one active and pick the first + if (!agent && !guest.department) { + const department = this.getRequiredDepartment(); + + if (department) { + guest.department = department._id; + } + } + + // delegate room creation to QueueMethods + const routingMethod = settings.get('Livechat_Routing_Method'); + room = QueueMethods[routingMethod](guest, message, roomInfo, agent); + + newRoom = true; + } + + if (!room || room.v.token !== guest.token) { + throw new Meteor.Error('cannot-access-room'); + } + + if (newRoom) { + Messages.setRoomIdByToken(guest.token, room._id); + } + + return { room, newRoom }; + }, + + sendMessage({ guest, message, roomInfo, agent }) { + const { room, newRoom } = this.getRoom(guest, message, roomInfo, agent); + if (guest.name) { + message.alias = guest.name; + } + + // return messages; + return _.extend(sendMessage(guest, message, room), { newRoom, showConnecting: this.showConnecting() }); + }, + + updateMessage({ guest, message }) { + check(message, Match.ObjectIncluding({ _id: String })); + + const originalMessage = Messages.findOneById(message._id); + if (!originalMessage || !originalMessage._id) { + return; + } + + const editAllowed = settings.get('Message_AllowEditing'); + const editOwn = originalMessage.u && originalMessage.u._id === guest._id; + + if (!editAllowed || !editOwn) { + throw new Meteor.Error('error-action-not-allowed', 'Message editing not allowed', { method: 'livechatUpdateMessage' }); + } + + updateMessage(message, guest); + + return true; + }, + + deleteMessage({ guest, message }) { + check(message, Match.ObjectIncluding({ _id: String })); + + const msg = Messages.findOneById(message._id); + if (!msg || !msg._id) { + return; + } + + const deleteAllowed = settings.get('Message_AllowDeleting'); + const editOwn = msg.u && msg.u._id === guest._id; + + if (!deleteAllowed || !editOwn) { + throw new Meteor.Error('error-action-not-allowed', 'Message deleting not allowed', { method: 'livechatDeleteMessage' }); + } + + deleteMessage(message, guest); + + return true; + }, + + registerGuest({ token, name, email, department, phone, username, connectionData } = {}) { + check(token, String); + + let userId; + const updateUser = { + $set: { + token, + }, + }; + + const user = LivechatVisitors.getVisitorByToken(token, { fields: { _id: 1 } }); + + if (user) { + userId = user._id; + } else { + if (!username) { + username = LivechatVisitors.getNextVisitorUsername(); + } + + let existingUser = null; + + if (s.trim(email) !== '' && (existingUser = LivechatVisitors.findOneGuestByEmailAddress(email))) { + userId = existingUser._id; + } else { + const userData = { + username, + }; + + if (settings.get('Livechat_Allow_collect_and_store_HTTP_header_informations')) { + const connection = this.connection || connectionData; + if (connection && connection.httpHeaders) { + userData.userAgent = connection.httpHeaders['user-agent']; + userData.ip = connection.httpHeaders['x-real-ip'] || connection.httpHeaders['x-forwarded-for'] || connection.clientAddress; + userData.host = connection.httpHeaders.host; + } + } + + userId = LivechatVisitors.insert(userData); + } + } + + if (phone) { + updateUser.$set.phone = [ + { phoneNumber: phone.number }, + ]; + } + + if (email && email.trim() !== '') { + updateUser.$set.visitorEmails = [ + { address: email }, + ]; + } + + if (name) { + updateUser.$set.name = name; + } + + if (!department) { + Object.assign(updateUser, { $unset: { department: 1 } }); + } else { + const dep = LivechatDepartment.findOneByIdOrName(department); + updateUser.$set.department = dep && dep._id; + } + + LivechatVisitors.updateById(userId, updateUser); + + return userId; + }, + + setDepartmentForGuest({ token, department } = {}) { + check(token, String); + + const updateUser = { + $set: { + department, + }, + }; + + const user = LivechatVisitors.getVisitorByToken(token, { fields: { _id: 1 } }); + if (user) { + return LivechatVisitors.updateById(user._id, updateUser); + } + return false; + }, + + saveGuest({ _id, name, email, phone }) { + const updateData = {}; + + if (name) { + updateData.name = name; + } + if (email) { + updateData.email = email; + } + if (phone) { + updateData.phone = phone; + } + const ret = LivechatVisitors.saveGuestById(_id, updateData); + + Meteor.defer(() => { + callbacks.run('livechat.saveGuest', updateData); + }); + + return ret; + }, + + closeRoom({ user, visitor, room, comment }) { + if (!room || room.t !== 'l' || !room.open) { + return false; + } + + const now = new Date(); + + const closeData = { + closedAt: now, + chatDuration: (now.getTime() - room.ts) / 1000, + }; + + if (user) { + closeData.closer = 'user'; + closeData.closedBy = { + _id: user._id, + username: user.username, + }; + } else if (visitor) { + closeData.closer = 'visitor'; + closeData.closedBy = { + _id: visitor._id, + username: visitor.username, + }; + } + + Rooms.closeByRoomId(room._id, closeData); + LivechatInquiry.closeByRoomId(room._id, closeData); + + const message = { + t: 'livechat-close', + msg: comment, + groupable: false, + }; + + // Retreive the closed room + room = Rooms.findOneByIdOrName(room._id); + + sendMessage(user, message, room); + + if (room.servedBy) { + Subscriptions.hideByRoomIdAndUserId(room._id, room.servedBy._id); + } + Messages.createCommandWithRoomIdAndUser('promptTranscript', room._id, closeData.closedBy); + + Meteor.defer(() => { + callbacks.run('livechat.closeRoom', room); + }); + + return true; + }, + + setCustomFields({ token, key, value, overwrite } = {}) { + check(token, String); + check(key, String); + check(value, String); + check(overwrite, Boolean); + + const customField = LivechatCustomField.findOneById(key); + if (!customField) { + throw new Meteor.Error('invalid-custom-field'); + } + + if (customField.scope === 'room') { + return Rooms.updateLivechatDataByToken(token, key, value, overwrite); + } + return LivechatVisitors.updateLivechatDataByToken(token, key, value, overwrite); + }, + + getInitSettings() { + const rcSettings = {}; + + Settings.findNotHiddenPublic([ + 'Livechat_title', + 'Livechat_title_color', + 'Livechat_enabled', + 'Livechat_registration_form', + 'Livechat_allow_switching_departments', + 'Livechat_offline_title', + 'Livechat_offline_title_color', + 'Livechat_offline_message', + 'Livechat_offline_success_message', + 'Livechat_offline_form_unavailable', + 'Livechat_display_offline_form', + 'Livechat_videocall_enabled', + 'Jitsi_Enabled', + 'Language', + 'Livechat_enable_transcript', + 'Livechat_transcript_message', + 'Livechat_fileupload_enabled', + 'FileUpload_Enabled', + 'Livechat_conversation_finished_message', + 'Livechat_name_field_registration_form', + 'Livechat_email_field_registration_form', + 'Livechat_registration_form_message', + 'Livechat_force_accept_data_processing_consent', + 'Livechat_data_processing_consent_text', + ]).forEach((setting) => { + rcSettings[setting._id] = setting.value; + }); + + settings.get('Livechat_history_monitor_type', (key, value) => { + rcSettings[key] = value; + }); + + rcSettings.Livechat_Show_Connecting = this.showConnecting(); + + return rcSettings; + }, + + saveRoomInfo(roomData, guestData) { + if ((roomData.topic != null || roomData.tags != null) && !Rooms.setTopicAndTagsById(roomData._id, roomData.topic, roomData.tags)) { + return false; + } + + Meteor.defer(() => { + callbacks.run('livechat.saveRoom', roomData); + }); + + if (!_.isEmpty(guestData.name)) { + return Rooms.setNameById(roomData._id, guestData.name, guestData.name) && Subscriptions.updateDisplayNameByRoomId(roomData._id, guestData.name); + } + }, + + closeOpenChats(userId, comment) { + const user = Users.findOneById(userId); + Rooms.findOpenByAgent(userId).forEach((room) => { + this.closeRoom({ user, room, comment }); + }); + }, + + forwardOpenChats(userId) { + Rooms.findOpenByAgent(userId).forEach((room) => { + const guest = LivechatVisitors.findOneById(room.v._id); + this.transfer(room, guest, { departmentId: guest.department }); + }); + }, + + savePageHistory(token, roomId, pageInfo) { + if (pageInfo.change === Livechat.historyMonitorType) { + const user = Users.findOneById('rocket.cat'); + + const pageTitle = pageInfo.title; + const pageUrl = pageInfo.location.href; + const extraData = { + navigation: { + page: pageInfo, + token, + }, + }; + + if (!roomId) { + // keep history of unregistered visitors for 1 month + const keepHistoryMiliseconds = 2592000000; + extraData.expireAt = new Date().getTime() + keepHistoryMiliseconds; + } + + if (!settings.get('Livechat_Visitor_navigation_as_a_message')) { + extraData._hidden = true; + } + + return Messages.createNavigationHistoryWithRoomIdMessageAndUser(roomId, `${ pageTitle } - ${ pageUrl }`, user, extraData); + } + }, + + transfer(room, guest, transferData) { + let agent; + + if (transferData.userId) { + const user = Users.findOneOnlineAgentById(transferData.userId); + if (!user) { + return false; + } + + const { _id: agentId, username } = user; + agent = Object.assign({}, { agentId, username }); + } else if (settings.get('Livechat_Routing_Method') !== 'Guest_Pool') { + agent = Livechat.getNextAgent(transferData.departmentId); + } else { + return Livechat.returnRoomAsInquiry(room._id, transferData.departmentId); + } + + const { servedBy } = room; + + if (agent && servedBy && agent.agentId !== servedBy._id) { + Rooms.changeAgentByRoomId(room._id, agent); + + if (transferData.departmentId) { + Rooms.changeDepartmentIdByRoomId(room._id, transferData.departmentId); + } + + const subscriptionData = { + rid: room._id, + name: guest.name || guest.username, + alert: true, + open: true, + unread: 1, + userMentions: 1, + groupMentions: 0, + u: { + _id: agent.agentId, + username: agent.username, + }, + t: 'l', + desktopNotifications: 'all', + mobilePushNotifications: 'all', + emailNotifications: 'all', + }; + Subscriptions.removeByRoomIdAndUserId(room._id, servedBy._id); + + Subscriptions.insert(subscriptionData); + Rooms.incUsersCountById(room._id); + + Messages.createUserLeaveWithRoomIdAndUser(room._id, { _id: servedBy._id, username: servedBy.username }); + Messages.createUserJoinWithRoomIdAndUser(room._id, { _id: agent.agentId, username: agent.username }); + + const guestData = { + token: guest.token, + department: transferData.departmentId, + }; + + this.setDepartmentForGuest(guestData); + const data = Users.getAgentInfo(agent.agentId); + + Livechat.stream.emit(room._id, { + type: 'agentData', + data, + }); + + return true; + } + + return false; + }, + + returnRoomAsInquiry(rid, departmentId) { + const room = Rooms.findOneById(rid); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'livechat:returnRoomAsInquiry' }); + } + + if (!room.servedBy) { + return false; + } + + const user = Users.findOne(room.servedBy._id); + if (!user || !user._id) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'livechat:returnRoomAsInquiry' }); + } + + const agentIds = []; + // get the agents of the department + if (departmentId) { + const agents = Livechat.getAgents(departmentId); + if (!agents || agents.count() === 0) { + return false; + } + + agents.forEach((agent) => { + agentIds.push(agent.agentId); + }); + + Rooms.changeDepartmentIdByRoomId(room._id, departmentId); + } + + // delete agent and room subscription + Subscriptions.removeByRoomId(rid); + + // remove agent from room + Rooms.removeAgentByRoomId(rid); + + // find inquiry corresponding to room + const inquiry = LivechatInquiry.findOne({ rid }); + if (!inquiry) { + return false; + } + + let openInq; + // mark inquiry as open + if (agentIds.length === 0) { + openInq = LivechatInquiry.openInquiry(inquiry._id); + } else { + openInq = LivechatInquiry.openInquiryWithAgents(inquiry._id, agentIds); + } + + if (openInq) { + Messages.createUserLeaveWithRoomIdAndUser(rid, { _id: room.servedBy._id, username: room.servedBy.username }); + + Livechat.stream.emit(rid, { + type: 'agentData', + data: null, + }); + } + + return openInq; + }, + + sendRequest(postData, callback, trying = 1) { + try { + const options = { data: postData }; + const secretToken = settings.get('Livechat_secret_token'); + if (secretToken !== '' && secretToken !== undefined) { + Object.assign(options, { headers: { 'X-RocketChat-Livechat-Token': secretToken } }); + } + return HTTP.post(settings.get('Livechat_webhookUrl'), options); + } catch (e) { + Livechat.logger.webhook.error(`Response error on ${ trying } try ->`, e); + // try 10 times after 10 seconds each + if (trying < 10) { + Livechat.logger.webhook.warn('Will try again in 10 seconds ...'); + trying++; + setTimeout(Meteor.bindEnvironment(() => { + Livechat.sendRequest(postData, callback, trying); + }), 10000); + } + } + }, + + getLivechatRoomGuestInfo(room) { + const visitor = LivechatVisitors.findOneById(room.v._id); + const agent = Users.findOneById(room.servedBy && room.servedBy._id); + + const ua = new UAParser(); + ua.setUA(visitor.userAgent); + + const postData = { + _id: room._id, + label: room.fname || room.label, // using same field for compatibility + topic: room.topic, + createdAt: room.ts, + lastMessageAt: room.lm, + tags: room.tags, + customFields: room.livechatData, + visitor: { + _id: visitor._id, + token: visitor.token, + name: visitor.name, + username: visitor.username, + email: null, + phone: null, + department: visitor.department, + ip: visitor.ip, + os: ua.getOS().name && `${ ua.getOS().name } ${ ua.getOS().version }`, + browser: ua.getBrowser().name && `${ ua.getBrowser().name } ${ ua.getBrowser().version }`, + customFields: visitor.livechatData, + }, + }; + + if (agent) { + postData.agent = { + _id: agent._id, + username: agent.username, + name: agent.name, + email: null, + }; + + if (agent.emails && agent.emails.length > 0) { + postData.agent.email = agent.emails[0].address; + } + } + + if (room.crmData) { + postData.crmData = room.crmData; + } + + if (visitor.visitorEmails && visitor.visitorEmails.length > 0) { + postData.visitor.email = visitor.visitorEmails; + } + if (visitor.phone && visitor.phone.length > 0) { + postData.visitor.phone = visitor.phone; + } + + return postData; + }, + + addAgent(username) { + check(username, String); + + const user = Users.findOneByUsername(username, { fields: { _id: 1, username: 1 } }); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'livechat:addAgent' }); + } + + if (addUserRoles(user._id, 'livechat-agent')) { + Users.setOperator(user._id, true); + Users.setLivechatStatus(user._id, 'available'); + return user; + } + + return false; + }, + + addManager(username) { + check(username, String); + + const user = Users.findOneByUsername(username, { fields: { _id: 1, username: 1 } }); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'livechat:addManager' }); + } + + if (addUserRoles(user._id, 'livechat-manager')) { + return user; + } + + return false; + }, + + removeAgent(username) { + check(username, String); + + const user = Users.findOneByUsername(username, { fields: { _id: 1 } }); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'livechat:removeAgent' }); + } + + if (removeUserFromRoles(user._id, 'livechat-agent')) { + Users.setOperator(user._id, false); + Users.setLivechatStatus(user._id, 'not-available'); + return true; + } + + return false; + }, + + removeManager(username) { + check(username, String); + + const user = Users.findOneByUsername(username, { fields: { _id: 1 } }); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'livechat:removeManager' }); + } + + return removeUserFromRoles(user._id, 'livechat-manager'); + }, + + removeGuest(_id) { + check(_id, String); + const guest = LivechatVisitors.findOneById(_id); + if (!guest) { + throw new Meteor.Error('error-invalid-guest', 'Invalid guest', { method: 'livechat:removeGuest' }); + } + + this.cleanGuestHistory(_id); + return LivechatVisitors.removeById(_id); + }, + + cleanGuestHistory(_id) { + const guest = LivechatVisitors.findOneById(_id); + if (!guest) { + throw new Meteor.Error('error-invalid-guest', 'Invalid guest', { method: 'livechat:cleanGuestHistory' }); + } + + const { token } = guest; + check(token, String); + + Rooms.findByVisitorToken(token).forEach((room) => { + Messages.removeFilesByRoomId(room._id); + Messages.removeByRoomId(room._id); + }); + + Subscriptions.removeByVisitorToken(token); + Rooms.removeByVisitorToken(token); + }, + + saveDepartment(_id, departmentData, departmentAgents) { + check(_id, Match.Maybe(String)); + + check(departmentData, { + enabled: Boolean, + name: String, + description: Match.Optional(String), + showOnRegistration: Boolean, + email: String, + showOnOfflineForm: Boolean, + }); + + check(departmentAgents, [ + Match.ObjectIncluding({ + agentId: String, + username: String, + }), + ]); + + if (_id) { + const department = LivechatDepartment.findOneById(_id); + if (!department) { + throw new Meteor.Error('error-department-not-found', 'Department not found', { method: 'livechat:saveDepartment' }); + } + } + + return LivechatDepartment.createOrUpdateDepartment(_id, departmentData, departmentAgents); + }, + + removeDepartment(_id) { + check(_id, String); + + const department = LivechatDepartment.findOneById(_id, { fields: { _id: 1 } }); + + if (!department) { + throw new Meteor.Error('department-not-found', 'Department not found', { method: 'livechat:removeDepartment' }); + } + + return LivechatDepartment.removeById(_id); + }, + + showConnecting() { + return settings.get('Livechat_Routing_Method') === 'Guest_Pool'; + }, + + sendEmail(from, to, replyTo, subject, html) { + Mailer.send({ + to, + from, + replyTo, + subject, + html, + }); + }, + + sendTranscript({ token, rid, email }) { + check(rid, String); + check(email, String); + + const room = Rooms.findOneById(rid); + + const visitor = LivechatVisitors.getVisitorByToken(token); + const userLanguage = (visitor && visitor.language) || settings.get('Language') || 'en'; + + // allow to only user to send transcripts from their own chats + if (!room || room.t !== 'l' || !room.v || room.v.token !== token) { + throw new Meteor.Error('error-invalid-room', 'Invalid room'); + } + + const messages = Messages.findVisibleByRoomIdNotContainingTypes(rid, ['livechat_navigation_history'], { sort: { ts: 1 } }); + + let html = '

'; + messages.forEach((message) => { + if (message.t && ['command', 'livechat-close', 'livechat_video_call'].indexOf(message.t) !== -1) { + return; + } + + let author; + if (message.u._id === visitor._id) { + author = TAPi18n.__('You', { lng: userLanguage }); + } else { + author = message.u.username; + } + + const datetime = moment(message.ts).locale(userLanguage).format('LLL'); + const singleMessage = ` +

${ author } ${ datetime }

+

${ message.msg }

+ `; + html += singleMessage; + }); + + html = `${ html }
`; + + let fromEmail = settings.get('From_Email').match(/\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,4}\b/i); + + if (fromEmail) { + fromEmail = fromEmail[0]; + } else { + fromEmail = settings.get('From_Email'); + } + + const subject = TAPi18n.__('Transcript_of_your_livechat_conversation', { lng: userLanguage }); + + this.sendEmail(fromEmail, email, fromEmail, subject, html); + + Meteor.defer(() => { + callbacks.run('livechat.sendTranscript', messages, email); + }); + + return true; + }, + + notifyGuestStatusChanged(token, status) { + LivechatInquiry.updateVisitorStatus(token, status); + Rooms.updateVisitorStatus(token, status); + }, + + sendOfflineMessage(data = {}) { + if (!settings.get('Livechat_display_offline_form')) { + return false; + } + + const message = `${ data.message }`.replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1
$2'); + + const html = ` +

New livechat message

+

Visitor name: ${ data.name }

+

Visitor email: ${ data.email }

+

Message:
${ message }

`; + + let fromEmail = settings.get('From_Email').match(/\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,4}\b/i); + + if (fromEmail) { + fromEmail = fromEmail[0]; + } else { + fromEmail = settings.get('From_Email'); + } + + if (settings.get('Livechat_validate_offline_email')) { + const emailDomain = data.email.substr(data.email.lastIndexOf('@') + 1); + + try { + Meteor.wrapAsync(dns.resolveMx)(emailDomain); + } catch (e) { + throw new Meteor.Error('error-invalid-email-address', 'Invalid email address', { method: 'livechat:sendOfflineMessage' }); + } + } + + let emailTo = settings.get('Livechat_offline_email'); + if (data.department) { + const dep = LivechatDepartment.findOneByIdOrName(data.department); + emailTo = dep.email || emailTo; + } + + const from = `${ data.name } - ${ data.email } <${ fromEmail }>`; + const replyTo = `${ data.name } <${ data.email }>`; + const subject = `Livechat offline message from ${ data.name }: ${ `${ data.message }`.substring(0, 20) }`; + + this.sendEmail(from, emailTo, replyTo, subject, html); + + Meteor.defer(() => { + callbacks.run('livechat.offlineMessage', data); + }); + + return true; + }, + + notifyAgentStatusChanged(userId, status) { + Rooms.findOpenByAgent(userId).forEach((room) => { + Livechat.stream.emit(room._id, { + type: 'agentStatus', + status, + }); + }); + }, +}; + +Livechat.stream = new Meteor.Streamer('livechat-room'); + +Livechat.stream.allowRead((roomId, extraData) => { + const room = Rooms.findOneById(roomId); + + if (!room) { + console.warn(`Invalid eventName: "${ roomId }"`); + return false; + } + + if (room.t === 'l' && extraData && extraData.visitorToken && room.v.token === extraData.visitorToken) { + return true; + } + return false; +}); + +settings.get('Livechat_history_monitor_type', (key, value) => { + Livechat.historyMonitorType = value; +}); diff --git a/app/livechat/server/lib/OfficeClock.js b/app/livechat/server/lib/OfficeClock.js new file mode 100644 index 0000000000000..03a6e352f8b0b --- /dev/null +++ b/app/livechat/server/lib/OfficeClock.js @@ -0,0 +1,15 @@ +// Every minute check if office closed +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../settings'; +import { Users, LivechatOfficeHour } from '../../../models'; + +Meteor.setInterval(function() { + if (settings.get('Livechat_enable_office_hours')) { + if (LivechatOfficeHour.isOpeningTime()) { + Users.openOffice(); + } else if (LivechatOfficeHour.isClosingTime()) { + Users.closeOffice(); + } + } +}, 60000); diff --git a/app/livechat/server/lib/OmniChannel.js b/app/livechat/server/lib/OmniChannel.js new file mode 100644 index 0000000000000..fae27bb1e0230 --- /dev/null +++ b/app/livechat/server/lib/OmniChannel.js @@ -0,0 +1,70 @@ +import { HTTP } from 'meteor/http'; + +import { settings } from '../../../settings'; + +const gatewayURL = 'https://omni.rocket.chat'; + +export default { + enable() { + const result = HTTP.call('POST', `${ gatewayURL }/facebook/enable`, { + headers: { + authorization: `Bearer ${ settings.get('Livechat_Facebook_API_Key') }`, + 'content-type': 'application/json', + }, + data: { + url: settings.get('Site_Url'), + }, + }); + return result.data; + }, + + disable() { + const result = HTTP.call('DELETE', `${ gatewayURL }/facebook/enable`, { + headers: { + authorization: `Bearer ${ settings.get('Livechat_Facebook_API_Key') }`, + 'content-type': 'application/json', + }, + }); + return result.data; + }, + + listPages() { + const result = HTTP.call('GET', `${ gatewayURL }/facebook/pages`, { + headers: { + authorization: `Bearer ${ settings.get('Livechat_Facebook_API_Key') }`, + }, + }); + return result.data; + }, + + subscribe(pageId) { + const result = HTTP.call('POST', `${ gatewayURL }/facebook/page/${ pageId }/subscribe`, { + headers: { + authorization: `Bearer ${ settings.get('Livechat_Facebook_API_Key') }`, + }, + }); + return result.data; + }, + + unsubscribe(pageId) { + const result = HTTP.call('DELETE', `${ gatewayURL }/facebook/page/${ pageId }/subscribe`, { + headers: { + authorization: `Bearer ${ settings.get('Livechat_Facebook_API_Key') }`, + }, + }); + return result.data; + }, + + reply({ page, token, text }) { + return HTTP.call('POST', `${ gatewayURL }/facebook/reply`, { + headers: { + authorization: `Bearer ${ settings.get('Livechat_Facebook_API_Key') }`, + }, + data: { + page, + token, + text, + }, + }); + }, +}; diff --git a/app/livechat/server/lib/QueueMethods.js b/app/livechat/server/lib/QueueMethods.js new file mode 100644 index 0000000000000..92262256e6aae --- /dev/null +++ b/app/livechat/server/lib/QueueMethods.js @@ -0,0 +1,204 @@ +import { Meteor } from 'meteor/meteor'; +import { TAPi18n } from 'meteor/tap:i18n'; +import _ from 'underscore'; + +import { Livechat } from './Livechat'; +import { Rooms, Subscriptions, Users } from '../../../models'; +import { settings } from '../../../settings'; +import { sendNotification } from '../../../lib'; +import { LivechatInquiry } from '../../lib/LivechatInquiry'; + +export const QueueMethods = { + /* Least Amount Queuing method: + * + * default method where the agent with the least number + * of open chats is paired with the incoming livechat + */ + 'Least_Amount'(guest, message, roomInfo, agent) { + if (!agent || (agent.username && !Users.findOneOnlineAgentByUsername(agent.username))) { + agent = Livechat.getNextAgent(guest.department); + if (!agent) { + throw new Meteor.Error('no-agent-online', 'Sorry, no online agents'); + } + } + + Rooms.updateLivechatRoomCount(); + + const room = _.extend({ + _id: message.rid, + msgs: 0, + usersCount: 1, + lm: new Date(), + fname: (roomInfo && roomInfo.fname) || guest.name || guest.username, + // usernames: [agent.username, guest.username], + t: 'l', + ts: new Date(), + v: { + _id: guest._id, + username: guest.username, + token: message.token, + status: guest.status || 'online', + }, + servedBy: { + _id: agent.agentId, + username: agent.username, + ts: new Date(), + }, + cl: false, + open: true, + waitingResponse: true, + }, roomInfo); + + const subscriptionData = { + rid: message.rid, + fname: guest.name || guest.username, + alert: true, + open: true, + unread: 1, + userMentions: 1, + groupMentions: 0, + u: { + _id: agent.agentId, + username: agent.username, + }, + t: 'l', + desktopNotifications: 'all', + mobilePushNotifications: 'all', + emailNotifications: 'all', + v: { + _id: guest._id, + username: guest.username, + token: message.token, + status: guest.status, + }, + }; + + if (guest.department) { + room.departmentId = guest.department; + } + + Rooms.insert(room); + + Subscriptions.insert(subscriptionData); + + Livechat.stream.emit(room._id, { + type: 'agentData', + data: Users.getAgentInfo(agent.agentId), + }); + + return room; + }, + /* Guest Pool Queuing Method: + * + * An incomming livechat is created as an Inquiry + * which is picked up from an agent. + * An Inquiry is visible to all agents (TODO: in the correct department) + * + * A room is still created with the initial message, but it is occupied by + * only the client until paired with an agent + */ + 'Guest_Pool'(guest, message, roomInfo) { + const onlineAgents = Livechat.getOnlineAgents(guest.department); + if (settings.get('Livechat_guest_pool_with_no_agents') === false) { + if (!onlineAgents || onlineAgents.count() === 0) { + throw new Meteor.Error('no-agent-online', 'Sorry, no online agents'); + } + } + + const allAgents = Livechat.getAgents(guest.department); + if (allAgents.count() === 0) { + throw new Meteor.Error('no-agent-available', 'Sorry, no available agents.'); + } + + Rooms.updateLivechatRoomCount(); + + const agentIds = []; + + allAgents.forEach((agent) => { + if (guest.department) { + agentIds.push(agent.agentId); + } else { + agentIds.push(agent._id); + } + }); + + const inquiry = { + rid: message.rid, + message: message.msg, + name: guest.name || guest.username, + ts: new Date(), + department: guest.department, + agents: agentIds, + status: 'open', + v: { + _id: guest._id, + username: guest.username, + token: message.token, + status: guest.status || 'online', + }, + t: 'l', + }; + + const room = _.extend({ + _id: message.rid, + msgs: 0, + usersCount: 0, + lm: new Date(), + fname: guest.name || guest.username, + // usernames: [guest.username], + t: 'l', + ts: new Date(), + v: { + _id: guest._id, + username: guest.username, + token: message.token, + status: guest.status, + }, + cl: false, + open: true, + waitingResponse: true, + }, roomInfo); + + if (guest.department) { + room.departmentId = guest.department; + } + + LivechatInquiry.insert(inquiry); + Rooms.insert(room); + + // Alert only the online agents of the queued request + onlineAgents.forEach((agent) => { + const { _id, active, emails, language, status, statusConnection, username } = agent; + + sendNotification({ + // fake a subscription in order to make use of the function defined above + subscription: { + rid: room._id, + t: room.t, + u: { + _id, + }, + receiver: [{ + active, + emails, + language, + status, + statusConnection, + username, + }], + }, + sender: room.v, + hasMentionToAll: true, // consider all agents to be in the room + hasMentionToHere: false, + message: Object.assign(message, { u: room.v }), + notificationMessage: message.msg, + room: Object.assign(room, { name: TAPi18n.__('New_livechat_in_queue') }), + mentionIds: [], + }); + }); + return room; + }, + 'External'(guest, message, roomInfo, agent) { + return this['Least_Amount'](guest, message, roomInfo, agent); // eslint-disable-line + }, +}; diff --git a/app/livechat/server/livechat.js b/app/livechat/server/livechat.js new file mode 100644 index 0000000000000..b23ec41b37354 --- /dev/null +++ b/app/livechat/server/livechat.js @@ -0,0 +1,43 @@ +import url from 'url'; + +import _ from 'underscore'; +import { Meteor } from 'meteor/meteor'; +import { WebApp } from 'meteor/webapp'; + +import { settings } from '../../settings/server'; +import { addServerUrlToIndex, addServerUrlToHead } from '../lib/Assets'; + +const latestVersion = '1.0.0'; +const indexHtmlWithServerURL = addServerUrlToIndex(Assets.getText('livechat/index.html')); +const headHtmlWithServerURL = addServerUrlToHead(Assets.getText('livechat/head.html')); +const isLatestVersion = (version) => version && version === latestVersion; + +WebApp.connectHandlers.use('/livechat', Meteor.bindEnvironment((req, res, next) => { + const reqUrl = url.parse(req.url); + if (reqUrl.pathname !== '/') { + return next(); + } + + const { version } = req.query; + const html = isLatestVersion(version) ? indexHtmlWithServerURL : headHtmlWithServerURL; + + res.setHeader('content-type', 'text/html; charset=utf-8'); + + let domainWhiteList = settings.get('Livechat_AllowedDomainsList'); + if (req.headers.referer && !_.isEmpty(domainWhiteList.trim())) { + domainWhiteList = _.map(domainWhiteList.split(','), function(domain) { + return domain.trim(); + }); + + const referer = url.parse(req.headers.referer); + if (!_.contains(domainWhiteList, referer.host)) { + res.setHeader('X-FRAME-OPTIONS', 'DENY'); + return next(); + } + + res.setHeader('X-FRAME-OPTIONS', `ALLOW-FROM ${ referer.protocol }//${ referer.host }`); + } + + res.write(html); + res.end(); +})); diff --git a/app/livechat/server/methods/addAgent.js b/app/livechat/server/methods/addAgent.js new file mode 100644 index 0000000000000..14b5467e303df --- /dev/null +++ b/app/livechat/server/methods/addAgent.js @@ -0,0 +1,14 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:addAgent'(username) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-manager')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:addAgent' }); + } + + return Livechat.addAgent(username); + }, +}); diff --git a/app/livechat/server/methods/addManager.js b/app/livechat/server/methods/addManager.js new file mode 100644 index 0000000000000..38b6024064672 --- /dev/null +++ b/app/livechat/server/methods/addManager.js @@ -0,0 +1,14 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:addManager'(username) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-manager')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:addManager' }); + } + + return Livechat.addManager(username); + }, +}); diff --git a/packages/rocketchat-livechat/server/methods/changeLivechatStatus.js b/app/livechat/server/methods/changeLivechatStatus.js similarity index 78% rename from packages/rocketchat-livechat/server/methods/changeLivechatStatus.js rename to app/livechat/server/methods/changeLivechatStatus.js index 7fbd9c1b0e101..e612508303600 100644 --- a/packages/rocketchat-livechat/server/methods/changeLivechatStatus.js +++ b/app/livechat/server/methods/changeLivechatStatus.js @@ -1,5 +1,7 @@ import { Meteor } from 'meteor/meteor'; +import { Users } from '../../../models'; + Meteor.methods({ 'livechat:changeLivechatStatus'() { if (!Meteor.userId()) { @@ -10,6 +12,6 @@ Meteor.methods({ const newStatus = user.statusLivechat === 'available' ? 'not-available' : 'available'; - return RocketChat.models.Users.setLivechatStatus(user._id, newStatus); + return Users.setLivechatStatus(user._id, newStatus); }, }); diff --git a/app/livechat/server/methods/closeByVisitor.js b/app/livechat/server/methods/closeByVisitor.js new file mode 100644 index 0000000000000..bd9354bb837cf --- /dev/null +++ b/app/livechat/server/methods/closeByVisitor.js @@ -0,0 +1,20 @@ +import { Meteor } from 'meteor/meteor'; +import { TAPi18n } from 'meteor/tap:i18n'; + +import { settings } from '../../../settings'; +import { Rooms, LivechatVisitors } from '../../../models'; +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:closeByVisitor'({ roomId, token }) { + const visitor = LivechatVisitors.getVisitorByToken(token); + + const language = (visitor && visitor.language) || settings.get('Language') || 'en'; + + return Livechat.closeRoom({ + visitor, + room: Rooms.findOneOpenByRoomIdAndVisitorToken(roomId, token), + comment: TAPi18n.__('Closed_by_visitor', { lng: language }), + }); + }, +}); diff --git a/app/livechat/server/methods/closeRoom.js b/app/livechat/server/methods/closeRoom.js new file mode 100644 index 0000000000000..f24e678350a65 --- /dev/null +++ b/app/livechat/server/methods/closeRoom.js @@ -0,0 +1,27 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Subscriptions, Rooms } from '../../../models'; +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:closeRoom'(roomId, comment) { + const userId = Meteor.userId(); + if (!userId || !hasPermission(userId, 'close-livechat-room')) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'livechat:closeRoom' }); + } + + const user = Meteor.user(); + + const subscription = Subscriptions.findOneByRoomIdAndUserId(roomId, user._id, { _id: 1 }); + if (!subscription && !hasPermission(userId, 'close-others-livechat-room')) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'livechat:closeRoom' }); + } + + return Livechat.closeRoom({ + user, + room: Rooms.findOneById(roomId), + comment, + }); + }, +}); diff --git a/app/livechat/server/methods/facebook.js b/app/livechat/server/methods/facebook.js new file mode 100644 index 0000000000000..7d17161fe9384 --- /dev/null +++ b/app/livechat/server/methods/facebook.js @@ -0,0 +1,66 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { settings } from '../../../settings'; +import OmniChannel from '../lib/OmniChannel'; + +Meteor.methods({ + 'livechat:facebook'(options) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-manager')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:addAgent' }); + } + + try { + switch (options.action) { + case 'initialState': { + return { + enabled: settings.get('Livechat_Facebook_Enabled'), + hasToken: !!settings.get('Livechat_Facebook_API_Key'), + }; + } + + case 'enable': { + const result = OmniChannel.enable(); + + if (!result.success) { + return result; + } + + return settings.updateById('Livechat_Facebook_Enabled', true); + } + + case 'disable': { + OmniChannel.disable(); + + return settings.updateById('Livechat_Facebook_Enabled', false); + } + + case 'list-pages': { + return OmniChannel.listPages(); + } + + case 'subscribe': { + return OmniChannel.subscribe(options.page); + } + + case 'unsubscribe': { + return OmniChannel.unsubscribe(options.page); + } + } + } catch (e) { + if (e.response && e.response.data && e.response.data.error) { + if (e.response.data.error.error) { + throw new Meteor.Error(e.response.data.error.error, e.response.data.error.message); + } + if (e.response.data.error.response) { + throw new Meteor.Error('integration-error', e.response.data.error.response.error.message); + } + if (e.response.data.error.message) { + throw new Meteor.Error('integration-error', e.response.data.error.message); + } + } + console.error('Error contacting omni.rocket.chat:', e); + throw new Meteor.Error('integration-error', e.error); + } + }, +}); diff --git a/app/livechat/server/methods/getAgentData.js b/app/livechat/server/methods/getAgentData.js new file mode 100644 index 0000000000000..4e4c2d171af94 --- /dev/null +++ b/app/livechat/server/methods/getAgentData.js @@ -0,0 +1,24 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { Users, Rooms, LivechatVisitors } from '../../../models'; + +Meteor.methods({ + 'livechat:getAgentData'({ roomId, token }) { + check(roomId, String); + check(token, String); + + const room = Rooms.findOneById(roomId); + const visitor = LivechatVisitors.getVisitorByToken(token); + + if (!room || room.t !== 'l' || !room.v || room.v.token !== visitor.token) { + throw new Meteor.Error('error-invalid-room', 'Invalid room'); + } + + if (!room.servedBy) { + return; + } + + return Users.getAgentInfo(room.servedBy._id); + }, +}); diff --git a/app/livechat/server/methods/getAgentOverviewData.js b/app/livechat/server/methods/getAgentOverviewData.js new file mode 100644 index 0000000000000..7722de1bacb3c --- /dev/null +++ b/app/livechat/server/methods/getAgentOverviewData.js @@ -0,0 +1,21 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:getAgentOverviewData'(options) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-manager')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'livechat:getAgentOverviewData', + }); + } + + if (!(options.chartOptions && options.chartOptions.name)) { + console.log('Incorrect analytics options'); + return; + } + + return Livechat.Analytics.getAgentOverviewData(options); + }, +}); diff --git a/app/livechat/server/methods/getAnalyticsChartData.js b/app/livechat/server/methods/getAnalyticsChartData.js new file mode 100644 index 0000000000000..efe2a8529052f --- /dev/null +++ b/app/livechat/server/methods/getAnalyticsChartData.js @@ -0,0 +1,21 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:getAnalyticsChartData'(options) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-manager')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'livechat:getAnalyticsChartData', + }); + } + + if (!(options.chartOptions && options.chartOptions.name)) { + console.log('Incorrect chart options'); + return; + } + + return Livechat.Analytics.getAnalyticsChartData(options); + }, +}); diff --git a/app/livechat/server/methods/getAnalyticsOverviewData.js b/app/livechat/server/methods/getAnalyticsOverviewData.js new file mode 100644 index 0000000000000..4f41291d804a6 --- /dev/null +++ b/app/livechat/server/methods/getAnalyticsOverviewData.js @@ -0,0 +1,21 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:getAnalyticsOverviewData'(options) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-manager')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'livechat:getAnalyticsOverviewData', + }); + } + + if (!(options.analyticsOptions && options.analyticsOptions.name)) { + console.log('Incorrect analytics options'); + return; + } + + return Livechat.Analytics.getAnalyticsOverviewData(options); + }, +}); diff --git a/app/livechat/server/methods/getCustomFields.js b/app/livechat/server/methods/getCustomFields.js new file mode 100644 index 0000000000000..df39364bcceec --- /dev/null +++ b/app/livechat/server/methods/getCustomFields.js @@ -0,0 +1,9 @@ +import { Meteor } from 'meteor/meteor'; + +import { LivechatCustomField } from '../../../models'; + +Meteor.methods({ + 'livechat:getCustomFields'() { + return LivechatCustomField.find().fetch(); + }, +}); diff --git a/app/livechat/server/methods/getFirstRoomMessage.js b/app/livechat/server/methods/getFirstRoomMessage.js new file mode 100644 index 0000000000000..7ff74d6da0965 --- /dev/null +++ b/app/livechat/server/methods/getFirstRoomMessage.js @@ -0,0 +1,23 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { Rooms, Messages } from '../../../models'; +import { hasPermission } from '../../../authorization'; + +Meteor.methods({ + 'livechat:getFirstRoomMessage'({ rid }) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-l-room')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:getFirstRoomMessage' }); + } + + check(rid, String); + + const room = Rooms.findOneById(rid); + + if (!room || room.t !== 'l') { + throw new Meteor.Error('error-invalid-room', 'Invalid room'); + } + + return Messages.findOne({ rid }, { sort: { ts: 1 } }); + }, +}); diff --git a/packages/rocketchat-livechat/server/methods/getInitialData.js b/app/livechat/server/methods/getInitialData.js similarity index 78% rename from packages/rocketchat-livechat/server/methods/getInitialData.js rename to app/livechat/server/methods/getInitialData.js index bacc6bb0ca513..67c6798ac49e7 100644 --- a/packages/rocketchat-livechat/server/methods/getInitialData.js +++ b/app/livechat/server/methods/getInitialData.js @@ -1,7 +1,8 @@ import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; -import LivechatVisitors from '../models/LivechatVisitors'; +import { Rooms, Users, LivechatDepartment, LivechatTrigger, LivechatVisitors } from '../../../models'; +import { Livechat } from '../lib/Livechat'; Meteor.methods({ 'livechat:getInitialData'(visitorToken, departmentId) { @@ -27,6 +28,7 @@ Meteor.methods({ nameFieldRegistrationForm: null, emailFieldRegistrationForm: null, registrationFormMessage: null, + showConnecting: false, }; const options = { @@ -41,7 +43,7 @@ Meteor.methods({ departmentId: 1, }, }; - const room = (departmentId) ? RocketChat.models.Rooms.findOpenByVisitorTokenAndDepartmentId(visitorToken, departmentId, options).fetch() : RocketChat.models.Rooms.findOpenByVisitorToken(visitorToken, options).fetch(); + const room = departmentId ? Rooms.findOpenByVisitorTokenAndDepartmentId(visitorToken, departmentId, options).fetch() : Rooms.findOpenByVisitorToken(visitorToken, options).fetch(); if (room && room.length > 0) { info.room = room[0]; } @@ -59,7 +61,7 @@ Meteor.methods({ info.visitor = visitor; } - const initSettings = RocketChat.Livechat.getInitSettings(); + const initSettings = Livechat.getInitSettings(); info.title = initSettings.Livechat_title; info.color = initSettings.Livechat_title_color; @@ -80,19 +82,20 @@ Meteor.methods({ info.nameFieldRegistrationForm = initSettings.Livechat_name_field_registration_form; info.emailFieldRegistrationForm = initSettings.Livechat_email_field_registration_form; info.registrationFormMessage = initSettings.Livechat_registration_form_message; + info.showConnecting = initSettings.Livechat_Show_Connecting; - info.agentData = room && room[0] && room[0].servedBy && RocketChat.models.Users.getAgentInfo(room[0].servedBy._id); + info.agentData = room && room[0] && room[0].servedBy && Users.getAgentInfo(room[0].servedBy._id); - RocketChat.models.LivechatTrigger.findEnabled().forEach((trigger) => { + LivechatTrigger.findEnabled().forEach((trigger) => { info.triggers.push(_.pick(trigger, '_id', 'actions', 'conditions', 'runOnce')); }); - RocketChat.models.LivechatDepartment.findEnabledWithAgents().forEach((department) => { + LivechatDepartment.findEnabledWithAgents().forEach((department) => { info.departments.push(department); }); info.allowSwitchingDepartments = initSettings.Livechat_allow_switching_departments; - info.online = RocketChat.models.Users.findOnlineAgents().count() > 0; + info.online = Users.findOnlineAgents().count() > 0; return info; }, }); diff --git a/app/livechat/server/methods/getNextAgent.js b/app/livechat/server/methods/getNextAgent.js new file mode 100644 index 0000000000000..3776f85cf636e --- /dev/null +++ b/app/livechat/server/methods/getNextAgent.js @@ -0,0 +1,31 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { Rooms, Users } from '../../../models'; +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:getNextAgent'({ token, department }) { + check(token, String); + + const room = Rooms.findOpenByVisitorToken(token).fetch(); + + if (room && room.length > 0) { + return; + } + + if (!department) { + const requireDeparment = Livechat.getRequiredDepartment(); + if (requireDeparment) { + department = requireDeparment._id; + } + } + + const agent = Livechat.getNextAgent(department); + if (!agent) { + return; + } + + return Users.getAgentInfo(agent.agentId); + }, +}); diff --git a/app/livechat/server/methods/loadHistory.js b/app/livechat/server/methods/loadHistory.js new file mode 100644 index 0000000000000..395ea3ea5a94d --- /dev/null +++ b/app/livechat/server/methods/loadHistory.js @@ -0,0 +1,16 @@ +import { Meteor } from 'meteor/meteor'; + +import { loadMessageHistory } from '../../../lib'; +import { LivechatVisitors } from '../../../models'; + +Meteor.methods({ + 'livechat:loadHistory'({ token, rid, end, limit = 20, ls }) { + const visitor = LivechatVisitors.getVisitorByToken(token, { fields: { _id: 1 } }); + + if (!visitor) { + return; + } + + return loadMessageHistory({ userId: visitor._id, rid, end, limit, ls }); + }, +}); diff --git a/packages/rocketchat-livechat/server/methods/loginByToken.js b/app/livechat/server/methods/loginByToken.js similarity index 81% rename from packages/rocketchat-livechat/server/methods/loginByToken.js rename to app/livechat/server/methods/loginByToken.js index 1eaae13492bc9..9320b1f424d61 100644 --- a/packages/rocketchat-livechat/server/methods/loginByToken.js +++ b/app/livechat/server/methods/loginByToken.js @@ -1,5 +1,6 @@ import { Meteor } from 'meteor/meteor'; -import LivechatVisitors from '../models/LivechatVisitors'; + +import { LivechatVisitors } from '../../../models'; Meteor.methods({ 'livechat:loginByToken'(token) { diff --git a/app/livechat/server/methods/pageVisited.js b/app/livechat/server/methods/pageVisited.js new file mode 100644 index 0000000000000..c9c88e95ddc59 --- /dev/null +++ b/app/livechat/server/methods/pageVisited.js @@ -0,0 +1,9 @@ +import { Meteor } from 'meteor/meteor'; + +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:pageVisited'(token, room, pageInfo) { + Livechat.savePageHistory(token, room, pageInfo); + }, +}); diff --git a/packages/rocketchat-livechat/server/methods/registerGuest.js b/app/livechat/server/methods/registerGuest.js similarity index 75% rename from packages/rocketchat-livechat/server/methods/registerGuest.js rename to app/livechat/server/methods/registerGuest.js index ba9935bfec36f..7622747f434b7 100644 --- a/packages/rocketchat-livechat/server/methods/registerGuest.js +++ b/app/livechat/server/methods/registerGuest.js @@ -1,9 +1,11 @@ import { Meteor } from 'meteor/meteor'; -import LivechatVisitors from '../models/LivechatVisitors'; + +import { Messages, Rooms, LivechatVisitors } from '../../../models'; +import { Livechat } from '../lib/Livechat'; Meteor.methods({ 'livechat:registerGuest'({ token, name, email, department, customFields } = {}) { - const userId = RocketChat.Livechat.registerGuest.call(this, { + const userId = Livechat.registerGuest.call(this, { token, name, email, @@ -11,7 +13,7 @@ Meteor.methods({ }); // update visited page history to not expire - RocketChat.models.Messages.keepHistoryForToken(token); + Messages.keepHistoryForToken(token); const visitor = LivechatVisitors.getVisitorByToken(token, { fields: { @@ -24,9 +26,9 @@ Meteor.methods({ }); // If it's updating an existing visitor, it must also update the roomInfo - const cursor = RocketChat.models.Rooms.findOpenByVisitorToken(token); + const cursor = Rooms.findOpenByVisitorToken(token); cursor.forEach((room) => { - RocketChat.Livechat.saveRoomInfo(room, visitor); + Livechat.saveRoomInfo(room, visitor); }); if (customFields && customFields instanceof Array) { diff --git a/app/livechat/server/methods/removeAgent.js b/app/livechat/server/methods/removeAgent.js new file mode 100644 index 0000000000000..ade5412cb7eb5 --- /dev/null +++ b/app/livechat/server/methods/removeAgent.js @@ -0,0 +1,14 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:removeAgent'(username) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-manager')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:removeAgent' }); + } + + return Livechat.removeAgent(username); + }, +}); diff --git a/app/livechat/server/methods/removeCustomField.js b/app/livechat/server/methods/removeCustomField.js new file mode 100644 index 0000000000000..98018741bf7fb --- /dev/null +++ b/app/livechat/server/methods/removeCustomField.js @@ -0,0 +1,23 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { hasPermission } from '../../../authorization'; +import { LivechatCustomField } from '../../../models'; + +Meteor.methods({ + 'livechat:removeCustomField'(_id) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-manager')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:removeCustomField' }); + } + + check(_id, String); + + const customField = LivechatCustomField.findOneById(_id, { fields: { _id: 1 } }); + + if (!customField) { + throw new Meteor.Error('error-invalid-custom-field', 'Custom field not found', { method: 'livechat:removeCustomField' }); + } + + return LivechatCustomField.removeById(_id); + }, +}); diff --git a/app/livechat/server/methods/removeDepartment.js b/app/livechat/server/methods/removeDepartment.js new file mode 100644 index 0000000000000..2dd1515f30cbc --- /dev/null +++ b/app/livechat/server/methods/removeDepartment.js @@ -0,0 +1,14 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:removeDepartment'(_id) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-manager')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:removeDepartment' }); + } + + return Livechat.removeDepartment(_id); + }, +}); diff --git a/app/livechat/server/methods/removeManager.js b/app/livechat/server/methods/removeManager.js new file mode 100644 index 0000000000000..b479d867803b9 --- /dev/null +++ b/app/livechat/server/methods/removeManager.js @@ -0,0 +1,14 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:removeManager'(username) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-manager')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:removeManager' }); + } + + return Livechat.removeManager(username); + }, +}); diff --git a/app/livechat/server/methods/removeRoom.js b/app/livechat/server/methods/removeRoom.js new file mode 100644 index 0000000000000..f060d91dcad38 --- /dev/null +++ b/app/livechat/server/methods/removeRoom.js @@ -0,0 +1,36 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Rooms, Messages, Subscriptions } from '../../../models'; + +Meteor.methods({ + 'livechat:removeRoom'(rid) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'remove-closed-livechat-rooms')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:removeRoom' }); + } + + const room = Rooms.findOneById(rid); + + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'livechat:removeRoom', + }); + } + + if (room.t !== 'l') { + throw new Meteor.Error('error-this-is-not-a-livechat-room', 'This is not a Livechat room', { + method: 'livechat:removeRoom', + }); + } + + if (room.open) { + throw new Meteor.Error('error-room-is-not-closed', 'Room is not closed', { + method: 'livechat:removeRoom', + }); + } + + Messages.removeByRoomId(rid); + Subscriptions.removeByRoomId(rid); + return Rooms.removeById(rid); + }, +}); diff --git a/app/livechat/server/methods/removeTrigger.js b/app/livechat/server/methods/removeTrigger.js new file mode 100644 index 0000000000000..f39431b578b0d --- /dev/null +++ b/app/livechat/server/methods/removeTrigger.js @@ -0,0 +1,17 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { hasPermission } from '../../../authorization'; +import { LivechatTrigger } from '../../../models'; + +Meteor.methods({ + 'livechat:removeTrigger'(triggerId) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-manager')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:removeTrigger' }); + } + + check(triggerId, String); + + return LivechatTrigger.removeById(triggerId); + }, +}); diff --git a/app/livechat/server/methods/returnAsInquiry.js b/app/livechat/server/methods/returnAsInquiry.js new file mode 100644 index 0000000000000..6dae070a1d9f7 --- /dev/null +++ b/app/livechat/server/methods/returnAsInquiry.js @@ -0,0 +1,14 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:returnAsInquiry'(rid, departmentId) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-l-room')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:saveDepartment' }); + } + + return Livechat.returnRoomAsInquiry(rid, departmentId); + }, +}); diff --git a/packages/rocketchat-livechat/server/methods/saveAppearance.js b/app/livechat/server/methods/saveAppearance.js similarity index 79% rename from packages/rocketchat-livechat/server/methods/saveAppearance.js rename to app/livechat/server/methods/saveAppearance.js index c0fa2e91ce825..34eecd65e462c 100644 --- a/packages/rocketchat-livechat/server/methods/saveAppearance.js +++ b/app/livechat/server/methods/saveAppearance.js @@ -1,8 +1,11 @@ import { Meteor } from 'meteor/meteor'; +import { hasPermission } from '../../../authorization'; +import { settings as rcSettings } from '../../../settings'; + Meteor.methods({ 'livechat:saveAppearance'(settings) { - if (!Meteor.userId() || !RocketChat.authz.hasPermission(Meteor.userId(), 'view-livechat-manager')) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-manager')) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:saveAppearance' }); } @@ -31,9 +34,7 @@ Meteor.methods({ } settings.forEach((setting) => { - RocketChat.settings.updateById(setting._id, setting.value); + rcSettings.updateById(setting._id, setting.value); }); - - return; }, }); diff --git a/app/livechat/server/methods/saveCustomField.js b/app/livechat/server/methods/saveCustomField.js new file mode 100644 index 0000000000000..9f6520b4d5675 --- /dev/null +++ b/app/livechat/server/methods/saveCustomField.js @@ -0,0 +1,32 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; + +import { hasPermission } from '../../../authorization'; +import { LivechatCustomField } from '../../../models'; + +Meteor.methods({ + 'livechat:saveCustomField'(_id, customFieldData) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-manager')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:saveCustomField' }); + } + + if (_id) { + check(_id, String); + } + + check(customFieldData, Match.ObjectIncluding({ field: String, label: String, scope: String, visibility: String })); + + if (!/^[0-9a-zA-Z-_]+$/.test(customFieldData.field)) { + throw new Meteor.Error('error-invalid-custom-field-nmae', 'Invalid custom field name. Use only letters, numbers, hyphens and underscores.', { method: 'livechat:saveCustomField' }); + } + + if (_id) { + const customField = LivechatCustomField.findOneById(_id); + if (!customField) { + throw new Meteor.Error('error-invalid-custom-field', 'Custom Field Not found', { method: 'livechat:saveCustomField' }); + } + } + + return LivechatCustomField.createOrUpdateCustomField(_id, customFieldData.field, customFieldData.label, customFieldData.scope, customFieldData.visibility); + }, +}); diff --git a/app/livechat/server/methods/saveDepartment.js b/app/livechat/server/methods/saveDepartment.js new file mode 100644 index 0000000000000..95c9e87e5e0fa --- /dev/null +++ b/app/livechat/server/methods/saveDepartment.js @@ -0,0 +1,14 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:saveDepartment'(_id, departmentData, departmentAgents) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-manager')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:saveDepartment' }); + } + + return Livechat.saveDepartment(_id, departmentData, departmentAgents); + }, +}); diff --git a/app/livechat/server/methods/saveInfo.js b/app/livechat/server/methods/saveInfo.js new file mode 100644 index 0000000000000..08e74ce364a7c --- /dev/null +++ b/app/livechat/server/methods/saveInfo.js @@ -0,0 +1,46 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; + +import { hasPermission } from '../../../authorization'; +import { Rooms } from '../../../models'; +import { callbacks } from '../../../callbacks'; +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:saveInfo'(guestData, roomData) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-l-room')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:saveInfo' }); + } + + check(guestData, Match.ObjectIncluding({ + _id: String, + name: Match.Optional(String), + email: Match.Optional(String), + phone: Match.Optional(String), + })); + + check(roomData, Match.ObjectIncluding({ + _id: String, + topic: Match.Optional(String), + tags: Match.Optional(String), + })); + + const room = Rooms.findOneById(roomData._id, { fields: { t: 1, servedBy: 1 } }); + + if (room == null || room.t !== 'l') { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'livechat:saveInfo' }); + } + + if ((!room.servedBy || room.servedBy._id !== Meteor.userId()) && !hasPermission(Meteor.userId(), 'save-others-livechat-room-info')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:saveInfo' }); + } + + const ret = Livechat.saveGuest(guestData) && Livechat.saveRoomInfo(roomData, guestData); + + Meteor.defer(() => { + callbacks.run('livechat.saveInfo', Rooms.findOneById(roomData._id)); + }); + + return ret; + }, +}); diff --git a/app/livechat/server/methods/saveIntegration.js b/app/livechat/server/methods/saveIntegration.js new file mode 100644 index 0000000000000..521b01e9dac4e --- /dev/null +++ b/app/livechat/server/methods/saveIntegration.js @@ -0,0 +1,37 @@ +import { Meteor } from 'meteor/meteor'; +import s from 'underscore.string'; + +import { hasPermission } from '../../../authorization'; +import { settings } from '../../../settings'; + +Meteor.methods({ + 'livechat:saveIntegration'(values) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-manager')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:saveIntegration' }); + } + + if (typeof values.Livechat_webhookUrl !== 'undefined') { + settings.updateById('Livechat_webhookUrl', s.trim(values.Livechat_webhookUrl)); + } + + if (typeof values.Livechat_secret_token !== 'undefined') { + settings.updateById('Livechat_secret_token', s.trim(values.Livechat_secret_token)); + } + + if (typeof values.Livechat_webhook_on_close !== 'undefined') { + settings.updateById('Livechat_webhook_on_close', !!values.Livechat_webhook_on_close); + } + + if (typeof values.Livechat_webhook_on_offline_msg !== 'undefined') { + settings.updateById('Livechat_webhook_on_offline_msg', !!values.Livechat_webhook_on_offline_msg); + } + + if (typeof values.Livechat_webhook_on_visitor_message !== 'undefined') { + settings.updateById('Livechat_webhook_on_visitor_message', !!values.Livechat_webhook_on_visitor_message); + } + + if (typeof values.Livechat_webhook_on_agent_message !== 'undefined') { + settings.updateById('Livechat_webhook_on_agent_message', !!values.Livechat_webhook_on_agent_message); + } + }, +}); diff --git a/app/livechat/server/methods/saveOfficeHours.js b/app/livechat/server/methods/saveOfficeHours.js new file mode 100644 index 0000000000000..0c41d7190350e --- /dev/null +++ b/app/livechat/server/methods/saveOfficeHours.js @@ -0,0 +1,9 @@ +import { Meteor } from 'meteor/meteor'; + +import { LivechatOfficeHour } from '../../../models'; + +Meteor.methods({ + 'livechat:saveOfficeHours'(day, start, finish, open) { + LivechatOfficeHour.updateHours(day, start, finish, open); + }, +}); diff --git a/packages/rocketchat-livechat/server/methods/saveSurveyFeedback.js b/app/livechat/server/methods/saveSurveyFeedback.js similarity index 76% rename from packages/rocketchat-livechat/server/methods/saveSurveyFeedback.js rename to app/livechat/server/methods/saveSurveyFeedback.js index 56cd820a5957f..47167e9c96c20 100644 --- a/packages/rocketchat-livechat/server/methods/saveSurveyFeedback.js +++ b/app/livechat/server/methods/saveSurveyFeedback.js @@ -1,9 +1,9 @@ -/* eslint new-cap: [2, {"capIsNewExceptions": ["Match.ObjectIncluding"]}] */ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; -import LivechatVisitors from '../models/LivechatVisitors'; import _ from 'underscore'; +import { Rooms, LivechatVisitors } from '../../../models'; + Meteor.methods({ 'livechat:saveSurveyFeedback'(visitorToken, visitorRoom, formData) { check(visitorToken, String); @@ -11,7 +11,7 @@ Meteor.methods({ check(formData, [Match.ObjectIncluding({ name: String, value: String })]); const visitor = LivechatVisitors.getVisitorByToken(visitorToken); - const room = RocketChat.models.Rooms.findOneById(visitorRoom); + const room = Rooms.findOneById(visitorRoom); if (visitor !== undefined && room !== undefined && room.v !== undefined && room.v.token === visitor.token) { const updateData = {}; @@ -23,7 +23,7 @@ Meteor.methods({ } } if (!_.isEmpty(updateData)) { - return RocketChat.models.Rooms.updateSurveyFeedbackById(room._id, updateData); + return Rooms.updateSurveyFeedbackById(room._id, updateData); } } }, diff --git a/app/livechat/server/methods/saveTrigger.js b/app/livechat/server/methods/saveTrigger.js new file mode 100644 index 0000000000000..6be81479badc5 --- /dev/null +++ b/app/livechat/server/methods/saveTrigger.js @@ -0,0 +1,28 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; + +import { hasPermission } from '../../../authorization'; +import { LivechatTrigger } from '../../../models'; + +Meteor.methods({ + 'livechat:saveTrigger'(trigger) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-manager')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:saveTrigger' }); + } + + check(trigger, { + _id: Match.Maybe(String), + name: String, + description: String, + enabled: Boolean, + runOnce: Boolean, + conditions: Array, + actions: Array, + }); + + if (trigger._id) { + return LivechatTrigger.updateById(trigger._id, trigger); + } + return LivechatTrigger.insert(trigger); + }, +}); diff --git a/app/livechat/server/methods/searchAgent.js b/app/livechat/server/methods/searchAgent.js new file mode 100644 index 0000000000000..8fe28ebd3a09b --- /dev/null +++ b/app/livechat/server/methods/searchAgent.js @@ -0,0 +1,25 @@ +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { hasPermission } from '../../../authorization'; +import { Users } from '../../../models'; + +Meteor.methods({ + 'livechat:searchAgent'(username) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-manager')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:searchAgent' }); + } + + if (!username || !_.isString(username)) { + throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', { method: 'livechat:searchAgent' }); + } + + const user = Users.findOneByUsernameIgnoringCase(username, { fields: { _id: 1, username: 1 } }); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'livechat:searchAgent' }); + } + + return user; + }, +}); diff --git a/packages/rocketchat-livechat/server/methods/sendFileLivechatMessage.js b/app/livechat/server/methods/sendFileLivechatMessage.js similarity index 90% rename from packages/rocketchat-livechat/server/methods/sendFileLivechatMessage.js rename to app/livechat/server/methods/sendFileLivechatMessage.js index 0968b8e5a2e69..e0724d7e31880 100644 --- a/packages/rocketchat-livechat/server/methods/sendFileLivechatMessage.js +++ b/app/livechat/server/methods/sendFileLivechatMessage.js @@ -1,7 +1,9 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; import { Random } from 'meteor/random'; -import LivechatVisitors from '../models/LivechatVisitors'; + +import { Rooms, LivechatVisitors } from '../../../models'; +import { FileUpload } from '../../../file-upload'; Meteor.methods({ async 'sendFileLivechatMessage'(roomId, visitorToken, file, msgData = {}) { @@ -11,7 +13,7 @@ Meteor.methods({ return false; } - const room = RocketChat.models.Rooms.findOneOpenByRoomIdAndVisitorToken(roomId, visitorToken); + const room = Rooms.findOneOpenByRoomIdAndVisitorToken(roomId, visitorToken); if (!room) { return false; diff --git a/packages/rocketchat-livechat/server/methods/sendMessageLivechat.js b/app/livechat/server/methods/sendMessageLivechat.js similarity index 83% rename from packages/rocketchat-livechat/server/methods/sendMessageLivechat.js rename to app/livechat/server/methods/sendMessageLivechat.js index 2efafe1ef7880..47c2dad856cdd 100644 --- a/packages/rocketchat-livechat/server/methods/sendMessageLivechat.js +++ b/app/livechat/server/methods/sendMessageLivechat.js @@ -1,6 +1,8 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; -import LivechatVisitors from '../models/LivechatVisitors'; + +import { LivechatVisitors } from '../../../models'; +import { Livechat } from '../lib/Livechat'; Meteor.methods({ sendMessageLivechat({ token, _id, rid, msg, attachments }, agent) { @@ -27,7 +29,7 @@ Meteor.methods({ throw new Meteor.Error('invalid-token'); } - return RocketChat.Livechat.sendMessage({ + return Livechat.sendMessage({ guest, message: { _id, diff --git a/app/livechat/server/methods/sendOfflineMessage.js b/app/livechat/server/methods/sendOfflineMessage.js new file mode 100644 index 0000000000000..e390785d2e455 --- /dev/null +++ b/app/livechat/server/methods/sendOfflineMessage.js @@ -0,0 +1,25 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; + +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:sendOfflineMessage'(data) { + check(data, { + name: String, + email: String, + message: String, + }); + + return Livechat.sendOfflineMessage(data); + }, +}); + +DDPRateLimiter.addRule({ + type: 'method', + name: 'livechat:sendOfflineMessage', + connectionId() { + return true; + }, +}, 1, 5000); diff --git a/app/livechat/server/methods/sendTranscript.js b/app/livechat/server/methods/sendTranscript.js new file mode 100644 index 0000000000000..3bc83b95aa15f --- /dev/null +++ b/app/livechat/server/methods/sendTranscript.js @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; + +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:sendTranscript'(token, rid, email) { + check(rid, String); + check(email, String); + + return Livechat.sendTranscript({ token, rid, email }); + }, +}); + +DDPRateLimiter.addRule({ + type: 'method', + name: 'livechat:sendTranscript', + connectionId() { + return true; + }, +}, 1, 5000); diff --git a/app/livechat/server/methods/setCustomField.js b/app/livechat/server/methods/setCustomField.js new file mode 100644 index 0000000000000..b1d0ddb6eb350 --- /dev/null +++ b/app/livechat/server/methods/setCustomField.js @@ -0,0 +1,18 @@ +import { Meteor } from 'meteor/meteor'; + +import { Rooms, LivechatVisitors, LivechatCustomField } from '../../../models'; + +Meteor.methods({ + 'livechat:setCustomField'(token, key, value, overwrite = true) { + const customField = LivechatCustomField.findOneById(key); + if (customField) { + if (customField.scope === 'room') { + return Rooms.updateLivechatDataByToken(token, key, value, overwrite); + } + // Save in user + return LivechatVisitors.updateLivechatDataByToken(token, key, value, overwrite); + } + + return true; + }, +}); diff --git a/app/livechat/server/methods/setDepartmentForVisitor.js b/app/livechat/server/methods/setDepartmentForVisitor.js new file mode 100644 index 0000000000000..4578338f07b0a --- /dev/null +++ b/app/livechat/server/methods/setDepartmentForVisitor.js @@ -0,0 +1,30 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { Rooms, Messages, LivechatVisitors } from '../../../models'; +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:setDepartmentForVisitor'({ roomId, visitorToken, departmentId } = {}) { + check(roomId, String); + check(visitorToken, String); + check(departmentId, String); + + const room = Rooms.findOneById(roomId); + const visitor = LivechatVisitors.getVisitorByToken(visitorToken); + + if (!room || room.t !== 'l' || !room.v || room.v.token !== visitor.token) { + throw new Meteor.Error('error-invalid-room', 'Invalid room'); + } + + // update visited page history to not expire + Messages.keepHistoryForToken(visitorToken); + + const transferData = { + roomId, + departmentId, + }; + + return Livechat.transfer(room, visitor, transferData); + }, +}); diff --git a/app/livechat/server/methods/setUpConnection.js b/app/livechat/server/methods/setUpConnection.js new file mode 100644 index 0000000000000..21dd64263ef9b --- /dev/null +++ b/app/livechat/server/methods/setUpConnection.js @@ -0,0 +1,21 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:setUpConnection'(data) { + check(data, { + token: String, + }); + + const { token } = data; + + if (!this.connection.livechatToken) { + this.connection.livechatToken = token; + this.connection.onClose(() => { + Livechat.notifyGuestStatusChanged(token, 'offline'); + }); + } + }, +}); diff --git a/app/livechat/server/methods/startFileUploadRoom.js b/app/livechat/server/methods/startFileUploadRoom.js new file mode 100644 index 0000000000000..2779af23d2bb4 --- /dev/null +++ b/app/livechat/server/methods/startFileUploadRoom.js @@ -0,0 +1,21 @@ +import { Meteor } from 'meteor/meteor'; +import { Random } from 'meteor/random'; + +import { LivechatVisitors } from '../../../models'; +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:startFileUploadRoom'(roomId, token) { + const guest = LivechatVisitors.getVisitorByToken(token); + + const message = { + _id: Random.id(), + rid: roomId || Random.id(), + msg: '', + ts: new Date(), + token: guest.token, + }; + + return Livechat.getRoom(guest, message); + }, +}); diff --git a/app/livechat/server/methods/startVideoCall.js b/app/livechat/server/methods/startVideoCall.js new file mode 100644 index 0000000000000..5b91fce6e3295 --- /dev/null +++ b/app/livechat/server/methods/startVideoCall.js @@ -0,0 +1,39 @@ +import { Meteor } from 'meteor/meteor'; +import { Random } from 'meteor/random'; + +import { Messages } from '../../../models'; +import { settings } from '../../../settings'; +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:startVideoCall'(roomId) { + if (!Meteor.userId()) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'livechat:closeByVisitor' }); + } + + const guest = Meteor.user(); + + const message = { + _id: Random.id(), + rid: roomId || Random.id(), + msg: '', + ts: new Date(), + }; + + const { room } = Livechat.getRoom(guest, message, { jitsiTimeout: new Date(Date.now() + 3600 * 1000) }); + message.rid = room._id; + + Messages.createWithTypeRoomIdMessageAndUser('livechat_video_call', room._id, '', guest, { + actionLinks: [ + { icon: 'icon-videocam', i18nLabel: 'Accept', method_id: 'createLivechatCall', params: '' }, + { icon: 'icon-cancel', i18nLabel: 'Decline', method_id: 'denyLivechatCall', params: '' }, + ], + }); + + return { + roomId: room._id, + domain: settings.get('Jitsi_Domain'), + jitsiRoom: settings.get('Jitsi_URL_Room_Prefix') + settings.get('uniqueID') + roomId, + }; + }, +}); diff --git a/app/livechat/server/methods/takeInquiry.js b/app/livechat/server/methods/takeInquiry.js new file mode 100644 index 0000000000000..27edb5d518d98 --- /dev/null +++ b/app/livechat/server/methods/takeInquiry.js @@ -0,0 +1,89 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Users, Rooms, Subscriptions, Messages, LivechatVisitors } from '../../../models'; +import { LivechatInquiry } from '../../lib/LivechatInquiry'; +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:takeInquiry'(inquiryId) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-l-room')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:takeInquiry' }); + } + + const inquiry = LivechatInquiry.findOneById(inquiryId); + + if (!inquiry || inquiry.status === 'taken') { + throw new Meteor.Error('error-not-allowed', 'Inquiry already taken', { method: 'livechat:takeInquiry' }); + } + + const user = Users.findOneById(Meteor.userId()); + + const agent = { + agentId: user._id, + username: user.username, + ts: new Date(), + }; + + const { v: { _id: visitorId } = {} } = inquiry; + const guest = LivechatVisitors.findOneById(visitorId); + if (!guest) { + throw new Meteor.Error('error-invalid-guest', 'Invalid guest', { method: 'livechat:takeInquiry' }); + } + + // add subscription + const subscriptionData = { + rid: inquiry.rid, + name: inquiry.name, + alert: true, + open: true, + unread: 1, + userMentions: 1, + groupMentions: 0, + u: { + _id: agent.agentId, + username: agent.username, + }, + t: 'l', + desktopNotifications: 'all', + mobilePushNotifications: 'all', + emailNotifications: 'all', + v: { + _id: guest._id, + username: guest.username, + token: guest.token, + status: guest.status || 'online', + }, + }; + + Subscriptions.insert(subscriptionData); + Rooms.incUsersCountById(inquiry.rid); + + // update room + const room = Rooms.findOneById(inquiry.rid); + + Rooms.changeAgentByRoomId(inquiry.rid, agent); + + room.servedBy = { + _id: agent.agentId, + username: agent.username, + ts: agent.ts, + }; + + // mark inquiry as taken + LivechatInquiry.takeInquiry(inquiry._id); + + // remove sending message from guest widget + // dont check if setting is true, because if settingwas switched off inbetween guest entered pool, + // and inquiry being taken, message would not be switched off. + Messages.createCommandWithRoomIdAndUser('connected', room._id, user); + + Livechat.stream.emit(room._id, { + type: 'agentData', + data: Users.getAgentInfo(agent.agentId), + }); + + // return inquiry (for redirecting agent to the room route) + return inquiry; + }, +}); diff --git a/app/livechat/server/methods/transfer.js b/app/livechat/server/methods/transfer.js new file mode 100644 index 0000000000000..8c2952ce3b5af --- /dev/null +++ b/app/livechat/server/methods/transfer.js @@ -0,0 +1,34 @@ +import { Meteor } from 'meteor/meteor'; +import { Match, check } from 'meteor/check'; + +import { hasPermission, hasRole } from '../../../authorization'; +import { Rooms, Subscriptions, LivechatVisitors } from '../../../models'; +import { Livechat } from '../lib/Livechat'; + +Meteor.methods({ + 'livechat:transfer'(transferData) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-l-room')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:transfer' }); + } + + check(transferData, { + roomId: String, + userId: Match.Optional(String), + departmentId: Match.Optional(String), + }); + + const room = Rooms.findOneById(transferData.roomId); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'livechat:transfer' }); + } + + const subscription = Subscriptions.findOneByRoomIdAndUserId(room._id, Meteor.userId(), { fields: { _id: 1 } }); + if (!subscription && !hasRole(Meteor.userId(), 'livechat-manager')) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'livechat:transfer' }); + } + + const guest = LivechatVisitors.findOneById(room.v && room.v._id); + + return Livechat.transfer(room, guest, transferData); + }, +}); diff --git a/packages/rocketchat-livechat/server/methods/webhookTest.js b/app/livechat/server/methods/webhookTest.js similarity index 83% rename from packages/rocketchat-livechat/server/methods/webhookTest.js rename to app/livechat/server/methods/webhookTest.js index 27ce96c1ab030..45a642a661765 100644 --- a/packages/rocketchat-livechat/server/methods/webhookTest.js +++ b/app/livechat/server/methods/webhookTest.js @@ -1,5 +1,7 @@ -/* globals HTTP */ import { Meteor } from 'meteor/meteor'; +import { HTTP } from 'meteor/http'; + +import { settings } from '../../../settings'; const postCatchError = Meteor.wrapAsync(function(url, options, resolve) { HTTP.post(url, options, function(err, res) { @@ -64,20 +66,18 @@ Meteor.methods({ const options = { headers: { - 'X-RocketChat-Livechat-Token': RocketChat.settings.get('Livechat_secret_token'), + 'X-RocketChat-Livechat-Token': settings.get('Livechat_secret_token'), }, data: sampleData, }; - const response = postCatchError(RocketChat.settings.get('Livechat_webhookUrl'), options); + const response = postCatchError(settings.get('Livechat_webhookUrl'), options); console.log('response ->', response); if (response && response.statusCode && response.statusCode === 200) { return true; - } else { - throw new Meteor.Error('error-invalid-webhook-response'); } + throw new Meteor.Error('error-invalid-webhook-response'); }, }); - diff --git a/app/livechat/server/permissions.js b/app/livechat/server/permissions.js new file mode 100644 index 0000000000000..1539136195261 --- /dev/null +++ b/app/livechat/server/permissions.js @@ -0,0 +1,27 @@ +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { Roles, Permissions } from '../../models'; + +Meteor.startup(() => { + const roles = _.pluck(Roles.find().fetch(), 'name'); + if (roles.indexOf('livechat-agent') === -1) { + Roles.createOrUpdate('livechat-agent'); + } + if (roles.indexOf('livechat-manager') === -1) { + Roles.createOrUpdate('livechat-manager'); + } + if (roles.indexOf('livechat-guest') === -1) { + Roles.createOrUpdate('livechat-guest'); + } + if (Permissions) { + Permissions.createOrUpdate('view-l-room', ['livechat-agent', 'livechat-manager', 'admin']); + Permissions.createOrUpdate('view-livechat-manager', ['livechat-manager', 'admin']); + Permissions.createOrUpdate('view-livechat-rooms', ['livechat-manager', 'admin']); + Permissions.createOrUpdate('close-livechat-room', ['livechat-agent', 'livechat-manager', 'admin']); + Permissions.createOrUpdate('close-others-livechat-room', ['livechat-manager', 'admin']); + Permissions.createOrUpdate('save-others-livechat-room-info', ['livechat-manager']); + Permissions.createOrUpdate('remove-closed-livechat-rooms', ['livechat-manager', 'admin']); + Permissions.createOrUpdate('view-livechat-analytics', ['livechat-manager', 'admin']); + } +}); diff --git a/app/livechat/server/publications/customFields.js b/app/livechat/server/publications/customFields.js new file mode 100644 index 0000000000000..255ff58e143e4 --- /dev/null +++ b/app/livechat/server/publications/customFields.js @@ -0,0 +1,21 @@ +import { Meteor } from 'meteor/meteor'; +import s from 'underscore.string'; + +import { hasPermission } from '../../../authorization'; +import { LivechatCustomField } from '../../../models'; + +Meteor.publish('livechat:customFields', function(_id) { + if (!this.userId) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:customFields' })); + } + + if (!hasPermission(this.userId, 'view-l-room')) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:customFields' })); + } + + if (s.trim(_id)) { + return LivechatCustomField.find({ _id }); + } + + return LivechatCustomField.find(); +}); diff --git a/app/livechat/server/publications/departmentAgents.js b/app/livechat/server/publications/departmentAgents.js new file mode 100644 index 0000000000000..1631e6af59f45 --- /dev/null +++ b/app/livechat/server/publications/departmentAgents.js @@ -0,0 +1,16 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { LivechatDepartmentAgents } from '../../../models'; + +Meteor.publish('livechat:departmentAgents', function(departmentId) { + if (!this.userId) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:departmentAgents' })); + } + + if (!hasPermission(this.userId, 'view-livechat-rooms')) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:departmentAgents' })); + } + + return LivechatDepartmentAgents.find({ departmentId }); +}); diff --git a/app/livechat/server/publications/externalMessages.js b/app/livechat/server/publications/externalMessages.js new file mode 100644 index 0000000000000..ac4af8a5a8416 --- /dev/null +++ b/app/livechat/server/publications/externalMessages.js @@ -0,0 +1,7 @@ +import { Meteor } from 'meteor/meteor'; + +import { LivechatExternalMessage } from '../../lib/LivechatExternalMessage'; + +Meteor.publish('livechat:externalMessages', function(roomId) { + return LivechatExternalMessage.findByRoomId(roomId); +}); diff --git a/packages/rocketchat-livechat/server/publications/livechatAgents.js b/app/livechat/server/publications/livechatAgents.js similarity index 76% rename from packages/rocketchat-livechat/server/publications/livechatAgents.js rename to app/livechat/server/publications/livechatAgents.js index 8356a2537ec0c..b2048e3ad8e57 100644 --- a/packages/rocketchat-livechat/server/publications/livechatAgents.js +++ b/app/livechat/server/publications/livechatAgents.js @@ -1,17 +1,19 @@ import { Meteor } from 'meteor/meteor'; +import { hasPermission, getUsersInRole } from '../../../authorization'; + Meteor.publish('livechat:agents', function() { if (!this.userId) { return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:agents' })); } - if (!RocketChat.authz.hasPermission(this.userId, 'view-l-room')) { + if (!hasPermission(this.userId, 'view-l-room')) { return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:agents' })); } const self = this; - const handle = RocketChat.authz.getUsersInRole('livechat-agent').observeChanges({ + const handle = getUsersInRole('livechat-agent').observeChanges({ added(id, fields) { self.added('agentUsers', id, fields); }, diff --git a/app/livechat/server/publications/livechatAppearance.js b/app/livechat/server/publications/livechatAppearance.js new file mode 100644 index 0000000000000..285d3cfefd0b9 --- /dev/null +++ b/app/livechat/server/publications/livechatAppearance.js @@ -0,0 +1,56 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Settings } from '../../../models'; + +Meteor.publish('livechat:appearance', function() { + if (!this.userId) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:appearance' })); + } + + if (!hasPermission(this.userId, 'view-livechat-manager')) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:appearance' })); + } + + const query = { + _id: { + $in: [ + 'Livechat_title', + 'Livechat_title_color', + 'Livechat_show_agent_email', + 'Livechat_display_offline_form', + 'Livechat_offline_form_unavailable', + 'Livechat_offline_message', + 'Livechat_offline_success_message', + 'Livechat_offline_title', + 'Livechat_offline_title_color', + 'Livechat_offline_email', + 'Livechat_conversation_finished_message', + 'Livechat_registration_form', + 'Livechat_name_field_registration_form', + 'Livechat_email_field_registration_form', + 'Livechat_registration_form_message', + ], + }, + }; + + const self = this; + + const handle = Settings.find(query).observeChanges({ + added(id, fields) { + self.added('livechatAppearance', id, fields); + }, + changed(id, fields) { + self.changed('livechatAppearance', id, fields); + }, + removed(id) { + self.removed('livechatAppearance', id); + }, + }); + + this.ready(); + + this.onStop(() => { + handle.stop(); + }); +}); diff --git a/app/livechat/server/publications/livechatDepartments.js b/app/livechat/server/publications/livechatDepartments.js new file mode 100644 index 0000000000000..98619d3964210 --- /dev/null +++ b/app/livechat/server/publications/livechatDepartments.js @@ -0,0 +1,19 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { LivechatDepartment } from '../../../models'; + +Meteor.publish('livechat:departments', function(_id) { + if (!this.userId) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:agents' })); + } + + if (!hasPermission(this.userId, 'view-l-room')) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:agents' })); + } + + if (_id !== undefined) { + return LivechatDepartment.findByDepartmentId(_id); + } + return LivechatDepartment.find(); +}); diff --git a/app/livechat/server/publications/livechatInquiries.js b/app/livechat/server/publications/livechatInquiries.js new file mode 100644 index 0000000000000..2ca276089326a --- /dev/null +++ b/app/livechat/server/publications/livechatInquiries.js @@ -0,0 +1,37 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { LivechatInquiry } from '../../lib/LivechatInquiry'; + +Meteor.publish('livechat:inquiry', function(_id) { + if (!this.userId) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:inquiry' })); + } + + if (!hasPermission(this.userId, 'view-l-room')) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:inquiry' })); + } + + const publication = this; + + const cursorHandle = LivechatInquiry.find({ + agents: this.userId, + status: 'open', + ..._id && { _id }, + }).observeChanges({ + added(_id, record) { + return publication.added('rocketchat_livechat_inquiry', _id, record); + }, + changed(_id, record) { + return publication.changed('rocketchat_livechat_inquiry', _id, record); + }, + removed(_id) { + return publication.removed('rocketchat_livechat_inquiry', _id); + }, + }); + + this.ready(); + return this.onStop(function() { + return cursorHandle.stop(); + }); +}); diff --git a/app/livechat/server/publications/livechatIntegration.js b/app/livechat/server/publications/livechatIntegration.js new file mode 100644 index 0000000000000..45df6fa6e5afb --- /dev/null +++ b/app/livechat/server/publications/livechatIntegration.js @@ -0,0 +1,34 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Settings } from '../../../models'; + +Meteor.publish('livechat:integration', function() { + if (!this.userId) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:integration' })); + } + + if (!hasPermission(this.userId, 'view-livechat-manager')) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:integration' })); + } + + const self = this; + + const handle = Settings.findByIds(['Livechat_webhookUrl', 'Livechat_secret_token', 'Livechat_webhook_on_close', 'Livechat_webhook_on_offline_msg', 'Livechat_webhook_on_visitor_message', 'Livechat_webhook_on_agent_message']).observeChanges({ + added(id, fields) { + self.added('livechatIntegration', id, fields); + }, + changed(id, fields) { + self.changed('livechatIntegration', id, fields); + }, + removed(id) { + self.removed('livechatIntegration', id); + }, + }); + + self.ready(); + + self.onStop(function() { + handle.stop(); + }); +}); diff --git a/packages/rocketchat-livechat/server/publications/livechatManagers.js b/app/livechat/server/publications/livechatManagers.js similarity index 76% rename from packages/rocketchat-livechat/server/publications/livechatManagers.js rename to app/livechat/server/publications/livechatManagers.js index deccf10283943..9a064efcb4d0b 100644 --- a/packages/rocketchat-livechat/server/publications/livechatManagers.js +++ b/app/livechat/server/publications/livechatManagers.js @@ -1,17 +1,19 @@ import { Meteor } from 'meteor/meteor'; +import { hasPermission, getUsersInRole } from '../../../authorization'; + Meteor.publish('livechat:managers', function() { if (!this.userId) { return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:managers' })); } - if (!RocketChat.authz.hasPermission(this.userId, 'view-livechat-rooms')) { + if (!hasPermission(this.userId, 'view-livechat-rooms')) { return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:managers' })); } const self = this; - const handle = RocketChat.authz.getUsersInRole('livechat-manager').observeChanges({ + const handle = getUsersInRole('livechat-manager').observeChanges({ added(id, fields) { self.added('managerUsers', id, fields); }, diff --git a/packages/rocketchat-livechat/server/publications/livechatMonitoring.js b/app/livechat/server/publications/livechatMonitoring.js similarity index 77% rename from packages/rocketchat-livechat/server/publications/livechatMonitoring.js rename to app/livechat/server/publications/livechatMonitoring.js index 8fa094e382427..85eba2622a56b 100644 --- a/packages/rocketchat-livechat/server/publications/livechatMonitoring.js +++ b/app/livechat/server/publications/livechatMonitoring.js @@ -1,12 +1,15 @@ import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; +import { hasPermission } from '../../../authorization'; +import { Rooms } from '../../../models'; + Meteor.publish('livechat:monitoring', function(date) { if (!this.userId) { return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:monitoring' })); } - if (!RocketChat.authz.hasPermission(this.userId, 'view-livechat-manager')) { + if (!hasPermission(this.userId, 'view-livechat-manager')) { return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:monitoring' })); } @@ -20,7 +23,7 @@ Meteor.publish('livechat:monitoring', function(date) { const self = this; - const handle = RocketChat.models.Rooms.getAnalyticsMetricsBetweenDate('l', date).observeChanges({ + const handle = Rooms.getAnalyticsMetricsBetweenDate('l', date).observeChanges({ added(id, fields) { self.added('livechatMonitoring', id, fields); }, diff --git a/app/livechat/server/publications/livechatOfficeHours.js b/app/livechat/server/publications/livechatOfficeHours.js new file mode 100644 index 0000000000000..42b964eeadab4 --- /dev/null +++ b/app/livechat/server/publications/livechatOfficeHours.js @@ -0,0 +1,12 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { LivechatOfficeHour } from '../../../models'; + +Meteor.publish('livechat:officeHour', function() { + if (!hasPermission(this.userId, 'view-l-room')) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:agents' })); + } + + return LivechatOfficeHour.find(); +}); diff --git a/app/livechat/server/publications/livechatQueue.js b/app/livechat/server/publications/livechatQueue.js new file mode 100644 index 0000000000000..92a4e7fae5eb2 --- /dev/null +++ b/app/livechat/server/publications/livechatQueue.js @@ -0,0 +1,52 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { LivechatDepartmentAgents } from '../../../models'; + +Meteor.publish('livechat:queue', function() { + if (!this.userId) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:queue' })); + } + + if (!hasPermission(this.userId, 'view-l-room')) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:queue' })); + } + + // let sort = { count: 1, sort: 1, username: 1 }; + // let onlineUsers = {}; + + // let handleUsers = RocketChat.models.Users.findOnlineAgents().observeChanges({ + // added(id, fields) { + // onlineUsers[fields.username] = 1; + // // this.added('livechatQueueUser', id, fields); + // }, + // changed(id, fields) { + // onlineUsers[fields.username] = 1; + // // this.changed('livechatQueueUser', id, fields); + // }, + // removed(id) { + // this.removed('livechatQueueUser', id); + // } + // }); + + const self = this; + + const handleDepts = LivechatDepartmentAgents.findUsersInQueue().observeChanges({ + added(id, fields) { + self.added('livechatQueueUser', id, fields); + }, + changed(id, fields) { + self.changed('livechatQueueUser', id, fields); + }, + removed(id) { + self.removed('livechatQueueUser', id); + }, + }); + + this.ready(); + + this.onStop(() => { + // handleUsers.stop(); + handleDepts.stop(); + }); +}); diff --git a/packages/rocketchat-livechat/server/publications/livechatRooms.js b/app/livechat/server/publications/livechatRooms.js similarity index 86% rename from packages/rocketchat-livechat/server/publications/livechatRooms.js rename to app/livechat/server/publications/livechatRooms.js index da8ae754c4123..bba937e12a017 100644 --- a/packages/rocketchat-livechat/server/publications/livechatRooms.js +++ b/app/livechat/server/publications/livechatRooms.js @@ -1,12 +1,15 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; +import { hasPermission } from '../../../authorization'; +import { Rooms } from '../../../models'; + Meteor.publish('livechat:rooms', function(filter = {}, offset = 0, limit = 20) { if (!this.userId) { return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:rooms' })); } - if (!RocketChat.authz.hasPermission(this.userId, 'view-livechat-rooms')) { + if (!hasPermission(this.userId, 'view-livechat-rooms')) { return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:rooms' })); } @@ -49,7 +52,7 @@ Meteor.publish('livechat:rooms', function(filter = {}, offset = 0, limit = 20) { const self = this; - const handle = RocketChat.models.Rooms.findLivechat(query, offset, limit).observeChanges({ + const handle = Rooms.findLivechat(query, offset, limit).observeChanges({ added(id, fields) { self.added('livechatRoom', id, fields); }, diff --git a/app/livechat/server/publications/livechatTriggers.js b/app/livechat/server/publications/livechatTriggers.js new file mode 100644 index 0000000000000..21c3c824f9ec7 --- /dev/null +++ b/app/livechat/server/publications/livechatTriggers.js @@ -0,0 +1,19 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { LivechatTrigger } from '../../../models'; + +Meteor.publish('livechat:triggers', function(_id) { + if (!this.userId) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:triggers' })); + } + + if (!hasPermission(this.userId, 'view-livechat-manager')) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:triggers' })); + } + + if (_id !== undefined) { + return LivechatTrigger.findById(_id); + } + return LivechatTrigger.find(); +}); diff --git a/packages/rocketchat-livechat/server/publications/livechatVisitors.js b/app/livechat/server/publications/livechatVisitors.js similarity index 84% rename from packages/rocketchat-livechat/server/publications/livechatVisitors.js rename to app/livechat/server/publications/livechatVisitors.js index b14726acc2eef..8803da19962c0 100644 --- a/packages/rocketchat-livechat/server/publications/livechatVisitors.js +++ b/app/livechat/server/publications/livechatVisitors.js @@ -1,13 +1,15 @@ import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; -import LivechatVisitors from '../models/LivechatVisitors'; + +import { hasPermission } from '../../../authorization'; +import { LivechatVisitors } from '../../../models'; Meteor.publish('livechat:visitors', function(date) { if (!this.userId) { return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:visitors' })); } - if (!RocketChat.authz.hasPermission(this.userId, 'view-livechat-manager')) { + if (!hasPermission(this.userId, 'view-livechat-manager')) { return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:visitors' })); } diff --git a/app/livechat/server/publications/visitorHistory.js b/app/livechat/server/publications/visitorHistory.js new file mode 100644 index 0000000000000..4a7159196d4c0 --- /dev/null +++ b/app/livechat/server/publications/visitorHistory.js @@ -0,0 +1,45 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Rooms, Subscriptions } from '../../../models'; + +Meteor.publish('livechat:visitorHistory', function({ rid: roomId }) { + if (!this.userId) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:visitorHistory' })); + } + + if (!hasPermission(this.userId, 'view-l-room')) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:visitorHistory' })); + } + + const room = Rooms.findOneById(roomId); + + const subscription = Subscriptions.findOneByRoomIdAndUserId(room._id, this.userId, { fields: { _id: 1 } }); + if (!subscription) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:visitorHistory' })); + } + + const self = this; + + if (room && room.v && room.v._id) { + const handle = Rooms.findByVisitorId(room.v._id).observeChanges({ + added(id, fields) { + self.added('visitor_history', id, fields); + }, + changed(id, fields) { + self.changed('visitor_history', id, fields); + }, + removed(id) { + self.removed('visitor_history', id); + }, + }); + + self.ready(); + + self.onStop(function() { + handle.stop(); + }); + } else { + self.ready(); + } +}); diff --git a/app/livechat/server/publications/visitorInfo.js b/app/livechat/server/publications/visitorInfo.js new file mode 100644 index 0000000000000..197832f081939 --- /dev/null +++ b/app/livechat/server/publications/visitorInfo.js @@ -0,0 +1,21 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Rooms, LivechatVisitors } from '../../../models'; + +Meteor.publish('livechat:visitorInfo', function({ rid: roomId }) { + if (!this.userId) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:visitorInfo' })); + } + + if (!hasPermission(this.userId, 'view-l-room')) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:visitorInfo' })); + } + + const room = Rooms.findOneById(roomId); + + if (room && room.v && room.v._id) { + return LivechatVisitors.findById(room.v._id); + } + return this.ready(); +}); diff --git a/app/livechat/server/publications/visitorPageVisited.js b/app/livechat/server/publications/visitorPageVisited.js new file mode 100644 index 0000000000000..062d370aad103 --- /dev/null +++ b/app/livechat/server/publications/visitorPageVisited.js @@ -0,0 +1,39 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Rooms, Messages } from '../../../models'; + +Meteor.publish('livechat:visitorPageVisited', function({ rid: roomId }) { + if (!this.userId) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:visitorPageVisited' })); + } + + if (!hasPermission(this.userId, 'view-l-room')) { + return this.error(new Meteor.Error('error-not-authorized', 'Not authorized', { publish: 'livechat:visitorPageVisited' })); + } + + const self = this; + const room = Rooms.findOneById(roomId); + + if (room) { + const handle = Messages.findByRoomIdAndType(room._id, 'livechat_navigation_history').observeChanges({ + added(id, fields) { + self.added('visitor_navigation_history', id, fields); + }, + changed(id, fields) { + self.changed('visitor_navigation_history', id, fields); + }, + removed(id) { + self.removed('visitor_navigation_history', id); + }, + }); + + self.ready(); + + self.onStop(function() { + handle.stop(); + }); + } else { + self.ready(); + } +}); diff --git a/app/livechat/server/roomType.js b/app/livechat/server/roomType.js new file mode 100644 index 0000000000000..3bbe342fb3498 --- /dev/null +++ b/app/livechat/server/roomType.js @@ -0,0 +1,36 @@ +import { Rooms, LivechatVisitors } from '../../models'; +import { roomTypes } from '../../utils'; +import LivechatRoomType from '../lib/LivechatRoomType'; + + +class LivechatRoomTypeServer extends LivechatRoomType { + getMsgSender(senderId) { + return LivechatVisitors.findOneById(senderId); + } + + /** + * Returns details to use on notifications + * + * @param {object} room + * @param {object} user + * @param {string} notificationMessage + * @return {object} Notification details + */ + getNotificationDetails(room, user, notificationMessage) { + const title = `[livechat] ${ this.roomName(room) }`; + const text = notificationMessage; + + return { title, text }; + } + + canAccessUploadedFile({ rc_token, rc_rid } = {}) { + return rc_token && rc_rid && Rooms.findOneOpenByRoomIdAndVisitorToken(rc_rid, rc_token); + } + + getReadReceiptsExtraData(message) { + const { token } = message; + return { token }; + } +} + +roomTypes.add(new LivechatRoomTypeServer()); diff --git a/app/livechat/server/sendMessageBySMS.js b/app/livechat/server/sendMessageBySMS.js new file mode 100644 index 0000000000000..1715cd6e13309 --- /dev/null +++ b/app/livechat/server/sendMessageBySMS.js @@ -0,0 +1,46 @@ +import { callbacks } from '../../callbacks'; +import { settings } from '../../settings'; +import { SMS } from '../../sms'; +import { LivechatVisitors } from '../../models'; + +callbacks.add('afterSaveMessage', function(message, room) { + // skips this callback if the message was edited + if (message.editedAt) { + return message; + } + + if (!SMS.enabled) { + return message; + } + + // only send the sms by SMS if it is a livechat room with SMS set to true + if (!(typeof room.t !== 'undefined' && room.t === 'l' && room.sms && room.v && room.v.token)) { + return message; + } + + // if the message has a token, it was sent from the visitor, so ignore it + if (message.token) { + return message; + } + + // if the message has a type means it is a special message (like the closing comment), so skips + if (message.t) { + return message; + } + + const SMSService = SMS.getService(settings.get('SMS_Service')); + + if (!SMSService) { + return message; + } + + const visitor = LivechatVisitors.getVisitorByToken(room.v.token); + + if (!visitor || !visitor.phone || visitor.phone.length === 0) { + return message; + } + + SMSService.send(room.sms.from, visitor.phone[0].phoneNumber, message.msg); + + return message; +}, callbacks.priority.LOW, 'sendMessageBySms'); diff --git a/app/livechat/server/startup.js b/app/livechat/server/startup.js new file mode 100644 index 0000000000000..c76325cb4e042 --- /dev/null +++ b/app/livechat/server/startup.js @@ -0,0 +1,46 @@ +import { Meteor } from 'meteor/meteor'; +import { TAPi18n } from 'meteor/tap:i18n'; + +import { roomTypes } from '../../utils'; +import { Rooms } from '../../models'; +import { hasPermission, addRoomAccessValidator } from '../../authorization'; +import { callbacks } from '../../callbacks'; +import { settings } from '../../settings'; +import { LivechatInquiry } from '../lib/LivechatInquiry'; + +Meteor.startup(() => { + roomTypes.setRoomFind('l', (_id) => Rooms.findOneLivechatById(_id)); + + addRoomAccessValidator(function(room, user) { + return room && room.t === 'l' && user && hasPermission(user._id, 'view-livechat-rooms'); + }); + + addRoomAccessValidator(function(room, user, extraData) { + if (!room && extraData && extraData.rid) { + room = Rooms.findOneById(extraData.rid); + } + return room && room.t === 'l' && extraData && extraData.visitorToken && room.v && room.v.token === extraData.visitorToken; + }); + + addRoomAccessValidator(function(room, user) { + if (settings.get('Livechat_Routing_Method') !== 'Guest_Pool') { + return; + } + + if (!user || !room || room.t !== 'l') { + return; + } + + const inquiry = LivechatInquiry.findOne({ agents: user._id, rid: room._id }, { fields: { status: 1 } }); + return inquiry && inquiry.status === 'open'; + }); + + callbacks.add('beforeLeaveRoom', function(user, room) { + if (room.t !== 'l') { + return user; + } + throw new Meteor.Error(TAPi18n.__('You_cant_leave_a_livechat_room_Please_use_the_close_button', { + lng: user.language || settings.get('Language') || 'en', + })); + }, callbacks.priority.LOW, 'cant-leave-room'); +}); diff --git a/app/livechat/server/unclosedLivechats.js b/app/livechat/server/unclosedLivechats.js new file mode 100644 index 0000000000000..29c96b68e1beb --- /dev/null +++ b/app/livechat/server/unclosedLivechats.js @@ -0,0 +1,95 @@ +import { Meteor } from 'meteor/meteor'; +import { UserPresenceMonitor } from 'meteor/konecty:user-presence'; + +import { Livechat } from './lib/Livechat'; +import { settings } from '../../settings'; +import { Users } from '../../models'; + +let agentsHandler; +let monitorAgents = false; +let actionTimeout = 60000; + +const onlineAgents = { + users: {}, + queue: {}, + + add(userId) { + if (this.queue[userId]) { + clearTimeout(this.queue[userId]); + delete this.queue[userId]; + } + this.users[userId] = 1; + }, + + remove(userId, callback) { + if (this.queue[userId]) { + clearTimeout(this.queue[userId]); + } + this.queue[userId] = setTimeout(Meteor.bindEnvironment(() => { + callback(); + + delete this.users[userId]; + delete this.queue[userId]; + }), actionTimeout); + }, + + exists(userId) { + return !!this.users[userId]; + }, +}; + +function runAgentLeaveAction(userId) { + const action = settings.get('Livechat_agent_leave_action'); + if (action === 'close') { + return Livechat.closeOpenChats(userId, settings.get('Livechat_agent_leave_comment')); + } if (action === 'forward') { + return Livechat.forwardOpenChats(userId); + } +} + +settings.get('Livechat_agent_leave_action_timeout', function(key, value) { + actionTimeout = value * 1000; +}); + +settings.get('Livechat_agent_leave_action', function(key, value) { + monitorAgents = value; + if (value !== 'none') { + if (!agentsHandler) { + agentsHandler = Users.findOnlineAgents().observeChanges({ + added(id) { + onlineAgents.add(id); + }, + changed(id, fields) { + if (fields.statusLivechat && fields.statusLivechat === 'not-available') { + onlineAgents.remove(id, () => { + runAgentLeaveAction(id); + }); + } else { + onlineAgents.add(id); + } + }, + removed(id) { + onlineAgents.remove(id, () => { + runAgentLeaveAction(id); + }); + }, + }); + } + } else if (agentsHandler) { + agentsHandler.stop(); + agentsHandler = null; + } +}); + +UserPresenceMonitor.onSetUserStatus((user, status/* , statusConnection*/) => { + if (!monitorAgents) { + return; + } + if (onlineAgents.exists(user._id)) { + if (status === 'offline' || user.statusLivechat === 'not-available') { + onlineAgents.remove(user._id, () => { + runAgentLeaveAction(user._id); + }); + } + } +}); diff --git a/app/livechat/server/visitorStatus.js b/app/livechat/server/visitorStatus.js new file mode 100644 index 0000000000000..466d82c8efc45 --- /dev/null +++ b/app/livechat/server/visitorStatus.js @@ -0,0 +1,12 @@ +import { Meteor } from 'meteor/meteor'; +import { UserPresenceEvents } from 'meteor/konecty:user-presence'; + +import { Livechat } from './lib/Livechat'; + +Meteor.startup(() => { + UserPresenceEvents.on('setStatus', (session, status, metadata) => { + if (metadata && metadata.visitor) { + Livechat.notifyGuestStatusChanged(metadata.visitor, status); + } + }); +}); diff --git a/packages/rocketchat-livestream/.gitignore b/app/livestream/.gitignore similarity index 100% rename from packages/rocketchat-livestream/.gitignore rename to app/livestream/.gitignore diff --git a/app/livestream/client/index.js b/app/livestream/client/index.js new file mode 100644 index 0000000000000..4221702c7ee03 --- /dev/null +++ b/app/livestream/client/index.js @@ -0,0 +1,9 @@ +import './views/liveStreamTab.html'; +import './views/liveStreamTab'; +import './views/livestreamBroadcast.html'; +import './views/livestreamBroadcast'; +import './views/broadcastView.html'; +import './views/broadcastView'; +import './views/liveStreamView.html'; +import './views/liveStreamView'; +import './tabBar'; diff --git a/app/livestream/client/oauth.js b/app/livestream/client/oauth.js new file mode 100644 index 0000000000000..e81ff871387b6 --- /dev/null +++ b/app/livestream/client/oauth.js @@ -0,0 +1,17 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../settings'; + +export const close = (popup) => new Promise(function(resolve) { + const checkInterval = setInterval(() => { + if (popup.closed) { + clearInterval(checkInterval); + resolve(); + } + }, 300); +}); + +export const auth = async () => { + const oauthWindow = window.open(`${ settings.get('Site_Url') }/api/v1/livestream/oauth?userId=${ Meteor.userId() }`, 'youtube-integration-oauth', 'width=400,height=600'); + return close(oauthWindow); +}; diff --git a/packages/rocketchat-livestream/client/styles/liveStreamTab.css b/app/livestream/client/styles/liveStreamTab.css similarity index 100% rename from packages/rocketchat-livestream/client/styles/liveStreamTab.css rename to app/livestream/client/styles/liveStreamTab.css diff --git a/app/livestream/client/tabBar.js b/app/livestream/client/tabBar.js new file mode 100644 index 0000000000000..1ebd068b27195 --- /dev/null +++ b/app/livestream/client/tabBar.js @@ -0,0 +1,26 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; +import { Session } from 'meteor/session'; + +import { TabBar } from '../../ui-utils'; +import { Rooms } from '../../models'; +import { settings } from '../../settings'; + +Meteor.startup(function() { + Tracker.autorun(function() { + TabBar.removeButton('livestream'); + if (settings.get('Livestream_enabled')) { + const live = Rooms.findOne({ _id: Session.get('openedRoom'), 'streamingOptions.type': 'livestream', 'streamingOptions.id': { $exists: 1 } }, { fields: { streamingOptions: 1 } }); + TabBar.size = live ? 5 : 4; + return TabBar.addButton({ + groups: ['channel', 'group'], + id: 'livestream', + i18nTitle: 'Livestream', + icon: 'podcast', + template: 'liveStreamTab', + order: live ? -1 : 15, + class: () => live && 'live', + }); + } + }); +}); diff --git a/packages/rocketchat-livestream/client/views/broadcastView.html b/app/livestream/client/views/broadcastView.html similarity index 100% rename from packages/rocketchat-livestream/client/views/broadcastView.html rename to app/livestream/client/views/broadcastView.html diff --git a/packages/rocketchat-livestream/client/views/broadcastView.js b/app/livestream/client/views/broadcastView.js similarity index 86% rename from packages/rocketchat-livestream/client/views/broadcastView.js rename to app/livestream/client/views/broadcastView.js index e2feea2e05f8e..4fbe6fc554fcd 100644 --- a/packages/rocketchat-livestream/client/views/broadcastView.js +++ b/app/livestream/client/views/broadcastView.js @@ -2,11 +2,13 @@ import { Meteor } from 'meteor/meteor'; import { ReactiveVar } from 'meteor/reactive-var'; import { Session } from 'meteor/session'; import { Template } from 'meteor/templating'; -import { RocketChat, handleError } from 'meteor/rocketchat:lib'; + +import { handleError } from '../../../utils'; +import { settings } from '../../../settings'; const getMedia = () => navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia; const createAndConnect = (url) => { - if (!'WebSocket' in window) { // eslint-disable-line no-negated-in-lhs + if (!('WebSocket' in window)) { // eslint-disable-line no-negated-in-lhs return false; } @@ -31,26 +33,26 @@ export const call = (...args) => new Promise(function(resolve, reject) { const delay = (time) => new Promise((resolve) => setTimeout(resolve, time)); -const waitForStreamStatus = async(id, status) => { - const streamActive = new Promise(async(resolve) => { +const waitForStreamStatus = async (id, status) => { + const streamActive = new Promise(async (resolve) => { while (true) { // eslint-disable-line no-constant-condition - const currentStatus = await call('livestreamStreamStatus', { streamId: id }); + const currentStatus = await call('livestreamStreamStatus', { streamId: id }); // eslint-disable-line no-await-in-loop if (currentStatus === status) { return resolve(status); } - await delay(1500); + await delay(1500); // eslint-disable-line no-await-in-loop } }); await streamActive; }; -const waitForBroadcastStatus = async(id, status) => { - const broadcastActive = new Promise(async(resolve) => { +const waitForBroadcastStatus = async (id, status) => { + const broadcastActive = new Promise(async (resolve) => { while (true) { // eslint-disable-line no-constant-condition - const currentStatus = await call('getBroadcastStatus', { broadcastId: id }); + const currentStatus = await call('getBroadcastStatus', { broadcastId: id }); // eslint-disable-line no-await-in-loop if (currentStatus === status) { return resolve(status); } - await delay(1500); + await delay(1500); // eslint-disable-line no-await-in-loop } }); await broadcastActive; @@ -66,14 +68,10 @@ Template.broadcastView.helpers({ }); Template.broadcastView.onCreated(async function() { - const connection = createAndConnect(`${ RocketChat.settings.get('Broadcasting_media_server_url') }/${ this.data.id }`); + const connection = createAndConnect(`${ settings.get('Broadcasting_media_server_url') }/${ this.data.id }`); this.mediaStream = new ReactiveVar(null); this.mediaRecorder = new ReactiveVar(null); this.connection = new ReactiveVar(connection); - - if (!connection) { - return; - } }); Template.broadcastView.onDestroyed(function() { if (this.connection.get()) { @@ -123,7 +121,6 @@ Template.broadcastView.onRendered(async function() { await call('setLivestreamStatus', { broadcastId: this.data.broadcast.id, status: 'testing' }); await waitForBroadcastStatus(this.data.broadcast.id, 'testing'); document.querySelector('.streaming-popup').dispatchEvent(new Event('broadcastStreamReady')); - } catch (e) { console.log(e); } diff --git a/packages/rocketchat-livestream/client/views/liveStreamTab.html b/app/livestream/client/views/liveStreamTab.html similarity index 100% rename from packages/rocketchat-livestream/client/views/liveStreamTab.html rename to app/livestream/client/views/liveStreamTab.html diff --git a/packages/rocketchat-livestream/client/views/liveStreamTab.js b/app/livestream/client/views/liveStreamTab.js similarity index 90% rename from packages/rocketchat-livestream/client/views/liveStreamTab.js rename to app/livestream/client/views/liveStreamTab.js index ebd843664b080..9722540a94288 100644 --- a/packages/rocketchat-livestream/client/views/liveStreamTab.js +++ b/app/livestream/client/views/liveStreamTab.js @@ -5,9 +5,15 @@ import { Session } from 'meteor/session'; import { Template } from 'meteor/templating'; import { TAPi18n } from 'meteor/tap:i18n'; import toastr from 'toastr'; + import { auth } from '../oauth.js'; -import { RocketChatAnnouncement, RocketChat, handleError } from 'meteor/rocketchat:lib'; -import { popout, t } from 'meteor/rocketchat:ui'; +import { RocketChatAnnouncement } from '../../../lib'; +import { popout } from '../../../ui-utils'; +import { t, handleError } from '../../../utils'; +import { settings } from '../../../settings'; +import { callbacks } from '../../../callbacks'; +import { hasAllPermission } from '../../../authorization'; +import { Users, Rooms } from '../../../models'; export const call = (...args) => new Promise(function(resolve, reject) { Meteor.call(...args, function(err, result) { @@ -45,7 +51,7 @@ function optionsFromUrl(url) { Template.liveStreamTab.helpers({ broadcastEnabled() { - return !!RocketChat.settings.get('Broadcasting_enabled'); + return !!settings.get('Broadcasting_enabled'); }, streamingSource() { return Template.instance().streamingOptions.get() ? Template.instance().streamingOptions.get().url : ''; @@ -63,7 +69,7 @@ Template.liveStreamTab.helpers({ return !!Template.instance().streamingOptions.get() && !!Template.instance().streamingOptions.get().url && Template.instance().streamingOptions.get().url !== ''; }, canEdit() { - return RocketChat.authz.hasAllPermission('edit-room', this.rid); + return hasAllPermission('edit-room', this.rid); }, editing() { return Template.instance().editing.get() || Template.instance().streamingOptions.get() == null || (Template.instance().streamingOptions.get() != null && (Template.instance().streamingOptions.get().url == null || Template.instance().streamingOptions.get().url === '')); @@ -75,15 +81,13 @@ Template.liveStreamTab.helpers({ if (popout.context) { popoutSource = Blaze.getData(popout.context).data && Blaze.getData(popout.context).data.streamingSource; } - } catch (e) { - return false; - } finally { if (popoutSource != null && livestreamTabSource === popoutSource) { return true; - } else { - return false; } + } catch (e) { + return false; } + return false; }, isPopoutOpen() { return Template.instance().popoutOpen.get(); @@ -99,7 +103,7 @@ Template.liveStreamTab.onCreated(function() { this.popoutOpen = new ReactiveVar(popout.context != null); this.autorun(() => { - const room = RocketChat.models.Rooms.findOne(this.data.rid, { fields: { streamingOptions : 1 } }); + const room = Rooms.findOne(this.data.rid, { fields: { streamingOptions: 1 } }); this.streamingOptions.set(room.streamingOptions); }); }); @@ -120,8 +124,6 @@ Template.liveStreamTab.events({ e.preventDefault(); const clearedObject = { - message: i.streamingOptions.get().message || '', - isAudioOnly: i.streamingOptions.get().isAudioOnly || false, }; Meteor.call('saveRoomSettings', this.rid, 'streamingOptions', clearedObject, function(err) { @@ -189,7 +191,7 @@ Template.liveStreamTab.events({ streamingSource: i.streamingOptions.get().url, isAudioOnly: i.streamingOptions.get().isAudioOnly, showVideoControls: true, - streamingOptions: i.streamingOptions.get(), + streamingOptions: i.streamingOptions.get(), }, onCloseCallback: () => i.popoutOpen.set(false), }); @@ -235,7 +237,7 @@ Template.liveStreamTab.events({ streamingSource: i.streamingOptions.get().url, isAudioOnly: i.streamingOptions.get().isAudioOnly, showVideoControls: true, - streamingOptions: i.streamingOptions.get(), + streamingOptions: i.streamingOptions.get(), }, onCloseCallback: () => i.popoutOpen.set(false), }); @@ -245,7 +247,7 @@ Template.liveStreamTab.events({ e.preventDefault(); e.currentTarget.classList.add('loading'); try { - const user = RocketChat.models.Users.findOne({ _id: Meteor.userId() }, { fields: { 'settings.livestream': 1 } }); + const user = Users.findOne({ _id: Meteor.userId() }, { fields: { 'settings.livestream': 1 } }); if (!user.settings || !user.settings.livestream) { await auth(); } @@ -259,7 +261,6 @@ Template.liveStreamTab.events({ }, onCloseCallback: () => i.popoutOpen.set(false), }); - } catch (e) { console.log(e); } finally { @@ -268,7 +269,7 @@ Template.liveStreamTab.events({ }, }); -RocketChat.callbacks.add('openBroadcast', (rid) => { +callbacks.add('openBroadcast', (rid) => { const roomData = Session.get(`roomData${ rid }`); if (!roomData) { return; } popout.open({ @@ -277,7 +278,7 @@ RocketChat.callbacks.add('openBroadcast', (rid) => { streamingSource: roomData.streamingOptions.url, isAudioOnly: roomData.streamingOptions.isAudioOnly, showVideoControls: true, - streamingOptions: roomData.streamingOptions, + streamingOptions: roomData.streamingOptions, }, }); }); diff --git a/packages/rocketchat-livestream/client/views/liveStreamView.html b/app/livestream/client/views/liveStreamView.html similarity index 100% rename from packages/rocketchat-livestream/client/views/liveStreamView.html rename to app/livestream/client/views/liveStreamView.html diff --git a/packages/rocketchat-livestream/client/views/liveStreamView.js b/app/livestream/client/views/liveStreamView.js similarity index 100% rename from packages/rocketchat-livestream/client/views/liveStreamView.js rename to app/livestream/client/views/liveStreamView.js diff --git a/packages/rocketchat-livestream/client/views/livestreamBroadcast.html b/app/livestream/client/views/livestreamBroadcast.html similarity index 100% rename from packages/rocketchat-livestream/client/views/livestreamBroadcast.html rename to app/livestream/client/views/livestreamBroadcast.html diff --git a/packages/rocketchat-livestream/client/views/livestreamBroadcast.js b/app/livestream/client/views/livestreamBroadcast.js similarity index 100% rename from packages/rocketchat-livestream/client/views/livestreamBroadcast.js rename to app/livestream/client/views/livestreamBroadcast.js diff --git a/packages/rocketchat-livestream/server/functions/livestream.js b/app/livestream/server/functions/livestream.js similarity index 82% rename from packages/rocketchat-livestream/server/functions/livestream.js rename to app/livestream/server/functions/livestream.js index 2b91962bf99b9..acd31e138669a 100644 --- a/packages/rocketchat-livestream/server/functions/livestream.js +++ b/app/livestream/server/functions/livestream.js @@ -1,4 +1,5 @@ import google from 'googleapis'; + const { OAuth2 } = google.auth; @@ -11,7 +12,7 @@ const p = (fn) => new Promise(function(resolve, reject) { }); }); -export const getBroadcastStatus = async({ +export const getBroadcastStatus = async ({ id, access_token, refresh_token, @@ -24,15 +25,15 @@ export const getBroadcastStatus = async({ access_token, refresh_token, }); - const youtube = google.youtube({ version:'v3', auth }); + const youtube = google.youtube({ version: 'v3', auth }); const result = await p((resolve) => youtube.liveBroadcasts.list({ - part:'id,status', + part: 'id,status', id, }, resolve)); return result.items && result.items[0] && result.items[0].status.lifeCycleStatus; }; -export const statusStreamLiveStream = async({ +export const statusStreamLiveStream = async ({ id, access_token, refresh_token, @@ -46,9 +47,9 @@ export const statusStreamLiveStream = async({ refresh_token, }); - const youtube = google.youtube({ version:'v3', auth }); + const youtube = google.youtube({ version: 'v3', auth }); const result = await p((resolve) => youtube.liveStreams.list({ - part:'id,status', + part: 'id,status', id, }, resolve)); return result.items && result.items[0].status.streamStatus; @@ -69,10 +70,10 @@ export const statusLiveStream = ({ refresh_token, }); - const youtube = google.youtube({ version:'v3', auth }); + const youtube = google.youtube({ version: 'v3', auth }); return p((resolve) => youtube.liveBroadcasts.transition({ - part:'id,status', + part: 'id,status', id, broadcastStatus: status, }, resolve)); @@ -93,16 +94,16 @@ export const setBroadcastStatus = ({ refresh_token, }); - const youtube = google.youtube({ version:'v3', auth }); + const youtube = google.youtube({ version: 'v3', auth }); return p((resolve) => youtube.liveBroadcasts.transition({ - part:'id,status', + part: 'id,status', id, broadcastStatus: status, }, resolve)); }; -export const createLiveStream = async({ +export const createLiveStream = async ({ room, access_token, refresh_token, @@ -114,7 +115,7 @@ export const createLiveStream = async({ access_token, refresh_token, }); - const youtube = google.youtube({ version:'v3', auth }); + const youtube = google.youtube({ version: 'v3', auth }); const [stream, broadcast] = await Promise.all([p((resolve) => youtube.liveStreams.insert({ part: 'id,snippet,cdn,contentDetails,status', @@ -132,7 +133,7 @@ export const createLiveStream = async({ resource: { snippet: { title: room.name || 'RocketChat Broadcast', - scheduledStartTime : new Date().toISOString(), + scheduledStartTime: new Date().toISOString(), }, status: { privacyStatus: 'unlisted', diff --git a/app/livestream/server/index.js b/app/livestream/server/index.js new file mode 100644 index 0000000000000..9254662a155af --- /dev/null +++ b/app/livestream/server/index.js @@ -0,0 +1,3 @@ +import './routes.js'; +import './methods.js'; +import './settings'; diff --git a/app/livestream/server/methods.js b/app/livestream/server/methods.js new file mode 100644 index 0000000000000..c18e7c4ab9b6a --- /dev/null +++ b/app/livestream/server/methods.js @@ -0,0 +1,140 @@ +import { Meteor } from 'meteor/meteor'; + +import { createLiveStream, statusLiveStream, statusStreamLiveStream, getBroadcastStatus, setBroadcastStatus } from './functions/livestream'; +import { settings } from '../../settings'; +import { Rooms } from '../../models'; + +const selectLivestreamSettings = (user) => user && user.settings && user.settings.livestream; + +Meteor.methods({ + + async livestreamStreamStatus({ streamId }) { + if (!streamId) { + // TODO: change error + throw new Meteor.Error('error-not-allowed', 'Livestream ID not found', { + method: 'livestreamStreamStatus', + }); + } + const livestreamSettings = selectLivestreamSettings(Meteor.user()); + + if (!livestreamSettings) { + throw new Meteor.Error('error-not-allowed', 'You have no settings to stream', { + method: 'livestreamStreamStatus', + }); + } + + const { access_token, refresh_token } = livestreamSettings; + + return statusStreamLiveStream({ + id: streamId, + access_token, + refresh_token, + clientId: settings.get('Broadcasting_client_id'), + clientSecret: settings.get('Broadcasting_client_secret'), + }); + }, + async setLivestreamStatus({ broadcastId, status }) { + if (!broadcastId) { + // TODO: change error + throw new Meteor.Error('error-not-allowed', 'You have no settings to livestream', { + method: 'livestreamStart', + }); + } + const livestreamSettings = selectLivestreamSettings(Meteor.user()); + + if (!livestreamSettings) { + throw new Meteor.Error('error-not-allowed', 'You have no settings to livestream', { + method: 'livestreamStart', + }); + } + + const { access_token, refresh_token } = livestreamSettings; + + return statusLiveStream({ + id: broadcastId, + access_token, + refresh_token, + status, + clientId: settings.get('Broadcasting_client_id'), + clientSecret: settings.get('Broadcasting_client_secret'), + }); + }, + async livestreamGet({ rid }) { + const livestreamSettings = selectLivestreamSettings(Meteor.user()); + + if (!livestreamSettings) { + throw new Meteor.Error('error-not-allowed', 'You have no settings to livestream', { + method: 'livestreamGet', + }); + } + + const room = Rooms.findOne({ _id: rid }); + + if (!room) { + // TODO: change error + throw new Meteor.Error('error-not-allowed', 'You have no settings to livestream', { + method: 'livestreamGet', + }); + } + + const { access_token, refresh_token } = livestreamSettings; + return createLiveStream({ + room, + access_token, + refresh_token, + clientId: settings.get('Broadcasting_client_id'), + clientSecret: settings.get('Broadcasting_client_secret'), + }); + }, + async getBroadcastStatus({ broadcastId }) { + if (!broadcastId) { + // TODO: change error + throw new Meteor.Error('error-not-allowed', 'Broadcast ID not found', { + method: 'getBroadcastStatus', + }); + } + const livestreamSettings = selectLivestreamSettings(Meteor.user()); + + if (!livestreamSettings) { + throw new Meteor.Error('error-not-allowed', 'You have no settings to stream', { + method: 'getBroadcastStatus', + }); + } + + const { access_token, refresh_token } = livestreamSettings; + + return getBroadcastStatus({ + id: broadcastId, + access_token, + refresh_token, + clientId: settings.get('Broadcasting_client_id'), + clientSecret: settings.get('Broadcasting_client_secret'), + }); + }, + async setBroadcastStatus({ broadcastId, status }) { + if (!broadcastId) { + // TODO: change error + throw new Meteor.Error('error-not-allowed', 'Broadcast ID not found', { + method: 'setBroadcastStatus', + }); + } + const livestreamSettings = selectLivestreamSettings(Meteor.user()); + + if (!livestreamSettings) { + throw new Meteor.Error('error-not-allowed', 'You have no settings to stream', { + method: 'setBroadcastStatus', + }); + } + + const { access_token, refresh_token } = livestreamSettings; + + return setBroadcastStatus({ + id: broadcastId, + access_token, + refresh_token, + status, + clientId: settings.get('Broadcasting_client_id'), + clientSecret: settings.get('Broadcasting_client_secret'), + }); + }, +}); diff --git a/app/livestream/server/routes.js b/app/livestream/server/routes.js new file mode 100644 index 0000000000000..8668217d19a55 --- /dev/null +++ b/app/livestream/server/routes.js @@ -0,0 +1,53 @@ +import { Meteor } from 'meteor/meteor'; +import google from 'googleapis'; + +import { settings } from '../../settings'; +import { Users } from '../../models'; +import { API } from '../../api'; + +const { OAuth2 } = google.auth; + +API.v1.addRoute('livestream/oauth', { + get: function functionName() { + const clientAuth = new OAuth2(settings.get('Broadcasting_client_id'), settings.get('Broadcasting_client_secret'), `${ settings.get('Site_Url') }/api/v1/livestream/oauth/callback`.replace(/\/{2}api/g, '/api')); + const { userId } = this.queryParams; + const url = clientAuth.generateAuthUrl({ + access_type: 'offline', + scope: ['https://www.googleapis.com/auth/youtube'], + state: JSON.stringify({ + userId, + }), + }); + + return { + statusCode: 302, + headers: { + Location: url, + }, + body: 'Oauth redirect', + }; + }, +}); + +API.v1.addRoute('livestream/oauth/callback', { + get: function functionName() { + const { code, state } = this.queryParams; + + const { userId } = JSON.parse(state); + + const clientAuth = new OAuth2(settings.get('Broadcasting_client_id'), settings.get('Broadcasting_client_secret'), `${ settings.get('Site_Url') }/api/v1/livestream/oauth/callback`.replace(/\/{2}api/g, '/api')); + + const ret = Meteor.wrapAsync(clientAuth.getToken.bind(clientAuth))(code); + + Users.update({ _id: userId }, { $set: { + 'settings.livestream': ret, + } }); + + return { + headers: { + 'content-type': 'text/html', + }, + body: '', + }; + }, +}); diff --git a/app/livestream/server/settings.js b/app/livestream/server/settings.js new file mode 100644 index 0000000000000..5fc796efa87d0 --- /dev/null +++ b/app/livestream/server/settings.js @@ -0,0 +1,25 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../settings'; + +Meteor.startup(function() { + settings.addGroup('LiveStream & Broadcasting', function() { + this.add('Livestream_enabled', false, { + type: 'boolean', + public: true, + alert: 'This feature is currently in beta! Please report bugs to github.com/RocketChat/Rocket.Chat/issues', + }); + + this.add('Broadcasting_enabled', false, { + type: 'boolean', + public: true, + alert: 'This feature is currently in beta! Please report bugs to github.com/RocketChat/Rocket.Chat/issues', + enableQuery: { _id: 'Livestream_enabled', value: true }, + }); + + this.add('Broadcasting_client_id', '', { type: 'string', public: false, enableQuery: { _id: 'Broadcasting_enabled', value: true } }); + this.add('Broadcasting_client_secret', '', { type: 'string', public: false, enableQuery: { _id: 'Broadcasting_enabled', value: true } }); + this.add('Broadcasting_api_key', '', { type: 'string', public: false, enableQuery: { _id: 'Broadcasting_enabled', value: true } }); + this.add('Broadcasting_media_server_url', '', { type: 'string', public: true, enableQuery: { _id: 'Broadcasting_enabled', value: true } }); + }); +}); diff --git a/packages/rocketchat-logger/README.md b/app/logger/README.md similarity index 100% rename from packages/rocketchat-logger/README.md rename to app/logger/README.md diff --git a/app/logger/client/ansispan.js b/app/logger/client/ansispan.js new file mode 100644 index 0000000000000..1ccf35296f78a --- /dev/null +++ b/app/logger/client/ansispan.js @@ -0,0 +1,34 @@ +const foregroundColors = { + 30: 'gray', + 31: 'red', + 32: 'lime', + 33: 'yellow', + 34: '#6B98FF', + 35: '#FF00FF', + 36: 'cyan', + 37: 'white', +}; + +export const ansispan = (str: string) => { + str = str + .replace(/\s/g, ' ') + .replace(/(\\n|\n)/g, '
') + .replace(/>/g, '>') + .replace(/$1') + .replace(/\033\[1m/g, '') + .replace(/\033\[22m/g, '') + .replace(/\033\[3m/g, '') + .replace(/\033\[23m/g, '') + .replace(/\033\[m/g, '') + .replace(/\033\[0m/g, '') + .replace(/\033\[39m/g, ''); + return Object.entries(foregroundColors).reduce((str, [ansiCode, color]) => { + const span = ``; + return ( + str + .replace(new RegExp(`\\033\\[${ ansiCode }m`, 'g'), span) + .replace(new RegExp(`\\033\\[0;${ ansiCode }m`, 'g'), span) + ); + }, str); +}; diff --git a/app/logger/client/index.js b/app/logger/client/index.js new file mode 100644 index 0000000000000..1cdc33f64edfd --- /dev/null +++ b/app/logger/client/index.js @@ -0,0 +1,3 @@ +import './logger'; +import './viewLogs'; +import './views/viewLogs'; diff --git a/app/logger/client/logger.js b/app/logger/client/logger.js new file mode 100644 index 0000000000000..61eb0bf9c11d7 --- /dev/null +++ b/app/logger/client/logger.js @@ -0,0 +1,86 @@ +import { Template } from 'meteor/templating'; +import _ from 'underscore'; + +import { getConfig } from '../../ui-utils/client/config'; + +Template.log = !!(getConfig('debug') || getConfig('debug-template')); + +if (Template.log) { + Template.logMatch = /.*/; + + Template.enableLogs = function(log) { + Template.logMatch = /.*/; + if (log === false) { + Template.log = false; + return false; + } + Template.log = true; + if (log instanceof RegExp) { + Template.logMatch = log; + return log; + } + }; + + const wrapHelpersAndEvents = function(original, prefix, color) { + return function(dict) { + const template = this; + const fn1 = function(name, fn) { + if (fn instanceof Function) { + dict[name] = function(...args) { + const result = fn.apply(this, args); + if (Template.log === true) { + const completeName = `${ prefix }:${ template.viewName.replace('Template.', '') }.${ name }`; + if (Template.logMatch.test(completeName)) { + console.log(`%c${ completeName }`, `color: ${ color }`, { + args, + scope: this, + result, + }); + } + } + return result; + }; + return dict[name]; + } + }; + _.each(name, (fn, name) => { + fn1(name, fn); + }); + return original.call(template, dict); + }; + }; + + Template.prototype.helpers = wrapHelpersAndEvents(Template.prototype.helpers, 'helper', 'blue'); + + Template.prototype.events = wrapHelpersAndEvents(Template.prototype.events, 'event', 'green'); + + const wrapLifeCycle = function(original, prefix, color) { + return function(fn) { + const template = this; + if (fn instanceof Function) { + const wrap = function(...args) { + const result = fn.apply(this, args); + if (Template.log === true) { + const completeName = `${ prefix }:${ template.viewName.replace('Template.', '') }.${ name }`; + if (Template.logMatch.test(completeName)) { + console.log(`%c${ completeName }`, `color: ${ color }; font-weight: bold`, { + args, + scope: this, + result, + }); + } + } + return result; + }; + return original.call(template, wrap); + } + return original.call(template, fn); + }; + }; + + Template.prototype.onCreated = wrapLifeCycle(Template.prototype.onCreated, 'onCreated', 'blue'); + + Template.prototype.onRendered = wrapLifeCycle(Template.prototype.onRendered, 'onRendered', 'green'); + + Template.prototype.onDestroyed = wrapLifeCycle(Template.prototype.onDestroyed, 'onDestroyed', 'red'); +} diff --git a/app/logger/client/viewLogs.js b/app/logger/client/viewLogs.js new file mode 100644 index 0000000000000..112a947fc70b6 --- /dev/null +++ b/app/logger/client/viewLogs.js @@ -0,0 +1,33 @@ +import { Meteor } from 'meteor/meteor'; +import { Mongo } from 'meteor/mongo'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { BlazeLayout } from 'meteor/kadira:blaze-layout'; + +import { AdminBox } from '../../ui-utils'; +import { hasAllPermission } from '../../authorization'; +import { t } from '../../utils'; + +export const stdout = new Mongo.Collection('stdout'); + +Meteor.startup(function() { + AdminBox.addOption({ + href: 'admin-view-logs', + i18nLabel: 'View_Logs', + icon: 'post', + permissionGranted() { + return hasAllPermission('view-logs'); + }, + }); +}); + +FlowRouter.route('/admin/view-logs', { + name: 'admin-view-logs', + action() { + return BlazeLayout.render('main', { + center: 'pageSettingsContainer', + pageTitle: t('View_Logs'), + pageTemplate: 'viewLogs', + noScroll: true, + }); + }, +}); diff --git a/app/logger/client/views/viewLogs.css b/app/logger/client/views/viewLogs.css new file mode 100644 index 0000000000000..e7619344c0834 --- /dev/null +++ b/app/logger/client/views/viewLogs.css @@ -0,0 +1,29 @@ +.view-logs { + &__terminal { + overflow-y: scroll; + flex: 1; + + margin: 0; + margin-bottom: 0 !important; + padding: 8px 10px !important; + + color: var(--color-white); + border: none !important; + background-color: #444444 !important; + + font-family: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-weight: 500; + + &-line { + word-break: break-all; + } + + &-time { + color: #7f7f7f; + } + + .rtl & { + direction: ltr; + } + } +} diff --git a/app/logger/client/views/viewLogs.html b/app/logger/client/views/viewLogs.html new file mode 100644 index 0000000000000..9c664aac2f8ca --- /dev/null +++ b/app/logger/client/views/viewLogs.html @@ -0,0 +1,17 @@ + diff --git a/app/logger/client/views/viewLogs.js b/app/logger/client/views/viewLogs.js new file mode 100644 index 0000000000000..82382c1ada0df --- /dev/null +++ b/app/logger/client/views/viewLogs.js @@ -0,0 +1,114 @@ +import _ from 'underscore'; +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; +import { Tracker } from 'meteor/tracker'; + +import { ansispan } from '../ansispan'; +import { stdout } from '../viewLogs'; +import { readMessage } from '../../../ui-utils'; +import { hasAllPermission } from '../../../authorization'; +import { SideNav } from '../../../ui-utils/client'; +import './viewLogs.html'; +import './viewLogs.css'; + +Template.viewLogs.onCreated(function() { + this.subscribe('stdout'); + this.atBottom = true; +}); + +Template.viewLogs.helpers({ + hasPermission() { + return hasAllPermission('view-logs'); + }, + logs() { + return stdout.find({}, { sort: { ts: 1 } }); + }, + ansispan, +}); + +Template.viewLogs.events({ + 'click .new-logs'(event, instance) { + instance.atBottom = true; + instance.sendToBottomIfNecessary(); + }, +}); + +Template.viewLogs.onRendered(function() { + Tracker.afterFlush(() => { + SideNav.setFlex('adminFlex'); + SideNav.openFlex(); + }); + + const wrapper = this.find('.js-terminal'); + const newLogs = this.find('.js-new-logs'); + + this.isAtBottom = (scrollThreshold) => { + if (scrollThreshold == null) { + scrollThreshold = 0; + } + if (wrapper.scrollTop + scrollThreshold >= wrapper.scrollHeight - wrapper.clientHeight) { + newLogs.className = 'new-logs not'; + return true; + } + return false; + }; + + this.sendToBottom = () => { + wrapper.scrollTop = wrapper.scrollHeight - wrapper.clientHeight; + newLogs.className = 'new-logs not'; + }; + + this.checkIfScrollIsAtBottom = () => { + this.atBottom = this.isAtBottom(100); + readMessage.enable(); + readMessage.read(); + }; + + this.sendToBottomIfNecessary = () => { + if (this.atBottom === true && this.isAtBottom() !== true) { + this.sendToBottom(); + } else if (this.atBottom === false) { + newLogs.className = 'new-logs'; + } + }; + + this.sendToBottomIfNecessaryDebounced = _.debounce(this.sendToBottomIfNecessary, 10); + this.sendToBottomIfNecessary(); + + if (window.MutationObserver) { + const observer = new MutationObserver((mutations) => { + mutations.forEach(() => this.sendToBottomIfNecessaryDebounced()); + }); + observer.observe(wrapper, { childList: true }); + } else { + wrapper.addEventListener('DOMSubtreeModified', () => this.sendToBottomIfNecessaryDebounced()); + } + + this.onWindowResize = () => { + Meteor.defer(() => this.sendToBottomIfNecessaryDebounced()); + }; + window.addEventListener('resize', this.onWindowResize); + + wrapper.addEventListener('mousewheel', () => { + this.atBottom = false; + Meteor.defer(() => this.checkIfScrollIsAtBottom()); + }); + + wrapper.addEventListener('wheel', () => { + this.atBottom = false; + Meteor.defer(() => this.checkIfScrollIsAtBottom()); + }); + + wrapper.addEventListener('touchstart', () => { + this.atBottom = false; + }); + + wrapper.addEventListener('touchend', () => { + Meteor.defer(() => this.checkIfScrollIsAtBottom()); + }); + + wrapper.addEventListener('scroll', () => { + this.atBottom = false; + Meteor.defer(() => this.checkIfScrollIsAtBottom()); + }); +}); diff --git a/app/logger/index.js b/app/logger/index.js new file mode 100644 index 0000000000000..a67eca871efbb --- /dev/null +++ b/app/logger/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/logger/server/index.js b/app/logger/server/index.js new file mode 100644 index 0000000000000..b101a98d37cf4 --- /dev/null +++ b/app/logger/server/index.js @@ -0,0 +1,7 @@ +import { LoggerManager, Logger, SystemLogger } from './server'; + +export { + LoggerManager, + Logger, + SystemLogger, +}; diff --git a/app/logger/server/server.js b/app/logger/server/server.js new file mode 100644 index 0000000000000..8e9bc460dc352 --- /dev/null +++ b/app/logger/server/server.js @@ -0,0 +1,391 @@ +import { EventEmitter } from 'events'; + +import { Meteor } from 'meteor/meteor'; +import { Random } from 'meteor/random'; +import { EJSON } from 'meteor/ejson'; +import { Log } from 'meteor/logging'; +import _ from 'underscore'; +import s from 'underscore.string'; + +import { settings } from '../../settings'; +import { hasPermission } from '../../authorization'; + +let Logger; + +const LoggerManager = new class extends EventEmitter { + constructor() { + super(); + this.enabled = false; + this.loggers = {}; + this.queue = []; + this.showPackage = false; + this.showFileAndLine = false; + this.logLevel = 0; + } + + register(logger) { + if (!(logger instanceof Logger)) { + return; + } + this.loggers[logger.name] = logger; + this.emit('register', logger); + } + + addToQueue(logger, args) { + this.queue.push({ + logger, args, + }); + } + + dispatchQueue() { + _.each(this.queue, (item) => item.logger._log.apply(item.logger, item.args)); + this.clearQueue(); + } + + clearQueue() { + this.queue = []; + } + + disable() { + this.enabled = false; + } + + enable(dispatchQueue = false) { + this.enabled = true; + return dispatchQueue === true ? this.dispatchQueue() : this.clearQueue(); + } +}(); + +const defaultTypes = { + debug: { + name: 'debug', + color: 'blue', + level: 2, + }, + log: { + name: 'info', + color: 'blue', + level: 1, + }, + info: { + name: 'info', + color: 'blue', + level: 1, + }, + success: { + name: 'info', + color: 'green', + level: 1, + }, + warn: { + name: 'warn', + color: 'magenta', + level: 1, + }, + error: { + name: 'error', + color: 'red', + level: 0, + }, +}; + +class _Logger { + constructor(name, config = {}) { + const self = this; + this.name = name; + + this.config = Object.assign({}, config); + if (LoggerManager.loggers && LoggerManager.loggers[this.name] != null) { + LoggerManager.loggers[this.name].warn('Duplicated instance'); + return LoggerManager.loggers[this.name]; + } + _.each(defaultTypes, (typeConfig, type) => { + this[type] = function(...args) { + return self._log.call(self, { + section: this.__section, + type, + level: typeConfig.level, + method: typeConfig.name, + arguments: args, + }); + }; + + self[`${ type }_box`] = function(...args) { + return self._log.call(self, { + section: this.__section, + type, + box: true, + level: typeConfig.level, + method: typeConfig.name, + arguments: args, + }); + }; + }); + if (this.config.methods) { + _.each(this.config.methods, (typeConfig, method) => { + if (this[method] != null) { + self.warn(`Method ${ method } already exists`); + } + if (defaultTypes[typeConfig.type] == null) { + self.warn(`Method type ${ typeConfig.type } does not exist`); + } + this[method] = function(...args) { + return self._log.call(self, { + section: this.__section, + type: typeConfig.type, + level: typeConfig.level != null ? typeConfig.level : defaultTypes[typeConfig.type] && defaultTypes[typeConfig.type].level, + method, + arguments: args, + }); + }; + this[`${ method }_box`] = function(...args) { + return self._log.call(self, { + section: this.__section, + type: typeConfig.type, + box: true, + level: typeConfig.level != null ? typeConfig.level : defaultTypes[typeConfig.type] && defaultTypes[typeConfig.type].level, + method, + arguments: args, + }); + }; + }); + } + if (this.config.sections) { + _.each(this.config.sections, (name, section) => { + this[section] = {}; + _.each(defaultTypes, (typeConfig, type) => { + self[section][type] = (...args) => this[type].apply({ __section: name }, args); + self[section][`${ type }_box`] = (...args) => this[`${ type }_box`].apply({ __section: name }, args); + }); + _.each(this.config.methods, (typeConfig, method) => { + self[section][method] = (...args) => self[method].apply({ __section: name }, args); + self[section][`${ method }_box`] = (...args) => self[`${ method }_box`].apply({ __section: name }, args); + }); + }); + } + + LoggerManager.register(this); + } + + getPrefix(options) { + let prefix = `${ this.name } ➔ ${ options.method }`; + if (options.section) { + prefix = `${ this.name } ➔ ${ options.section }.${ options.method }`; + } + const details = this._getCallerDetails(); + const detailParts = []; + if (details.package && (LoggerManager.showPackage === true || options.type === 'error')) { + detailParts.push(details.package); + } + if (LoggerManager.showFileAndLine === true || options.type === 'error') { + if ((details.file != null) && (details.line != null)) { + detailParts.push(`${ details.file }:${ details.line }`); + } else { + if (details.file != null) { + detailParts.push(details.file); + } + if (details.line != null) { + detailParts.push(details.line); + } + } + } + if (defaultTypes[options.type]) { + // format the message to a colored message + prefix = prefix[defaultTypes[options.type].color]; + } + if (detailParts.length > 0) { + prefix = `${ detailParts.join(' ') } ${ prefix }`; + } + return prefix; + } + + _getCallerDetails() { + const getStack = () => { + // We do NOT use Error.prepareStackTrace here (a V8 extension that gets us a + // core-parsed stack) since it's impossible to compose it with the use of + // Error.prepareStackTrace used on the server for source maps. + const { stack } = new Error(); + return stack; + }; + const stack = getStack(); + if (!stack) { + return {}; + } + const lines = stack.split('\n').splice(1); + // looking for the first line outside the logging package (or an + // eval if we find that first) + let line = lines[0]; + for (let index = 0, len = lines.length; index < len; index++, line = lines[index]) { + if (line.match(/^\s*at eval \(eval/)) { + return { file: 'eval' }; + } + + if (!line.match(/packages\/rocketchat_logger(?:\/|\.js)/)) { + break; + } + } + + const details = {}; + // The format for FF is 'functionName@filePath:lineNumber' + // The format for V8 is 'functionName (packages/logging/logging.js:81)' or + // 'packages/logging/logging.js:81' + const match = /(?:[@(]| at )([^(]+?):([0-9:]+)(?:\)|$)/.exec(line); + if (!match) { + return details; + } + details.line = match[2].split(':')[0]; + // Possible format: https://foo.bar.com/scripts/file.js?random=foobar + // XXX: if you can write the following in better way, please do it + // XXX: what about evals? + details.file = match[1].split('/').slice(-1)[0].split('?')[0]; + const packageMatch = match[1].match(/packages\/([^\.\/]+)(?:\/|\.)/); + if (packageMatch) { + details.package = packageMatch[1]; + } + return details; + } + + makeABox(message, title) { + if (!_.isArray(message)) { + message = message.split('\n'); + } + let len = 0; + + len = Math.max.apply(null, message.map((line) => line.length)); + + const topLine = `+--${ s.pad('', len, '-') }--+`; + const separator = `| ${ s.pad('', len, '') } |`; + let lines = []; + + lines.push(topLine); + if (title) { + lines.push(`| ${ s.lrpad(title, len) } |`); + lines.push(topLine); + } + lines.push(separator); + + lines = [...lines, ...message.map((line) => `| ${ s.rpad(line, len) } |`)]; + + lines.push(separator); + lines.push(topLine); + return lines; + } + + _log(options, ...args) { + if (LoggerManager.enabled === false) { + LoggerManager.addToQueue(this, [options, ...args]); + return; + } + if (options.level == null) { + options.level = 1; + } + + if (LoggerManager.logLevel < options.level) { + return; + } + + const prefix = this.getPrefix(options); + + if (options.box === true && _.isString(options.arguments[0])) { + let color = undefined; + if (defaultTypes[options.type]) { + color = defaultTypes[options.type].color; + } + + const box = this.makeABox(options.arguments[0], options.arguments[1]); + let subPrefix = '➔'; + if (color) { + subPrefix = subPrefix[color]; + } + + console.log(subPrefix, prefix); + box.forEach((line) => { + console.log(subPrefix, color ? line[color] : line); + }); + } else { + options.arguments.unshift(prefix); + console.log.apply(console, options.arguments); + } + } +} + +Logger = _Logger; +const processString = function(string, date) { + let obj; + try { + if (string[0] === '{') { + obj = EJSON.parse(string); + } else { + obj = { + message: string, + time: date, + level: 'info', + }; + } + return Log.format(obj, { color: true }); + } catch (error) { + return string; + } +}; + +const SystemLogger = new Logger('System', { + methods: { + startup: { + type: 'success', + level: 0, + }, + }, +}); + + +const StdOut = new class extends EventEmitter { + constructor() { + super(); + const { write } = process.stdout; + this.queue = []; + process.stdout.write = (...args) => { + write.apply(process.stdout, args); + const date = new Date(); + const string = processString(args[0], date); + const item = { + id: Random.id(), + string, + ts: date, + }; + this.queue.push(item); + + if (typeof settings !== 'undefined') { + const limit = settings.get('Log_View_Limit'); + if (limit && this.queue.length > limit) { + this.queue.shift(); + } + } + this.emit('write', string, item); + }; + } +}(); + + +Meteor.publish('stdout', function() { + if (!this.userId || hasPermission(this.userId, 'view-logs') !== true) { + return this.ready(); + } + + StdOut.queue.forEach((item) => { + this.added('stdout', item.id, { + string: item.string, + ts: item.ts, + }); + }); + + this.ready(); + StdOut.on('write', (string, item) => { + this.added('stdout', item.id, { + string: item.string, + ts: item.ts, + }); + }); +}); + + +export { SystemLogger, StdOut, LoggerManager, processString, Logger }; diff --git a/packages/rocketchat-mail-messages/client/index.js b/app/mail-messages/client/index.js similarity index 100% rename from packages/rocketchat-mail-messages/client/index.js rename to app/mail-messages/client/index.js diff --git a/app/mail-messages/client/router.js b/app/mail-messages/client/router.js new file mode 100644 index 0000000000000..9e706f11be74e --- /dev/null +++ b/app/mail-messages/client/router.js @@ -0,0 +1,20 @@ +import { Meteor } from 'meteor/meteor'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { BlazeLayout } from 'meteor/kadira:blaze-layout'; + +FlowRouter.route('/admin/mailer', { + name: 'admin-mailer', + action() { + return BlazeLayout.render('main', { + center: 'mailer', + }); + }, +}); + +FlowRouter.route('/mailer/unsubscribe/:_id/:createdAt', { + name: 'mailer-unsubscribe', + action(params) { + Meteor.call('Mailer:unsubscribe', params._id, params.createdAt); + return BlazeLayout.render('mailerUnsubscribe'); + }, +}); diff --git a/app/mail-messages/client/startup.js b/app/mail-messages/client/startup.js new file mode 100644 index 0000000000000..35f55d75cbbef --- /dev/null +++ b/app/mail-messages/client/startup.js @@ -0,0 +1,11 @@ +import { AdminBox } from '../../ui-utils'; +import { hasAllPermission } from '../../authorization'; + +AdminBox.addOption({ + href: 'admin-mailer', + i18nLabel: 'Mailer', + icon: 'mail', + permissionGranted() { + return hasAllPermission('access-mailer'); + }, +}); diff --git a/packages/rocketchat-mail-messages/client/views/mailer.html b/app/mail-messages/client/views/mailer.html similarity index 100% rename from packages/rocketchat-mail-messages/client/views/mailer.html rename to app/mail-messages/client/views/mailer.html diff --git a/app/mail-messages/client/views/mailer.js b/app/mail-messages/client/views/mailer.js new file mode 100644 index 0000000000000..0f8f7e5669854 --- /dev/null +++ b/app/mail-messages/client/views/mailer.js @@ -0,0 +1,47 @@ +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; +import { Tracker } from 'meteor/tracker'; +import { TAPi18n } from 'meteor/tap:i18n'; +import toastr from 'toastr'; + +import { settings } from '../../../settings'; +import { handleError } from '../../../utils'; +import { SideNav } from '../../../ui-utils/client'; + +Template.mailer.helpers({ + fromEmail() { + return settings.get('From_Email'); + }, +}); + +Template.mailer.events({ + 'click .send'(e, t) { + e.preventDefault(); + const from = $(t.find('[name=from]')).val(); + const subject = $(t.find('[name=subject]')).val(); + const body = $(t.find('[name=body]')).val(); + const dryrun = $(t.find('[name=dryrun]:checked')).val(); + const query = $(t.find('[name=query]')).val(); + if (!from) { + toastr.error(TAPi18n.__('error-invalid-from-address')); + return; + } + if (body.indexOf('[unsubscribe]') === -1) { + toastr.error(TAPi18n.__('error-missing-unsubscribe-link')); + return; + } + return Meteor.call('Mailer.sendMail', from, subject, body, dryrun, query, function(err) { + if (err) { + return handleError(err); + } + return toastr.success(TAPi18n.__('The_emails_are_being_sent')); + }); + }, +}); + +Template.mailer.onRendered(() => { + Tracker.afterFlush(() => { + SideNav.setFlex('adminFlex'); + SideNav.openFlex(); + }); +}); diff --git a/packages/rocketchat-mail-messages/client/views/mailerUnsubscribe.html b/app/mail-messages/client/views/mailerUnsubscribe.html similarity index 100% rename from packages/rocketchat-mail-messages/client/views/mailerUnsubscribe.html rename to app/mail-messages/client/views/mailerUnsubscribe.html diff --git a/packages/rocketchat-mail-messages/client/views/mailerUnsubscribe.js b/app/mail-messages/client/views/mailerUnsubscribe.js similarity index 100% rename from packages/rocketchat-mail-messages/client/views/mailerUnsubscribe.js rename to app/mail-messages/client/views/mailerUnsubscribe.js diff --git a/app/mail-messages/server/functions/sendMail.js b/app/mail-messages/server/functions/sendMail.js new file mode 100644 index 0000000000000..04bc2800831ff --- /dev/null +++ b/app/mail-messages/server/functions/sendMail.js @@ -0,0 +1,65 @@ +import { Meteor } from 'meteor/meteor'; +import { EJSON } from 'meteor/ejson'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import s from 'underscore.string'; + +import { placeholders } from '../../../utils'; +import * as Mailer from '../../../mailer'; + +export const sendMail = function(from, subject, body, dryrun, query) { + Mailer.checkAddressFormatAndThrow(from, 'Mailer.sendMail'); + if (body.indexOf('[unsubscribe]') === -1) { + throw new Meteor.Error('error-missing-unsubscribe-link', 'You must provide the [unsubscribe] link.', { + function: 'Mailer.sendMail', + }); + } + + let userQuery = { 'mailer.unsubscribed': { $exists: 0 } }; + if (query) { + userQuery = { $and: [userQuery, EJSON.parse(query)] }; + } + + if (dryrun) { + return Meteor.users.find({ + 'emails.address': from, + }).forEach((user) => { + const email = `${ user.name } <${ user.emails[0].address }>`; + const html = placeholders.replace(body, { + unsubscribe: Meteor.absoluteUrl(FlowRouter.path('mailer/unsubscribe/:_id/:createdAt', { + _id: user._id, + createdAt: user.createdAt.getTime(), + })), + name: user.name, + email, + }); + + console.log(`Sending email to ${ email }`); + return Mailer.send({ + to: email, + from, + subject, + html, + }); + }); + } + + return Meteor.users.find(userQuery).forEach(function(user) { + const email = `${ user.name } <${ user.emails[0].address }>`; + + const html = placeholders.replace(body, { + unsubscribe: Meteor.absoluteUrl(FlowRouter.path('mailer/unsubscribe/:_id/:createdAt', { + _id: user._id, + createdAt: user.createdAt.getTime(), + })), + name: s.escapeHTML(user.name), + email: s.escapeHTML(email), + }); + console.log(`Sending email to ${ email }`); + return Mailer.send({ + to: email, + from, + subject, + html, + }); + }); +}; diff --git a/app/mail-messages/server/functions/unsubscribe.js b/app/mail-messages/server/functions/unsubscribe.js new file mode 100644 index 0000000000000..9d892acb0c50d --- /dev/null +++ b/app/mail-messages/server/functions/unsubscribe.js @@ -0,0 +1,8 @@ +import { Users } from '../../../models'; + +export const unsubscribe = function(_id, createdAt) { + if (_id && createdAt) { + return Users.rocketMailUnsubscribe(_id, createdAt) === 1; + } + return false; +}; diff --git a/app/mail-messages/server/index.js b/app/mail-messages/server/index.js new file mode 100644 index 0000000000000..a05bc57cc61ea --- /dev/null +++ b/app/mail-messages/server/index.js @@ -0,0 +1,8 @@ +import './startup'; +import './methods/sendMail'; +import './methods/unsubscribe'; +import { Mailer } from './lib/Mailer'; + +export { + Mailer, +}; diff --git a/packages/rocketchat-mail-messages/server/lib/Mailer.js b/app/mail-messages/server/lib/Mailer.js similarity index 100% rename from packages/rocketchat-mail-messages/server/lib/Mailer.js rename to app/mail-messages/server/lib/Mailer.js diff --git a/app/mail-messages/server/methods/sendMail.js b/app/mail-messages/server/methods/sendMail.js new file mode 100644 index 0000000000000..9fef15299682d --- /dev/null +++ b/app/mail-messages/server/methods/sendMail.js @@ -0,0 +1,29 @@ +import { Meteor } from 'meteor/meteor'; + +import { Mailer } from '../lib/Mailer'; +import { hasRole } from '../../../authorization'; + +Meteor.methods({ + 'Mailer.sendMail'(from, subject, body, dryrun, query) { + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'Mailer.sendMail', + }); + } + if (hasRole(userId, 'admin') !== true) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'Mailer.sendMail', + }); + } + return Mailer.sendMail(from, subject, body, dryrun, query); + }, +}); + + +// Limit setting username once per minute +// DDPRateLimiter.addRule +// type: 'method' +// name: 'Mailer.sendMail' +// connectionId: -> return true +// , 1, 60000 diff --git a/app/mail-messages/server/methods/unsubscribe.js b/app/mail-messages/server/methods/unsubscribe.js new file mode 100644 index 0000000000000..180b3652b001b --- /dev/null +++ b/app/mail-messages/server/methods/unsubscribe.js @@ -0,0 +1,18 @@ +import { Meteor } from 'meteor/meteor'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; + +import { Mailer } from '../lib/Mailer'; + +Meteor.methods({ + 'Mailer:unsubscribe'(_id, createdAt) { + return Mailer.unsubscribe(_id, createdAt); + }, +}); + +DDPRateLimiter.addRule({ + type: 'method', + name: 'Mailer:unsubscribe', + connectionId() { + return true; + }, +}, 1, 60000); diff --git a/app/mail-messages/server/startup.js b/app/mail-messages/server/startup.js new file mode 100644 index 0000000000000..10dd89d2b8c00 --- /dev/null +++ b/app/mail-messages/server/startup.js @@ -0,0 +1,12 @@ +import { Meteor } from 'meteor/meteor'; + +import { Permissions } from '../../models'; + +Meteor.startup(function() { + return Permissions.upsert('access-mailer', { + $setOnInsert: { + _id: 'access-mailer', + roles: ['admin'], + }, + }); +}); diff --git a/app/mailer/index.js b/app/mailer/index.js new file mode 100644 index 0000000000000..7c4b0096a2da9 --- /dev/null +++ b/app/mailer/index.js @@ -0,0 +1 @@ +export * from './server/api'; diff --git a/app/mailer/server/api.js b/app/mailer/server/api.js new file mode 100644 index 0000000000000..d47ae99e89085 --- /dev/null +++ b/app/mailer/server/api.js @@ -0,0 +1,116 @@ +import { Meteor } from 'meteor/meteor'; +import { Email } from 'meteor/email'; +import { TAPi18n } from 'meteor/tap:i18n'; +import _ from 'underscore'; +import s from 'underscore.string'; +import juice from 'juice'; + +import { settings } from '../../settings'; + +let contentHeader; +let contentFooter; + +let body; +let Settings = { + get: () => {}, +}; + +// define server language for email translations +// @TODO: change TAPi18n.__ function to use the server language by default +let lng = 'en'; +settings.get('Language', (key, value) => { + lng = value || 'en'; +}); + +export const replacekey = (str, key, value = '') => str.replace(new RegExp(`(\\[${ key }\\]|__${ key }__)`, 'igm'), value); +export const translate = (str) => str.replace(/\{ ?([^\} ]+)(( ([^\}]+))+)? ?\}/gmi, (match, key) => TAPi18n.__(key, { lng })); +export const replace = function replace(str, data = {}) { + if (!str) { + return ''; + } + const options = { + Site_Name: Settings.get('Site_Name'), + Site_URL: Settings.get('Site_Url'), + Site_URL_Slash: Settings.get('Site_Url').replace(/\/?$/, '/'), + ...data.name && { + fname: s.strLeft(data.name, ' '), + lname: s.strRightBack(data.name, ' '), + }, + ...data, + }; + return Object.entries(options).reduce((ret, [key, value]) => replacekey(ret, key, value), translate(str)); +}; + +export const replaceEscaped = (str, data = {}) => replace(str, { + Site_Name: s.escapeHTML(settings.get('Site_Name')), + Site_Url: s.escapeHTML(settings.get('Site_Url')), + ...Object.entries(data).reduce((ret, [key, value]) => { + ret[key] = s.escapeHTML(value); + return ret; + }, {}), +}); +export const wrap = (html, data = {}) => replaceEscaped(body.replace('{{body}}', html), data); +export const inlinecss = (html) => juice.inlineContent(html, Settings.get('email_style')); +export const getTemplate = (template, fn, escape = true) => { + let html = ''; + Settings.get(template, (key, value) => { + html = value || ''; + fn(escape ? inlinecss(html) : html); + }); + Settings.get('email_style', () => { + fn(escape ? inlinecss(html) : html); + }); +}; +export const getTemplateWrapped = (template, fn) => { + let html = ''; + const wrapInlineCSS = _.debounce(() => fn(wrap(inlinecss(html))), 100); + + Settings.get('Email_Header', () => html && wrapInlineCSS()); + Settings.get('Email_Footer', () => html && wrapInlineCSS()); + Settings.get('email_style', () => html && wrapInlineCSS()); + Settings.get(template, (key, value) => { + html = value || ''; + return html && wrapInlineCSS(); + }); +}; +export const setSettings = (s) => { + Settings = s; + + getTemplate('Email_Header', (value) => { + contentHeader = replace(value || ''); + body = inlinecss(`${ contentHeader } {{body}} ${ contentFooter }`); + }, false); + + getTemplate('Email_Footer', (value) => { + contentFooter = replace(value || ''); + body = inlinecss(`${ contentHeader } {{body}} ${ contentFooter }`); + }, false); + + body = inlinecss(`${ contentHeader } {{body}} ${ contentFooter }`); +}; + +export const rfcMailPatternWithName = /^(?:.*<)?([a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)(?:>?)$/; + +export const checkAddressFormat = (from) => rfcMailPatternWithName.test(from); + +export const sendNoWrap = ({ to, from, subject, html, headers }) => { + if (!checkAddressFormat(to)) { + return; + } + Meteor.defer(() => Email.send({ to, from, subject, html, headers })); +}; + +export const send = ({ to, from, subject, html, data, headers }) => sendNoWrap({ to, from, subject: replace(subject, data), html: wrap(html, data), headers }); + +export const checkAddressFormatAndThrow = (from, func) => { + if (checkAddressFormat(from)) { + return true; + } + throw new Meteor.Error('error-invalid-from-address', 'Invalid from address', { + function: func, + }); +}; + +export const getHeader = () => contentHeader; + +export const getFooter = () => contentFooter; diff --git a/packages/rocketchat-mapview/client/index.js b/app/mapview/client/index.js similarity index 100% rename from packages/rocketchat-mapview/client/index.js rename to app/mapview/client/index.js diff --git a/packages/rocketchat-mapview/client/mapview.js b/app/mapview/client/mapview.js similarity index 81% rename from packages/rocketchat-mapview/client/mapview.js rename to app/mapview/client/mapview.js index bd6efc7962bfc..d12382a36908d 100644 --- a/packages/rocketchat-mapview/client/mapview.js +++ b/app/mapview/client/mapview.js @@ -1,17 +1,17 @@ import { TAPi18n } from 'meteor/tap:i18n'; -import { RocketChat } from 'meteor/rocketchat:lib'; + +import { settings } from '../../settings'; +import { callbacks } from '../../callbacks'; /* * MapView is a named function that will replace geolocation in messages with a Google Static Map * @param {Object} message - The message object */ function MapView(message) { - // get MapView settings - const mv_googlekey = RocketChat.settings.get('MapView_GMapsAPIKey'); + const mv_googlekey = settings.get('MapView_GMapsAPIKey'); if (message.location) { - // GeoJSON is reversed - ie. [lng, lat] const [longitude, latitude] = message.location.coordinates; @@ -26,4 +26,4 @@ function MapView(message) { return message; } -RocketChat.callbacks.add('renderMessage', MapView, RocketChat.callbacks.priority.HIGH, 'mapview'); +callbacks.add('renderMessage', MapView, callbacks.priority.HIGH, 'mapview'); diff --git a/packages/rocketchat-mapview/server/index.js b/app/mapview/server/index.js similarity index 100% rename from packages/rocketchat-mapview/server/index.js rename to app/mapview/server/index.js diff --git a/app/mapview/server/settings.js b/app/mapview/server/settings.js new file mode 100644 index 0000000000000..96c604de513e0 --- /dev/null +++ b/app/mapview/server/settings.js @@ -0,0 +1,8 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../settings'; + +Meteor.startup(function() { + settings.add('MapView_Enabled', false, { type: 'boolean', group: 'Message', section: 'Google Maps', public: true, i18nLabel: 'MapView_Enabled', i18nDescription: 'MapView_Enabled_Description' }); + return settings.add('MapView_GMapsAPIKey', '', { type: 'string', group: 'Message', section: 'Google Maps', public: true, i18nLabel: 'MapView_GMapsAPIKey', i18nDescription: 'MapView_GMapsAPIKey_Description', secret: true }); +}); diff --git a/app/markdown/client/index.js b/app/markdown/client/index.js new file mode 100644 index 0000000000000..33d93bded040f --- /dev/null +++ b/app/markdown/client/index.js @@ -0,0 +1 @@ +export { Markdown } from '../lib/markdown'; diff --git a/app/markdown/lib/markdown.js b/app/markdown/lib/markdown.js new file mode 100644 index 0000000000000..eb97f46442724 --- /dev/null +++ b/app/markdown/lib/markdown.js @@ -0,0 +1,101 @@ +/* + * Markdown is a named function that will parse markdown syntax + * @param {Object} message - The message object + */ +import s from 'underscore.string'; +import { Meteor } from 'meteor/meteor'; +import { Blaze } from 'meteor/blaze'; + +import { marked } from './parser/marked/marked.js'; +import { original } from './parser/original/original.js'; +import { code } from './parser/original/code.js'; +import { callbacks } from '../../callbacks'; +import { settings } from '../../settings'; + +const parsers = { + original, + marked, +}; + +class MarkdownClass { + parse(text) { + const message = { + html: s.escapeHTML(text), + }; + return this.mountTokensBack(this.parseMessageNotEscaped(message)).html; + } + + parseNotEscaped(text) { + const message = { + html: text, + }; + return this.mountTokensBack(this.parseMessageNotEscaped(message)).html; + } + + parseMessageNotEscaped(message) { + const parser = settings.get('Markdown_Parser'); + + if (parser === 'disabled') { + return message; + } + + if (typeof parsers[parser] === 'function') { + return parsers[parser](message); + } + return parsers.original(message); + } + + mountTokensBackRecursively(message, tokenList, useHtml = true) { + const missingTokens = []; + + if (tokenList.length > 0) { + for (const { token, text, noHtml } of tokenList) { + if (message.html.indexOf(token) >= 0) { + message.html = message.html.replace(token, () => (useHtml ? text : noHtml)); // Uses lambda so doesn't need to escape $ + } else { + missingTokens.push({ token, text, noHtml }); + } + } + } + + // If there are tokens that were missing from the string, but the last iteration replaced at least one token, then go again + // this is done because one of the tokens may have been hidden by another one + if (missingTokens.length > 0 && missingTokens.length < tokenList.length) { + this.mountTokensBackRecursively(message, missingTokens, useHtml); + } + } + + mountTokensBack(message, useHtml = true) { + if (message.tokens) { + this.mountTokensBackRecursively(message, message.tokens, useHtml); + } + + return message; + } + + code(...args) { + return code(...args); + } +} + +export const Markdown = new MarkdownClass(); + +// renderMessage already did html escape +const MarkdownMessage = (message) => { + if (s.trim(message != null ? message.html : undefined)) { + message = Markdown.parseMessageNotEscaped(message); + } + + return message; +}; + +callbacks.add('renderMessage', MarkdownMessage, callbacks.priority.HIGH, 'markdown'); + +if (Meteor.isClient) { + Blaze.registerHelper('RocketChatMarkdown', (text) => Markdown.parse(text)); + Blaze.registerHelper('RocketChatMarkdownUnescape', (text) => Markdown.parseNotEscaped(text)); + Blaze.registerHelper('RocketChatMarkdownInline', (text) => { + const output = Markdown.parse(text); + return output.replace(/^

/, '').replace(/<\/p>$/, ''); + }); +} diff --git a/app/markdown/lib/parser/marked/marked.js b/app/markdown/lib/parser/marked/marked.js new file mode 100644 index 0000000000000..b61e681a0e580 --- /dev/null +++ b/app/markdown/lib/parser/marked/marked.js @@ -0,0 +1,115 @@ +import { Random } from 'meteor/random'; +import _ from 'underscore'; +import s from 'underscore.string'; +import hljs from 'highlight.js'; +import _marked from 'marked'; + +import { settings } from '../../../../settings'; + +const renderer = new _marked.Renderer(); + +let msg = null; + +renderer.code = function(code, lang, escaped) { + if (this.options.highlight) { + const out = this.options.highlight(code, lang); + if (out != null && out !== code) { + escaped = true; + code = out; + } + } + + let text = null; + + if (!lang) { + text = `

${ (escaped ? code : s.escapeHTML(code, true)) }
`; + } else { + text = `
${ (escaped ? code : s.escapeHTML(code, true)) }
`; + } + + if (_.isString(msg)) { + return text; + } + + const token = `=!=${ Random.id() }=!=`; + msg.tokens.push({ + highlight: true, + token, + text, + }); + + return token; +}; + +renderer.codespan = function(text) { + text = `${ text }`; + if (_.isString(msg)) { + return text; + } + + const token = `=!=${ Random.id() }=!=`; + msg.tokens.push({ + token, + text, + }); + + return token; +}; + +renderer.blockquote = function(quote) { + return `
${ quote }
`; +}; + +const linkRenderer = renderer.link; +renderer.link = function(href, title, text) { + const html = linkRenderer.call(renderer, href, title, text); + return html.replace(/^ { + msg = message; + + if (!msg.tokens) { + msg.tokens = []; + } + + if (gfm == null) { gfm = settings.get('Markdown_Marked_GFM'); } + if (tables == null) { tables = settings.get('Markdown_Marked_Tables'); } + if (breaks == null) { breaks = settings.get('Markdown_Marked_Breaks'); } + if (pedantic == null) { pedantic = settings.get('Markdown_Marked_Pedantic'); } + if (smartLists == null) { smartLists = settings.get('Markdown_Marked_SmartLists'); } + if (smartypants == null) { smartypants = settings.get('Markdown_Marked_Smartypants'); } + + msg.html = _marked(s.unescapeHTML(msg.html), { + gfm, + tables, + breaks, + pedantic, + smartLists, + smartypants, + renderer, + sanitize: true, + highlight, + }); + + return msg; +}; diff --git a/packages/rocketchat-markdown/lib/parser/original/code.js b/app/markdown/lib/parser/original/code.js similarity index 91% rename from packages/rocketchat-markdown/lib/parser/original/code.js rename to app/markdown/lib/parser/original/code.js index a208ac561edb2..7a061858055f3 100644 --- a/packages/rocketchat-markdown/lib/parser/original/code.js +++ b/app/markdown/lib/parser/original/code.js @@ -6,10 +6,10 @@ import { Random } from 'meteor/random'; import s from 'underscore.string'; import hljs from 'highlight.js'; -const inlinecode = (message) => +const inlinecode = (message) => { // Support `text` message.html = message.html.replace(/\`([^`\r\n]+)\`([<_*~]|\B|\b|$)/gm, (match, p1, p2) => { - const token = ` =!=${ Random.id() }=!=`; + const token = `=!=${ Random.id() }=!=`; message.tokens.push({ token, @@ -18,15 +18,14 @@ const inlinecode = (message) => }); return token; - }) -; + }); +}; const codeblocks = (message) => { // Count occurencies of ``` const count = (message.html.match(/```/g) || []).length; if (count) { - // Check if we need to add a final ``` if ((count % 2) > 0) { message.html = `${ message.html }\n\`\`\``; @@ -48,7 +47,7 @@ const codeblocks = (message) => { const emptyLanguage = lang === '' ? s.unescapeHTML(codeMatch[1] + codeMatch[2]) : s.unescapeHTML(codeMatch[2]); const code = singleLine ? s.unescapeHTML(codeMatch[1]) : emptyLanguage; - const result = lang === '' ? hljs.highlightAuto((lang + code)) : hljs.highlight(lang, code); + const result = lang === '' ? hljs.highlightAuto(lang + code) : hljs.highlight(lang, code); const token = `=!=${ Random.id() }=!=`; message.tokens.push({ @@ -65,7 +64,7 @@ const codeblocks = (message) => { } // Re-mount message - return message.html = msgParts.join(''); + message.html = msgParts.join(''); } }; diff --git a/app/markdown/lib/parser/original/markdown.js b/app/markdown/lib/parser/original/markdown.js new file mode 100644 index 0000000000000..09517cd6fd4e0 --- /dev/null +++ b/app/markdown/lib/parser/original/markdown.js @@ -0,0 +1,96 @@ +/* + * Markdown is a named function that will parse markdown syntax + * @param {String} msg - The message html + */ +import { Meteor } from 'meteor/meteor'; +import { Random } from 'meteor/random'; +import s from 'underscore.string'; + +import { settings } from '../../../../settings'; + +const parseNotEscaped = function(msg, message) { + if (message && message.tokens == null) { + message.tokens = []; + } + + const addAsToken = function(html) { + const token = `=!=${ Random.id() }=!=`; + message.tokens.push({ + token, + text: html, + }); + + return token; + }; + + const schemes = settings.get('Markdown_SupportSchemesForLink').split(',').join('|'); + + if (settings.get('Markdown_Headers')) { + // Support # Text for h1 + msg = msg.replace(/^# (([\S\w\d-_\/\*\.,\\][ \u00a0\u1680\u180e\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]?)+)/gm, '

$1

'); + + // Support # Text for h2 + msg = msg.replace(/^## (([\S\w\d-_\/\*\.,\\][ \u00a0\u1680\u180e\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]?)+)/gm, '

$1

'); + + // Support # Text for h3 + msg = msg.replace(/^### (([\S\w\d-_\/\*\.,\\][ \u00a0\u1680\u180e\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]?)+)/gm, '

$1

'); + + // Support # Text for h4 + msg = msg.replace(/^#### (([\S\w\d-_\/\*\.,\\][ \u00a0\u1680\u180e\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]?)+)/gm, '

$1

'); + } + + // Support *text* to make bold + msg = msg.replace(/(|>|[ >_~`])\*{1,2}([^\*\r\n]+)\*{1,2}([<_~`]|\B|\b|$)/gm, '$1*$2*$3'); + + // Support _text_ to make italics + msg = msg.replace(/(^|>|[ >*~`])\_{1,2}([^\_\r\n]+)\_{1,2}([<*~`]|\B|\b|$)/gm, '$1_$2_$3'); + + // Support ~text~ to strike through text + msg = msg.replace(/(^|>|[ >_*`])\~{1,2}([^~\r\n]+)\~{1,2}([<_*`]|\B|\b|$)/gm, '$1~$2~$3'); + + // Support for block quote + // >>> + // Text + // <<< + msg = msg.replace(/(?:>){3}\n+([\s\S]*?)\n+(?:<){3}/g, '
>>>$1<<<
'); + + // Support >Text for quote + msg = msg.replace(/^>(.*)$/gm, '
>$1
'); + + // Remove white-space around blockquote (prevent
). Because blockquote is block element. + msg = msg.replace(/\s*
/gm, '
'); + msg = msg.replace(/<\/blockquote>\s*/gm, '
'); + + // Remove new-line between blockquotes. + msg = msg.replace(/<\/blockquote>\n
{ + const target = url.indexOf(Meteor.absoluteUrl()) === 0 ? '' : '_blank'; + return addAsToken(`
`); + }); + + // Support [Text](http://link) + msg = msg.replace(new RegExp(`\\[([^\\]]+)\\]\\(((?:${ schemes }):\\/\\/[^\\)]+)\\)`, 'gm'), (match, title, url) => { + const target = url.indexOf(Meteor.absoluteUrl()) === 0 ? '' : '_blank'; + title = title.replace(/&/g, '&'); + + let escapedUrl = s.escapeHTML(url); + escapedUrl = escapedUrl.replace(/&/g, '&'); + + return addAsToken(`${ s.escapeHTML(title) }`); + }); + + // Support + msg = msg.replace(new RegExp(`(?:<|<)((?:${ schemes }):\\/\\/[^\\|]+)\\|(.+?)(?=>|>)(?:>|>)`, 'gm'), (match, url, title) => { + const target = url.indexOf(Meteor.absoluteUrl()) === 0 ? '' : '_blank'; + return addAsToken(`${ s.escapeHTML(title) }`); + }); + + return msg; +}; + +export const markdown = function(message) { + message.html = parseNotEscaped(message.html, message); + return message; +}; diff --git a/packages/rocketchat-markdown/lib/parser/original/original.js b/app/markdown/lib/parser/original/original.js similarity index 100% rename from packages/rocketchat-markdown/lib/parser/original/original.js rename to app/markdown/lib/parser/original/original.js diff --git a/app/markdown/server/index.js b/app/markdown/server/index.js new file mode 100644 index 0000000000000..03cdcc2fdd649 --- /dev/null +++ b/app/markdown/server/index.js @@ -0,0 +1,3 @@ +import './settings'; + +export { Markdown } from '../lib/markdown'; diff --git a/app/markdown/server/settings.js b/app/markdown/server/settings.js new file mode 100644 index 0000000000000..71f3dc02c7f6a --- /dev/null +++ b/app/markdown/server/settings.js @@ -0,0 +1,89 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../settings'; + +Meteor.startup(() => { + settings.add('Markdown_Parser', 'original', { + type: 'select', + values: [{ + key: 'disabled', + i18nLabel: 'Disabled', + }, { + key: 'original', + i18nLabel: 'Original', + }, { + key: 'marked', + i18nLabel: 'Marked', + }], + group: 'Message', + section: 'Markdown', + public: true, + }); + + const enableQueryOriginal = { _id: 'Markdown_Parser', value: 'original' }; + settings.add('Markdown_Headers', false, { + type: 'boolean', + group: 'Message', + section: 'Markdown', + public: true, + enableQuery: enableQueryOriginal, + }); + settings.add('Markdown_SupportSchemesForLink', 'http,https', { + type: 'string', + group: 'Message', + section: 'Markdown', + public: true, + i18nDescription: 'Markdown_SupportSchemesForLink_Description', + enableQuery: enableQueryOriginal, + }); + + const enableQueryMarked = { _id: 'Markdown_Parser', value: 'marked' }; + settings.add('Markdown_Marked_GFM', true, { + type: 'boolean', + group: 'Message', + section: 'Markdown', + public: true, + enableQuery: enableQueryMarked, + }); + settings.add('Markdown_Marked_Tables', true, { + type: 'boolean', + group: 'Message', + section: 'Markdown', + public: true, + enableQuery: enableQueryMarked, + }); + settings.add('Markdown_Marked_Breaks', true, { + type: 'boolean', + group: 'Message', + section: 'Markdown', + public: true, + enableQuery: enableQueryMarked, + }); + settings.add('Markdown_Marked_Pedantic', false, { + type: 'boolean', + group: 'Message', + section: 'Markdown', + public: true, + enableQuery: [{ + _id: 'Markdown_Parser', + value: 'marked', + }, { + _id: 'Markdown_Marked_GFM', + value: false, + }], + }); + settings.add('Markdown_Marked_SmartLists', true, { + type: 'boolean', + group: 'Message', + section: 'Markdown', + public: true, + enableQuery: enableQueryMarked, + }); + settings.add('Markdown_Marked_Smartypants', true, { + type: 'boolean', + group: 'Message', + section: 'Markdown', + public: true, + enableQuery: enableQueryMarked, + }); +}); diff --git a/app/markdown/tests/client.mocks.js b/app/markdown/tests/client.mocks.js new file mode 100644 index 0000000000000..6838ac521620f --- /dev/null +++ b/app/markdown/tests/client.mocks.js @@ -0,0 +1,61 @@ +import mock from 'mock-require'; +import _ from 'underscore'; +import s from 'underscore.string'; + +_.mixin(s.exports()); + +mock('meteor/meteor', { + Meteor: { + absoluteUrl() { + return 'http://localhost:3000/'; + }, + }, +}); + +mock('meteor/blaze', { + Blaze: {}, +}); + +mock('../../settings', { + settings: { + get(setting) { + switch (setting) { + case 'Markdown_SupportSchemesForLink': + return 'http,https'; + case 'Markdown_Parser': + return 'original'; + case 'Markdown_Headers': + // case 'Markdown_Marked_GFM': + // case 'Markdown_Marked_Tables': + // case 'Markdown_Marked_Breaks': + // case 'Markdown_Marked_Pedantic': + // case 'Markdown_Marked_SmartLists': + // case 'Markdown_Marked_Smartypants': + return true; + default: + throw new Error(`Missing setting mock ${ setting }`); + } + }, + }, +}); + +mock('../../callbacks', { + callbacks: { + add() { + + }, + priority: { + HIGH: 1, + }, + }, +}); + +mock('meteor/random', { + Random: { + id() { + return Math.random(); + }, + }, +}); + +global.s = s; diff --git a/app/markdown/tests/client.tests.js b/app/markdown/tests/client.tests.js new file mode 100644 index 0000000000000..dcbe7f9a90e74 --- /dev/null +++ b/app/markdown/tests/client.tests.js @@ -0,0 +1,293 @@ +/* eslint-env mocha */ +import 'babel-polyfill'; +import assert from 'assert'; + +import s from 'underscore.string'; + +import './client.mocks.js'; +import { original } from '../lib/parser/original/original'; +import { Markdown } from '../lib/markdown'; + +const wrapper = (text, tag) => `${ tag }${ text }${ tag }`; +const boldWrapper = (text) => wrapper(`${ text }`, '*'); +const italicWrapper = (text) => wrapper(`${ text }`, '_'); +const strikeWrapper = (text) => wrapper(`${ text }`, '~'); +const headerWrapper = (text, level) => `${ text }`; +const quoteWrapper = (text) => `
>${ text }
`; +const linkWrapped = (link, title) => `${ s.escapeHTML(title) }`; +const inlinecodeWrapper = (text) => wrapper(`${ text }`, '`'); +const codeWrapper = (text, lang) => `
\`\`\`
${ text }
\`\`\`
`; + +const bold = { + '*Hello*': boldWrapper('Hello'), + '**Hello**': boldWrapper('Hello'), + '**Hello*': boldWrapper('Hello'), + '*Hello**': boldWrapper('Hello'), + Hello: 'Hello', + '*Hello': '*Hello', + 'Hello*': 'Hello*', + 'He*llo': 'He*llo', + '***Hello***': `*${ boldWrapper('Hello') }*`, + '***Hello**': `*${ boldWrapper('Hello') }`, + '*Hello* this is dog': `${ boldWrapper('Hello') } this is dog`, + 'Rocket cat says *Hello*': `Rocket cat says ${ boldWrapper('Hello') }`, + 'He said *Hello* to her': `He said ${ boldWrapper('Hello') } to her`, + '**Hello** this is dog': `${ boldWrapper('Hello') } this is dog`, + 'Rocket cat says **Hello**': `Rocket cat says ${ boldWrapper('Hello') }`, + 'He said **Hello** to her': `He said ${ boldWrapper('Hello') } to her`, + 'He was a**nn**oyed': `He was a${ boldWrapper('nn') }oyed`, + 'There are two o in f*oo*tball': `There are two o in f${ boldWrapper('oo') }tball`, +}; + +const italic = { + _Hello_: italicWrapper('Hello'), + __Hello__: italicWrapper('Hello'), + __Hello_: italicWrapper('Hello'), + _Hello__: italicWrapper('Hello'), + Hello: 'Hello', + _Hello: '_Hello', + Hello_: 'Hello_', + He_llo: 'He_llo', + ___Hello___: '___Hello___', + ___Hello__: '___Hello__', + '_Hello_ this is dog': `${ italicWrapper('Hello') } this is dog`, + 'Rocket cat says _Hello_': `Rocket cat says ${ italicWrapper('Hello') }`, + 'He said _Hello_ to her': `He said ${ italicWrapper('Hello') } to her`, + '__Hello__ this is dog': `${ italicWrapper('Hello') } this is dog`, + 'Rocket cat says __Hello__': `Rocket cat says ${ italicWrapper('Hello') }`, + 'He said __Hello__ to her': `He said ${ italicWrapper('Hello') } to her`, +}; + +const strike = { + '~Hello~': strikeWrapper('Hello'), + '~~Hello~~': strikeWrapper('Hello'), + '~~Hello~': strikeWrapper('Hello'), + '~Hello~~': strikeWrapper('Hello'), + Hello: 'Hello', + '~Hello': '~Hello', + 'Hello~': 'Hello~', + 'He~llo': 'He~llo', + '~~~Hello~~~': '~~~Hello~~~', + '~~~Hello~~': '~~~Hello~~', + '~Hello~ this is dog': `${ strikeWrapper('Hello') } this is dog`, + 'Rocket cat says ~Hello~': `Rocket cat says ${ strikeWrapper('Hello') }`, + 'He said ~Hello~ to her': `He said ${ strikeWrapper('Hello') } to her`, + '~~Hello~~ this is dog': `${ strikeWrapper('Hello') } this is dog`, + 'Rocket cat says ~~Hello~~': `Rocket cat says ${ strikeWrapper('Hello') }`, + 'He said ~~Hello~~ to her': `He said ${ strikeWrapper('Hello') } to her`, +}; + +const headersLevel1 = { + '# Hello': headerWrapper('Hello', 1), + '# Rocket.Cat': headerWrapper('Rocket.Cat', 1), + '# Hi': headerWrapper('Hi', 1), + '# Hello this is dog': headerWrapper('Hello this is dog', 1), + '# Rocket cat says Hello': headerWrapper('Rocket cat says Hello', 1), + '# He said Hello to her': headerWrapper('He said Hello to her', 1), + '#Hello': '#Hello', + '#Hello#': '#Hello#', + 'He#llo': 'He#llo', +}; + +const headersLevel2 = { + '## Hello': headerWrapper('Hello', 2), + '## Rocket.Cat': headerWrapper('Rocket.Cat', 2), + '## Hi': headerWrapper('Hi', 2), + '## Hello this is dog': headerWrapper('Hello this is dog', 2), + '## Rocket cat says Hello': headerWrapper('Rocket cat says Hello', 2), + '## He said Hello to her': headerWrapper('He said Hello to her', 2), + '##Hello': '##Hello', + '##Hello##': '##Hello##', + 'He##llo': 'He##llo', +}; + +const headersLevel3 = { + '### Hello': headerWrapper('Hello', 3), + '### Rocket.Cat': headerWrapper('Rocket.Cat', 3), + '### Hi': headerWrapper('Hi', 3), + '### Hello this is dog': headerWrapper('Hello this is dog', 3), + '### Rocket cat says Hello': headerWrapper('Rocket cat says Hello', 3), + '### He said Hello to her': headerWrapper('He said Hello to her', 3), + '###Hello': '###Hello', + '###Hello###': '###Hello###', + 'He###llo': 'He###llo', +}; + +const headersLevel4 = { + '#### Hello': headerWrapper('Hello', 4), + '#### Rocket.Cat': headerWrapper('Rocket.Cat', 4), + '#### Hi': headerWrapper('Hi', 4), + '#### Hello this is dog': headerWrapper('Hello this is dog', 4), + '#### Rocket cat says Hello': headerWrapper('Rocket cat says Hello', 4), + '#### He said Hello to her': headerWrapper('He said Hello to her', 4), + '####Hello': '####Hello', + '####Hello####': '####Hello####', + 'He####llo': 'He####llo', +}; + +const quote = { + '>Hello': s.escapeHTML('>Hello'), + '>Rocket.Cat': s.escapeHTML('>Rocket.Cat'), + '>Hi': s.escapeHTML('>Hi'), + '> Hello this is dog': s.escapeHTML('> Hello this is dog'), + '> Rocket cat says Hello': s.escapeHTML('> Rocket cat says Hello'), + '> He said Hello to her': s.escapeHTML('> He said Hello to her'), + '> He said Hello to her ': s.escapeHTML('> He said Hello to her '), + '<Hello': s.escapeHTML('<Hello'), + '<Rocket.Cat>': s.escapeHTML('<Rocket.Cat>'), + ' >Hi': s.escapeHTML(' >Hi'), + 'Hello > this is dog': s.escapeHTML('Hello > this is dog'), + 'Roc>ket cat says Hello': s.escapeHTML('Roc>ket cat says Hello'), + 'He said Hello to her>': s.escapeHTML('He said Hello to her>'), + '>Hello': quoteWrapper('Hello'), + '>Rocket.Cat': quoteWrapper('Rocket.Cat'), + '>Hi': quoteWrapper('Hi'), + '> Hello this is dog': quoteWrapper(' Hello this is dog'), + '> Rocket cat says Hello': quoteWrapper(' Rocket cat says Hello'), + '> He said Hello to her': quoteWrapper(' He said Hello to her'), + '': s.escapeHTML(''), + ' >Hi': s.escapeHTML(' >Hi'), + 'Hello > this is dog': s.escapeHTML('Hello > this is dog'), + 'Roc>ket cat says Hello': s.escapeHTML('Roc>ket cat says Hello'), + 'He said Hello to her>': s.escapeHTML('He said Hello to her>'), +}; + +const link = { + '<http://link|Text>': s.escapeHTML('<http://link|Text>'), + '<https://open.rocket.chat/|Open Site For Rocket.Chat>': s.escapeHTML('<https://open.rocket.chat/|Open Site For Rocket.Chat>'), + '<https://open.rocket.chat/ | Open Site For Rocket.Chat>': s.escapeHTML('<https://open.rocket.chat/ | Open Site For Rocket.Chat>'), + '<https://rocket.chat/|Rocket.Chat Site>': s.escapeHTML('<https://rocket.chat/|Rocket.Chat Site>'), + '<https://rocket.chat/docs/developer-guides/testing/#testing|Testing Entry on Rocket.Chat Docs Site>': s.escapeHTML('<https://rocket.chat/docs/developer-guides/testing/#testing|Testing Entry on Rocket.Chat Docs Site>'), + '<http://linkText>': s.escapeHTML('<http://linkText>'), + '<https:open.rocket.chat/ | Open Site For Rocket.Chat>': s.escapeHTML('<https:open.rocket.chat/ | Open Site For Rocket.Chat>'), + 'https://open.rocket.chat/|Open Site For Rocket.Chat': s.escapeHTML('https://open.rocket.chat/|Open Site For Rocket.Chat'), + '<www.open.rocket.chat/|Open Site For Rocket.Chat>': s.escapeHTML('<www.open.rocket.chat/|Open Site For Rocket.Chat>'), + '<htps://rocket.chat/|Rocket.Chat Site>': s.escapeHTML('<htps://rocket.chat/|Rocket.Chat Site>'), + '<ttps://rocket.chat/|Rocket.Chat Site>': s.escapeHTML('<ttps://rocket.chat/|Rocket.Chat Site>'), + '<tps://rocket.chat/|Rocket.Chat Site>': s.escapeHTML('<tps://rocket.chat/|Rocket.Chat Site>'), + '<open.rocket.chat/|Open Site For Rocket.Chat>': s.escapeHTML('<open.rocket.chat/|Open Site For Rocket.Chat>'), + '<htts://rocket.chat/docs/developer-guides/testing/#testing|Testing Entry on Rocket.Chat Docs Site>': s.escapeHTML('<htts://rocket.chat/docs/developer-guides/testing/#testing|Testing Entry on Rocket.Chat Docs Site>'), + + '': linkWrapped('http://link', 'Text'), + '': linkWrapped('https://open.rocket.chat/', 'Open Site For Rocket.Chat'), + '': linkWrapped('https://open.rocket.chat/ ', ' Open Site For Rocket.Chat'), + '': linkWrapped('https://rocket.chat/', 'Rocket.Chat Site'), + '': linkWrapped('https://rocket.chat/docs/developer-guides/testing/#testing', 'Testing Entry on Rocket.Chat Docs Site'), + '': s.escapeHTML(''), + '': s.escapeHTML(''), + '': s.escapeHTML(''), + '': s.escapeHTML(''), + '': s.escapeHTML(''), + '': s.escapeHTML(''), + '': s.escapeHTML(''), + '': s.escapeHTML(''), + + '[Text](http://link)': linkWrapped('http://link', 'Text'), + '[Open Site For Rocket.Chat](https://open.rocket.chat/)': linkWrapped('https://open.rocket.chat/', 'Open Site For Rocket.Chat'), + '[ Open Site For Rocket.Chat](https://open.rocket.chat/ )': linkWrapped('https://open.rocket.chat/ ', ' Open Site For Rocket.Chat'), + '[Rocket.Chat Site](https://rocket.chat/)': linkWrapped('https://rocket.chat/', 'Rocket.Chat Site'), + '[Testing Entry on Rocket.Chat Docs Site](https://rocket.chat/docs/developer-guides/testing/#testing)': linkWrapped('https://rocket.chat/docs/developer-guides/testing/#testing', 'Testing Entry on Rocket.Chat Docs Site'), + '[](http://linkText)': '[](http://linkText)', + '[text]': '[text]', + '[Open Site For Rocket.Chat](https:open.rocket.chat/)': '[Open Site For Rocket.Chat](https:open.rocket.chat/)', + '[Open Site For Rocket.Chat](www.open.rocket.chat/)': '[Open Site For Rocket.Chat](www.open.rocket.chat/)', + '[Rocket.Chat Site](htps://rocket.chat/)': '[Rocket.Chat Site](htps://rocket.chat/)', + '[Rocket.Chat Site](ttps://rocket.chat/)': '[Rocket.Chat Site](ttps://rocket.chat/)', + '[Rocket.Chat Site](tps://rocket.chat/)': '[Rocket.Chat Site](tps://rocket.chat/)', + '[Open Site For Rocket.Chat](open.rocket.chat/)': '[Open Site For Rocket.Chat](open.rocket.chat/)', + '[Testing Entry on Rocket.Chat Docs Site](htts://rocket.chat/docs/developer-guides/testing/#testing)': '[Testing Entry on Rocket.Chat Docs Site](htts://rocket.chat/docs/developer-guides/testing/#testing)', + '[Text](http://link?param1=1¶m2=2)': linkWrapped('http://link?param1=1¶m2=2', 'Text'), +}; + +const inlinecode = { + '`code`': inlinecodeWrapper('code'), + '`code` begin': `${ inlinecodeWrapper('code') } begin`, + 'End `code`': `End ${ inlinecodeWrapper('code') }`, + 'Middle `code` middle': `Middle ${ inlinecodeWrapper('code') } middle`, + '`code`begin': `${ inlinecodeWrapper('code') }begin`, + 'End`code`': `End${ inlinecodeWrapper('code') }`, + 'Middle`code`middle': `Middle${ inlinecodeWrapper('code') }middle`, +}; + +const code = { + '```code```': codeWrapper('code', 'clean'), + '```code': codeWrapper('code\n', 'stylus'), + '```code\n': codeWrapper('code\n', 'stylus'), + '```\ncode\n```': codeWrapper('code\n', 'stylus'), + '```code\n```': codeWrapper('code\n', 'stylus'), + '```\ncode```': codeWrapper('code', 'clean'), + '```javascript\nvar a = \'log\';\nconsole.log(a);```': codeWrapper('var a = \'log\';\nconsole.log(a);', 'javascript'), + '```*code*```': codeWrapper('*code*', 'armasm'), + '```**code**```': codeWrapper('**code**', 'armasm'), + '```_code_```': codeWrapper('_code_', 'sqf'), + '```__code__```': codeWrapper('__code__', 'markdown'), +}; + +const nested = { + '> some quote\n`window.location.reload();`': `${ quoteWrapper(' some quote') }${ inlinecodeWrapper('window.location.reload();') }`, +}; + +const defaultObjectTest = (result, object, objectKey) => assert.equal(result.html, object[objectKey]); + +const testObject = (object, parser = original, test = defaultObjectTest) => { + Object.keys(object).forEach((objectKey) => { + describe(objectKey, () => { + const message = { + html: s.escapeHTML(objectKey), + }; + const result = Markdown.mountTokensBack(parser(message)); + it(`should be equal to ${ object[objectKey] }`, () => { + test(result, object, objectKey); + }); + }); + }); +}; + +describe('Original', function() { + describe('Bold', () => testObject(bold)); + + describe('Italic', () => testObject(italic)); + + describe('Strike', () => testObject(strike)); + + describe('Headers', () => { + describe('Level 1', () => testObject(headersLevel1)); + + describe('Level 2', () => testObject(headersLevel2)); + + describe('Level 3', () => testObject(headersLevel3)); + + describe('Level 4', () => testObject(headersLevel4)); + }); + + describe('Quote', () => testObject(quote)); + + describe('Link', () => testObject(link)); + + describe('Inline Code', () => testObject(inlinecode)); + + describe('Code', () => testObject(code)); + + describe('Nested', () => testObject(nested)); +}); + +// describe.only('Marked', function() { +// describe('Bold', () => testObject(bold, marked)); + +// describe('Italic', () => testObject(italic, marked)); + +// describe('Strike', () => testObject(strike, marked)); + +// describe('Headers', () => { +// describe('Level 1', () => testObject(headersLevel1, marked)); + +// describe('Level 2', () => testObject(headersLevel2, marked)); + +// describe('Level 3', () => testObject(headersLevel3, marked)); + +// describe('Level 4', () => testObject(headersLevel4, marked)); +// }); + +// describe('Quote', () => testObject(quote, marked)); +// }); diff --git a/app/mentions-flextab/client/actionButton.js b/app/mentions-flextab/client/actionButton.js new file mode 100644 index 0000000000000..1e68f74d7ed50 --- /dev/null +++ b/app/mentions-flextab/client/actionButton.js @@ -0,0 +1,23 @@ +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; + +import { MessageAction, RoomHistoryManager } from '../../ui-utils'; +import { messageArgs } from '../../ui-utils/client/lib/messageArgs'; + +Meteor.startup(function() { + MessageAction.addButton({ + id: 'jump-to-message', + icon: 'jump', + label: 'Jump_to_message', + context: ['mentions', 'threads'], + action() { + const { msg: message } = messageArgs(this); + if (window.matchMedia('(max-width: 500px)').matches) { + Template.instance().tabBar.close(); + } + RoomHistoryManager.getSurroundingMessages(message, 50); + }, + order: 100, + group: 'menu', + }); +}); diff --git a/app/mentions-flextab/client/index.js b/app/mentions-flextab/client/index.js new file mode 100644 index 0000000000000..f1443a9803229 --- /dev/null +++ b/app/mentions-flextab/client/index.js @@ -0,0 +1,4 @@ +import './views/mentionsFlexTab.html'; +import './views/mentionsFlexTab'; +import './actionButton'; +import './tabBar'; diff --git a/packages/rocketchat-mentions-flextab/client/lib/MentionedMessage.js b/app/mentions-flextab/client/lib/MentionedMessage.js similarity index 100% rename from packages/rocketchat-mentions-flextab/client/lib/MentionedMessage.js rename to app/mentions-flextab/client/lib/MentionedMessage.js diff --git a/app/mentions-flextab/client/tabBar.js b/app/mentions-flextab/client/tabBar.js new file mode 100644 index 0000000000000..afad4b9d86e2f --- /dev/null +++ b/app/mentions-flextab/client/tabBar.js @@ -0,0 +1,14 @@ +import { Meteor } from 'meteor/meteor'; + +import { TabBar } from '../../ui-utils'; + +Meteor.startup(function() { + return TabBar.addButton({ + groups: ['channel', 'group'], + id: 'mentions', + i18nTitle: 'Mentions', + icon: 'at', + template: 'mentionsFlexTab', + order: 3, + }); +}); diff --git a/app/mentions-flextab/client/views/mentionsFlexTab.html b/app/mentions-flextab/client/views/mentionsFlexTab.html new file mode 100644 index 0000000000000..3405a79f3098a --- /dev/null +++ b/app/mentions-flextab/client/views/mentionsFlexTab.html @@ -0,0 +1,21 @@ + diff --git a/packages/rocketchat-mentions-flextab/client/views/mentionsFlexTab.js b/app/mentions-flextab/client/views/mentionsFlexTab.js similarity index 77% rename from packages/rocketchat-mentions-flextab/client/views/mentionsFlexTab.js rename to app/mentions-flextab/client/views/mentionsFlexTab.js index 0cd0b4c8fe174..ed3fad676e9f7 100644 --- a/packages/rocketchat-mentions-flextab/client/views/mentionsFlexTab.js +++ b/app/mentions-flextab/client/views/mentionsFlexTab.js @@ -1,36 +1,31 @@ import _ from 'underscore'; import { ReactiveVar } from 'meteor/reactive-var'; import { Template } from 'meteor/templating'; + import { MentionedMessage } from '../lib/MentionedMessage'; +import { messageContext } from '../../../ui-utils/client/lib/messageContext'; Template.mentionsFlexTab.helpers({ hasMessages() { - return MentionedMessage.find({ - rid: this.rid, - }, { - sort: { - ts: -1, - }, - }).count() > 0; + return Template.instance().cursor.count() > 0; }, messages() { - return MentionedMessage.find({ - rid: this.rid, - }, { - sort: { - ts: -1, - }, - }); - }, - message() { - return _.extend(this, { customClass: 'mentions', actionContext: 'mentions' }); + return Template.instance().cursor; }, hasMore() { return Template.instance().hasMore.get(); }, + messageContext, }); Template.mentionsFlexTab.onCreated(function() { + this.cursor = MentionedMessage.find({ + rid: this.data.rid, + }, { + sort: { + ts: -1, + }, + }); this.hasMore = new ReactiveVar(true); this.limit = new ReactiveVar(50); return this.autorun(() => { diff --git a/packages/rocketchat-mentions-flextab/server/index.js b/app/mentions-flextab/server/index.js similarity index 100% rename from packages/rocketchat-mentions-flextab/server/index.js rename to app/mentions-flextab/server/index.js diff --git a/packages/rocketchat-mentions-flextab/server/publications/mentionedMessages.js b/app/mentions-flextab/server/publications/mentionedMessages.js similarity index 78% rename from packages/rocketchat-mentions-flextab/server/publications/mentionedMessages.js rename to app/mentions-flextab/server/publications/mentionedMessages.js index c26d68c4b5987..8eaa292347e00 100644 --- a/packages/rocketchat-mentions-flextab/server/publications/mentionedMessages.js +++ b/app/mentions-flextab/server/publications/mentionedMessages.js @@ -1,19 +1,20 @@ import { Meteor } from 'meteor/meteor'; -import { RocketChat } from 'meteor/rocketchat:lib'; + +import { Users, Messages } from '../../../models'; Meteor.publish('mentionedMessages', function(rid, limit = 50) { if (!this.userId) { return this.ready(); } const publication = this; - const user = RocketChat.models.Users.findOneById(this.userId); + const user = Users.findOneById(this.userId); if (!user) { return this.ready(); } if (!Meteor.call('canAccessRoom', rid, this.userId)) { return this.ready(); } - const cursorHandle = RocketChat.models.Messages.findVisibleByMentionAndRoomId(user.username, rid, { + const cursorHandle = Messages.findVisibleByMentionAndRoomId(user.username, rid, { sort: { ts: -1, }, diff --git a/app/mentions/client/client.js b/app/mentions/client/client.js new file mode 100644 index 0000000000000..910c8a1f2f026 --- /dev/null +++ b/app/mentions/client/client.js @@ -0,0 +1,28 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; + +import { callbacks } from '../../callbacks'; +import { settings } from '../../settings'; +import { Users } from '../../models/client'; +import { MentionsParser } from '../lib/MentionsParser'; + +let me; +let useRealName; +let pattern; + +Meteor.startup(() => Tracker.autorun(() => { + const uid = Meteor.userId(); + me = uid && (Users.findOne(uid, { fields: { username: 1 } }) || {}).username; + pattern = settings.get('UTF8_Names_Validation'); + useRealName = settings.get('UI_Use_Real_Name'); +})); + + +const instance = new MentionsParser({ + pattern: () => pattern, + useRealName: () => useRealName, + me: () => me, +}); + +callbacks.add('renderMessage', (message) => instance.parse(message), callbacks.priority.MEDIUM, 'mentions-message'); +callbacks.add('renderMentions', (message) => instance.parse(message), callbacks.priority.MEDIUM, 'mentions-mentions'); diff --git a/app/mentions/client/index.js b/app/mentions/client/index.js new file mode 100644 index 0000000000000..c9353c892c3c5 --- /dev/null +++ b/app/mentions/client/index.js @@ -0,0 +1,2 @@ +import './client'; +import './mentionLink.css'; diff --git a/app/mentions/client/mentionLink.css b/app/mentions/client/mentionLink.css new file mode 100644 index 0000000000000..70c1be80838d2 --- /dev/null +++ b/app/mentions/client/mentionLink.css @@ -0,0 +1,37 @@ +.message .mention-link, +.mention-link { + padding: 0 6px 2px; + + transition: opacity 0.3s, background-color 0.3s, color 0.3s; + + color: var(--mention-link-text-color); + + border-radius: var(--mention-link-radius); + + background-color: var(--mention-link-background); + + font-weight: 700; + + &:hover { + opacity: 0.6; + color: var(--mention-link-text-color); + } + + &--me { + color: var(--mention-link-me-text-color); + background-color: var(--mention-link-me-background); + + &:hover { + color: var(--mention-link-me-text-color); + } + } + + &--group { + color: var(--mention-link-group-text-color); + background-color: var(--mention-link-group-background); + + &:hover { + color: var(--mention-link-group-text-color); + } + } +} diff --git a/app/mentions/lib/MentionsParser.js b/app/mentions/lib/MentionsParser.js new file mode 100644 index 0000000000000..f82ec3f0aef4e --- /dev/null +++ b/app/mentions/lib/MentionsParser.js @@ -0,0 +1,109 @@ +import s from 'underscore.string'; + +export class MentionsParser { + constructor({ pattern, useRealName, me }) { + this.pattern = pattern; + this.useRealName = useRealName; + this.me = me; + } + + set me(m) { + this._me = m; + } + + get me() { + return typeof this._me === 'function' ? this._me() : this._me; + } + + set pattern(p) { + this._pattern = p; + } + + get pattern() { + return typeof this._pattern === 'function' ? this._pattern() : this._pattern; + } + + set useRealName(s) { + this._useRealName = s; + } + + get useRealName() { + return typeof this._useRealName === 'function' ? this._useRealName() : this._useRealName; + } + + get userMentionRegex() { + return new RegExp(`(^|\\s|

|
?)@(${ this.pattern }(@(${ this.pattern }))?)`, 'gm'); + } + + get channelMentionRegex() { + return new RegExp(`(^|\\s|

)#(${ this.pattern }(@(${ this.pattern }))?)`, 'gm'); + } + + replaceUsers = (msg, { mentions, temp }, me) => msg + .replace(this.userMentionRegex, (match, prefix, mention) => { + const classNames = ['mention-link']; + + if (mention === 'all') { + classNames.push('mention-link--all'); + classNames.push('mention-link--group'); + } else if (mention === 'here') { + classNames.push('mention-link--here'); + classNames.push('mention-link--group'); + } else if (mention === me) { + classNames.push('mention-link--me'); + classNames.push('mention-link--user'); + } else { + classNames.push('mention-link--user'); + } + + const className = classNames.join(' '); + + if (mention === 'all' || mention === 'here') { + return `${ prefix }${ mention }`; + } + + const label = temp + ? mention && s.escapeHTML(mention) + : (mentions || []) + .filter(({ username }) => username === mention) + .map(({ name, username }) => (this.useRealName ? name : username)) + .map((label) => label && s.escapeHTML(label))[0]; + + if (!label) { + return match; + } + + return `${ prefix }${ label }`; + }) + + replaceChannels = (msg, { temp, channels }) => msg + .replace(/'/g, '\'') + .replace(this.channelMentionRegex, (match, prefix, mention) => { + if (!temp && !(channels && channels.find((c) => c.name === mention))) { + return match; + } + + const channel = channels && channels.find(({ name }) => name === mention); + const reference = channel ? channel._id : mention; + return `${ prefix }${ `#${ mention }` }`; + }) + + getUserMentions(str) { + return (str.match(this.userMentionRegex) || []).map((match) => match.trim()); + } + + getChannelMentions(str) { + return (str.match(this.channelMentionRegex) || []).map((match) => match.trim()); + } + + parse(message) { + let msg = (message && message.html) || ''; + if (!msg.trim()) { + return message; + } + msg = this.replaceUsers(msg, message, this.me); + msg = this.replaceChannels(msg, message, this.me); + message.html = msg; + return message; + } +} diff --git a/app/mentions/server/Mentions.js b/app/mentions/server/Mentions.js new file mode 100644 index 0000000000000..e604af6fa9bbf --- /dev/null +++ b/app/mentions/server/Mentions.js @@ -0,0 +1,87 @@ +/* +* Mentions is a named function that will process Mentions +* @param {Object} message - The message object +*/ +import { MentionsParser } from '../lib/MentionsParser'; + +export default class MentionsServer extends MentionsParser { + constructor(args) { + super(args); + this.messageMaxAll = args.messageMaxAll; + this.getChannel = args.getChannel; + this.getChannels = args.getChannels; + this.getUsers = args.getUsers; + this.getUser = args.getUser; + this.getTotalChannelMembers = args.getTotalChannelMembers; + this.onMaxRoomMembersExceeded = args.onMaxRoomMembersExceeded || (() => {}); + } + + set getUsers(m) { + this._getUsers = m; + } + + get getUsers() { + return typeof this._getUsers === 'function' ? this._getUsers : () => this._getUsers; + } + + set getChannels(m) { + this._getChannels = m; + } + + get getChannels() { + return typeof this._getChannels === 'function' ? this._getChannels : () => this._getChannels; + } + + set getChannel(m) { + this._getChannel = m; + } + + get getChannel() { + return typeof this._getChannel === 'function' ? this._getChannel : () => this._getChannel; + } + + set messageMaxAll(m) { + this._messageMaxAll = m; + } + + get messageMaxAll() { + return typeof this._messageMaxAll === 'function' ? this._messageMaxAll() : this._messageMaxAll; + } + + getUsersByMentions({ msg, rid, u: sender }) { + let mentions = this.getUserMentions(msg); + const mentionsAll = []; + const userMentions = []; + + mentions.forEach((m) => { + const mention = m.trim().substr(1); + if (mention !== 'all' && mention !== 'here') { + return userMentions.push(mention); + } + if (this.messageMaxAll > 0 && this.getTotalChannelMembers(rid) > this.messageMaxAll) { + return this.onMaxRoomMembersExceeded({ sender, rid }); + } + mentionsAll.push({ + _id: mention, + username: mention, + }); + }); + mentions = userMentions.length ? this.getUsers(userMentions) : []; + return [...mentionsAll, ...mentions]; + } + + getChannelbyMentions({ msg }) { + const channels = this.getChannelMentions(msg); + return this.getChannels(channels.map((c) => c.trim().substr(1))); + } + + execute(message) { + const mentionsAll = this.getUsersByMentions(message); + const channels = this.getChannelbyMentions(message); + + message.mentions = mentionsAll; + message.channels = channels; + + return message; + } +} diff --git a/packages/rocketchat-mentions/server/index.js b/app/mentions/server/index.js similarity index 100% rename from packages/rocketchat-mentions/server/index.js rename to app/mentions/server/index.js diff --git a/app/mentions/server/methods/getUserMentionsByChannel.js b/app/mentions/server/methods/getUserMentionsByChannel.js new file mode 100644 index 0000000000000..bdcc525279016 --- /dev/null +++ b/app/mentions/server/methods/getUserMentionsByChannel.js @@ -0,0 +1,24 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { Rooms, Users, Messages } from '../../../models'; + +Meteor.methods({ + getUserMentionsByChannel({ roomId, options }) { + check(roomId, String); + + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getUserMentionsByChannel' }); + } + + const room = Rooms.findOneById(roomId); + + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'getUserMentionsByChannel' }); + } + + const user = Users.findOneById(Meteor.userId()); + + return Messages.findVisibleByMentionAndRoomId(user.username, roomId, options).fetch(); + }, +}); diff --git a/app/mentions/server/server.js b/app/mentions/server/server.js new file mode 100644 index 0000000000000..bd6109787856a --- /dev/null +++ b/app/mentions/server/server.js @@ -0,0 +1,39 @@ +import { Meteor } from 'meteor/meteor'; +import { Random } from 'meteor/random'; +import { TAPi18n } from 'meteor/tap:i18n'; +import _ from 'underscore'; + +import MentionsServer from './Mentions'; +import { settings } from '../../settings'; +import { callbacks } from '../../callbacks'; +import { Notifications } from '../../notifications'; +import { Users, Subscriptions, Rooms } from '../../models'; + +const mention = new MentionsServer({ + pattern: () => settings.get('UTF8_Names_Validation'), + messageMaxAll: () => settings.get('Message_MaxAll'), + getUsers: (usernames) => Meteor.users.find({ username: { $in: _.unique(usernames) } }, { fields: { _id: true, username: true, name: 1 } }).fetch(), + getUser: (userId) => Users.findOneById(userId), + getTotalChannelMembers: (rid) => Subscriptions.findByRoomId(rid).count(), + getChannels: (channels) => Rooms.find({ name: { $in: _.unique(channels) }, t: { $in: ['c', 'p'] } }, { fields: { _id: 1, name: 1 } }).fetch(), + onMaxRoomMembersExceeded({ sender, rid }) { + // Get the language of the user for the error notification. + const { language } = this.getUser(sender._id); + const msg = TAPi18n.__('Group_mentions_disabled_x_members', { total: this.messageMaxAll }, language); + + Notifications.notifyUser(sender._id, 'message', { + _id: Random.id(), + rid, + ts: new Date(), + msg, + groupable: false, + }); + + // Also throw to stop propagation of 'sendMessage'. + throw new Meteor.Error('error-action-not-allowed', msg, { + method: 'filterATAllTag', + action: msg, + }); + }, +}); +callbacks.add('beforeSaveMessage', (message) => mention.execute(message), callbacks.priority.HIGH, 'mentions'); diff --git a/app/mentions/tests/client.tests.js b/app/mentions/tests/client.tests.js new file mode 100644 index 0000000000000..5854ec14ba6ab --- /dev/null +++ b/app/mentions/tests/client.tests.js @@ -0,0 +1,371 @@ +/* eslint-env mocha */ +import 'babel-polyfill'; +import assert from 'assert'; + +import { MentionsParser } from '../lib/MentionsParser'; + +let mentionsParser; +beforeEach(function functionName() { + mentionsParser = new MentionsParser({ + pattern: '[0-9a-zA-Z-_.]+', + me: () => 'me', + }); +}); + +describe('Mention', function() { + describe('get pattern', () => { + const regexp = '[0-9a-zA-Z-_.]+'; + beforeEach(() => { mentionsParser.pattern = () => regexp; }); + + describe('by function', function functionName() { + it(`should be equal to ${ regexp }`, () => { + assert.equal(regexp, mentionsParser.pattern); + }); + }); + + describe('by const', function functionName() { + it(`should be equal to ${ regexp }`, () => { + assert.equal(regexp, mentionsParser.pattern); + }); + }); + }); + + describe('get useRealName', () => { + beforeEach(() => { mentionsParser.useRealName = () => true; }); + + describe('by function', function functionName() { + it('should be true', () => { + assert.equal(true, mentionsParser.useRealName); + }); + }); + + describe('by const', function functionName() { + it('should be true', () => { + assert.equal(true, mentionsParser.useRealName); + }); + }); + }); + + describe('get me', () => { + const me = 'me'; + + describe('by function', function functionName() { + beforeEach(() => { mentionsParser.me = () => me; }); + + it(`should be equal to ${ me }`, () => { + assert.equal(me, mentionsParser.me); + }); + }); + + describe('by const', function functionName() { + beforeEach(() => { mentionsParser.me = me; }); + + it(`should be equal to ${ me }`, () => { + assert.equal(me, mentionsParser.me); + }); + }); + }); + + describe('getUserMentions', function functionName() { + describe('for simple text, no mentions', () => { + const result = []; + [ + '#rocket.cat', + 'hello rocket.cat how are you?', + ] + .forEach((text) => { + it(`should return "${ JSON.stringify(result) }" from "${ text }"`, () => { + assert.deepEqual(result, mentionsParser.getUserMentions(text)); + }); + }); + }); + + describe('for one user', () => { + const result = ['@rocket.cat']; + [ + '@rocket.cat', + ' @rocket.cat ', + 'hello @rocket.cat', + // 'hello,@rocket.cat', // this test case is ignored since is not compatible with the message box behavior + '@rocket.cat, hello', + '@rocket.cat,hello', + 'hello @rocket.cat how are you?', + ] + .forEach((text) => { + it(`should return "${ JSON.stringify(result) }" from "${ text }"`, () => { + assert.deepEqual(result, mentionsParser.getUserMentions(text)); + }); + }); + + it.skip('should return without the "." from "@rocket.cat."', () => { + assert.deepEqual(result, mentionsParser.getUserMentions('@rocket.cat.')); + }); + + it.skip('should return without the "_" from "@rocket.cat_"', () => { + assert.deepEqual(result, mentionsParser.getUserMentions('@rocket.cat_')); + }); + + it.skip('should return without the "-" from "@rocket.cat-"', () => { + assert.deepEqual(result, mentionsParser.getUserMentions('@rocket.cat-')); + }); + }); + + describe('for two users', () => { + const result = ['@rocket.cat', '@all']; + [ + '@rocket.cat @all', + ' @rocket.cat @all ', + 'hello @rocket.cat and @all', + '@rocket.cat, hello @all', + 'hello @rocket.cat and @all how are you?', + ] + .forEach((text) => { + it(`should return "${ JSON.stringify(result) }" from "${ text }"`, () => { + assert.deepEqual(result, mentionsParser.getUserMentions(text)); + }); + }); + }); + }); + + describe('getChannelMentions', function functionName() { + describe('for simple text, no mentions', () => { + const result = []; + [ + '@rocket.cat', + 'hello rocket.cat how are you?', + ] + .forEach((text) => { + it(`should return "${ JSON.stringify(result) }" from "${ text }"`, () => { + assert.deepEqual(result, mentionsParser.getChannelMentions(text)); + }); + }); + }); + + describe('for one channel', () => { + const result = ['#general']; + [ + '#general', + ' #general ', + 'hello #general', + '#general, hello', + 'hello #general, how are you?', + ].forEach((text) => { + it(`should return "${ JSON.stringify(result) }" from "${ text }"`, () => { + assert.deepEqual(result, mentionsParser.getChannelMentions(text)); + }); + }); + + it.skip('should return without the "." from "#general."', () => { + assert.deepEqual(result, mentionsParser.getUserMentions('#general.')); + }); + + it.skip('should return without the "_" from "#general_"', () => { + assert.deepEqual(result, mentionsParser.getUserMentions('#general_')); + }); + + it.skip('should return without the "-" from "#general."', () => { + assert.deepEqual(result, mentionsParser.getUserMentions('#general-')); + }); + }); + + describe('for two channels', () => { + const result = ['#general', '#other']; + [ + '#general #other', + ' #general #other', + 'hello #general and #other', + '#general, hello #other', + 'hello #general #other, how are you?', + ].forEach((text) => { + it(`should return "${ JSON.stringify(result) }" from "${ text }"`, () => { + assert.deepEqual(result, mentionsParser.getChannelMentions(text)); + }); + }); + }); + + describe('for url with fragments', () => { + const result = []; + [ + 'http://localhost/#general', + ].forEach((text) => { + it(`should return nothing from "${ text }"`, () => { + assert.deepEqual(result, mentionsParser.getChannelMentions(text)); + }); + }); + }); + + describe('for messages with url and channels', () => { + const result = ['#general']; + [ + 'http://localhost/#general #general', + ].forEach((text) => { + it(`should return "${ JSON.stringify(result) }" from "${ text }"`, () => { + assert.deepEqual(result, mentionsParser.getChannelMentions(text)); + }); + }); + }); + }); +}); + +const message = { + mentions: [{ username: 'rocket.cat', name: 'Rocket.Cat' }, { username: 'admin', name: 'Admin' }, { username: 'me', name: 'Me' }, { username: 'specialchars', name: '' }], + channels: [{ name: 'general', _id: '42' }, { name: 'rocket.cat', _id: '169' }], +}; + +describe('replace methods', function() { + describe('replaceUsers', () => { + it('should render for @all', () => { + const result = mentionsParser.replaceUsers('@all', message, 'me'); + assert.equal(result, 'all'); + }); + + const str2 = 'rocket.cat'; + + it(`should render for "@${ str2 }"`, () => { + const result = mentionsParser.replaceUsers(`@${ str2 }`, message, 'me'); + assert.equal(result, `${ str2 }`); + }); + + it(`should render for "hello ${ str2 }"`, () => { + const result = mentionsParser.replaceUsers(`hello @${ str2 }`, message, 'me'); + assert.equal(result, `hello ${ str2 }`); + }); + + it('should render for unknow/private user "hello @unknow"', () => { + const result = mentionsParser.replaceUsers('hello @unknow', message, 'me'); + assert.equal(result, 'hello @unknow'); + }); + + it('should render for me', () => { + const result = mentionsParser.replaceUsers('hello @me', message, 'me'); + assert.equal(result, 'hello me'); + }); + }); + + describe('replaceUsers (RealNames)', () => { + beforeEach(() => { + mentionsParser.useRealName = () => true; + }); + + it('should render for @all', () => { + const result = mentionsParser.replaceUsers('@all', message, 'me'); + assert.equal(result, 'all'); + }); + + const str2 = 'rocket.cat'; + const str2Name = 'Rocket.Cat'; + + it(`should render for "@${ str2 }"`, () => { + const result = mentionsParser.replaceUsers(`@${ str2 }`, message, 'me'); + assert.equal(result, `${ str2Name }`); + }); + + it(`should render for "hello @${ str2 }"`, () => { + const result = mentionsParser.replaceUsers(`hello @${ str2 }`, message, 'me'); + assert.equal(result, `hello ${ str2Name }`); + }); + + const specialchars = 'specialchars'; + const specialcharsName = '<img onerror=alert(hello)>'; + + it(`should escape special characters in "hello @${ specialchars }"`, () => { + const result = mentionsParser.replaceUsers(`hello @${ specialchars }`, message, 'me'); + assert.equal(result, `hello ${ specialcharsName }`); + }); + + it(`should render for "hello
@${ str2 }
"`, () => { + const result = mentionsParser.replaceUsers(`hello
@${ str2 }
`, message, 'me'); + assert.equal(result, `hello
${ str2Name }
`); + }); + + it('should render for unknow/private user "hello @unknow"', () => { + const result = mentionsParser.replaceUsers('hello @unknow', message, 'me'); + assert.equal(result, 'hello @unknow'); + }); + + it('should render for me', () => { + const result = mentionsParser.replaceUsers('hello @me', message, 'me'); + assert.equal(result, 'hello Me'); + }); + }); + + describe('replaceChannels', () => { + it('should render for #general', () => { + const result = mentionsParser.replaceChannels('#general', message); + assert.equal('#general', result); + }); + + const str2 = '#rocket.cat'; + + it(`should render for ${ str2 }`, () => { + const result = mentionsParser.replaceChannels(str2, message); + assert.equal(result, `${ str2 }`); + }); + + it(`should render for "hello ${ str2 }"`, () => { + const result = mentionsParser.replaceChannels(`hello ${ str2 }`, message); + assert.equal(result, `hello ${ str2 }`); + }); + + it('should render for unknow/private channel "hello #unknow"', () => { + const result = mentionsParser.replaceChannels('hello #unknow', message); + assert.equal(result, 'hello #unknow'); + }); + }); + + describe('parse all', () => { + it('should render for #general', () => { + message.html = '#general'; + const result = mentionsParser.parse(message, 'me'); + assert.equal(result.html, '#general'); + }); + + it('should render for "#general and @rocket.cat', () => { + message.html = '#general and @rocket.cat'; + const result = mentionsParser.parse(message, 'me'); + assert.equal(result.html, '#general and rocket.cat'); + }); + + it('should render for "', () => { + message.html = ''; + const result = mentionsParser.parse(message, 'me'); + assert.equal(result.html, ''); + }); + + it('should render for "simple text', () => { + message.html = 'simple text'; + const result = mentionsParser.parse(message, 'me'); + assert.equal(result.html, 'simple text'); + }); + }); + + describe('parse all (RealNames)', () => { + beforeEach(() => { + mentionsParser.useRealName = () => true; + }); + + it('should render for #general', () => { + message.html = '#general'; + const result = mentionsParser.parse(message, 'me'); + assert.equal(result.html, '#general'); + }); + + it('should render for "#general and @rocket.cat', () => { + message.html = '#general and @rocket.cat'; + const result = mentionsParser.parse(message, 'me'); + assert.equal(result.html, '#general and Rocket.Cat'); + }); + + it('should render for "', () => { + message.html = ''; + const result = mentionsParser.parse(message, 'me'); + assert.equal(result.html, ''); + }); + + it('should render for "simple text', () => { + message.html = 'simple text'; + const result = mentionsParser.parse(message, 'me'); + assert.equal(result.html, 'simple text'); + }); + }); +}); diff --git a/app/mentions/tests/server.tests.js b/app/mentions/tests/server.tests.js new file mode 100644 index 0000000000000..30bc075649840 --- /dev/null +++ b/app/mentions/tests/server.tests.js @@ -0,0 +1,266 @@ +/* eslint-env mocha */ +import 'babel-polyfill'; +import assert from 'assert'; + +import MentionsServer from '../server/Mentions'; + + +let mention; + +beforeEach(function() { + mention = new MentionsServer({ + pattern: '[0-9a-zA-Z-_.]+', + messageMaxAll: () => 4, // || RocketChat.settings.get('Message_MaxAll') + getUsers: (usernames) => + [{ + _id: 1, + username: 'rocket.cat', + }, { + _id: 2, + username: 'jon', + }].filter((user) => usernames.includes(user.username)), // Meteor.users.find({ username: {$in: _.unique(usernames)}}, { fields: {_id: true, username: true }}).fetch(); + getChannels(channels) { + return [{ + _id: 1, + name: 'general', + }].filter((channel) => channels.includes(channel.name)); + // return RocketChat.models.Rooms.find({ name: {$in: _.unique(channels)}, t: 'c' }, { fields: {_id: 1, name: 1 }}).fetch(); + }, + getUser: (userId) => ({ _id: userId, language: 'en' }), + getTotalChannelMembers: (/* rid*/) => 2, + }); +}); + +describe('Mention Server', () => { + describe('getUsersByMentions', () => { + describe('for @all but the number of users is greater than messageMaxAll', () => { + beforeEach(() => { + mention.getTotalChannelMembers = () => 5; + }); + it('should return nothing', () => { + const message = { + msg: '@all', + }; + const expected = []; + const result = mention.getUsersByMentions(message); + assert.deepEqual(expected, result); + }); + }); + describe('for one user', () => { + beforeEach(() => { + mention.getChannel = () => + ({ + usernames: [{ + _id: 1, + username: 'rocket.cat', + }, { + _id: 2, + username: 'jon', + }], + }); + // Meteor.users.find({ username: {$in: _.unique(usernames)}}, { fields: {_id: true, username: true }}).fetch(); + }); + it('should return "all"', () => { + const message = { + msg: '@all', + }; + const expected = [{ + _id: 'all', + username: 'all', + }]; + const result = mention.getUsersByMentions(message); + assert.deepEqual(expected, result); + }); + it('should return "here"', () => { + const message = { + msg: '@here', + }; + const expected = [{ + _id: 'here', + username: 'here', + }]; + const result = mention.getUsersByMentions(message); + assert.deepEqual(expected, result); + }); + it('should return "rocket.cat"', () => { + const message = { + msg: '@rocket.cat', + }; + const expected = [{ + _id: 1, + username: 'rocket.cat', + }]; + const result = mention.getUsersByMentions(message); + assert.deepEqual(expected, result); + }); + }); + describe('for two user', () => { + it('should return "all and here"', () => { + const message = { + msg: '@all @here', + }; + const expected = [{ + _id: 'all', + username: 'all', + }, { + _id: 'here', + username: 'here', + }]; + const result = mention.getUsersByMentions(message); + assert.deepEqual(expected, result); + }); + it('should return "here and rocket.cat"', () => { + const message = { + msg: '@here @rocket.cat', + }; + const expected = [{ + _id: 'here', + username: 'here', + }, { + _id: 1, + username: 'rocket.cat', + }]; + const result = mention.getUsersByMentions(message); + assert.deepEqual(expected, result); + }); + + it('should return "here, rocket.cat, jon"', () => { + const message = { + msg: '@here @rocket.cat @jon', + }; + const expected = [{ + _id: 'here', + username: 'here', + }, { + _id: 1, + username: 'rocket.cat', + }, { + _id: 2, + username: 'jon', + }]; + const result = mention.getUsersByMentions(message); + assert.deepEqual(expected, result); + }); + }); + + describe('for an unknow user', () => { + it('should return "nothing"', () => { + const message = { + msg: '@unknow', + }; + const expected = []; + const result = mention.getUsersByMentions(message); + assert.deepEqual(expected, result); + }); + }); + }); + describe('getChannelbyMentions', () => { + it('should return the channel "general"', () => { + const message = { + msg: '#general', + }; + const expected = [{ + _id: 1, + name: 'general', + }]; + const result = mention.getChannelbyMentions(message); + assert.deepEqual(result, expected); + }); + it('should return nothing"', () => { + const message = { + msg: '#unknow', + }; + const expected = []; + const result = mention.getChannelbyMentions(message); + assert.deepEqual(result, expected); + }); + }); + describe('execute', () => { + it('should return the channel "general"', () => { + const message = { + msg: '#general', + }; + const expected = [{ + _id: 1, + name: 'general', + }]; + const result = mention.getChannelbyMentions(message); + assert.deepEqual(result, expected); + }); + it('should return nothing"', () => { + const message = { + msg: '#unknow', + }; + const expected = { + msg: '#unknow', + mentions: [], + channels: [], + }; + const result = mention.execute(message); + assert.deepEqual(result, expected); + }); + }); + + describe('getters and setters', () => { + describe('messageMaxAll', () => { + const mention = new MentionsServer({}); + describe('constant', () => { + it('should return the informed value', () => { + mention.messageMaxAll = 4; + assert.deepEqual(mention.messageMaxAll, 4); + }); + }); + describe('function', () => { + it('should return the informed value', () => { + mention.messageMaxAll = () => 4; + assert.deepEqual(mention.messageMaxAll, 4); + }); + }); + }); + describe('getUsers', () => { + const mention = new MentionsServer({}); + describe('constant', () => { + it('should return the informed value', () => { + mention.getUsers = 4; + assert.deepEqual(mention.getUsers(), 4); + }); + }); + describe('function', () => { + it('should return the informed value', () => { + mention.getUsers = () => 4; + assert.deepEqual(mention.getUsers(), 4); + }); + }); + }); + describe('getChannels', () => { + const mention = new MentionsServer({}); + describe('constant', () => { + it('should return the informed value', () => { + mention.getChannels = 4; + assert.deepEqual(mention.getChannels(), 4); + }); + }); + describe('function', () => { + it('should return the informed value', () => { + mention.getChannels = () => 4; + assert.deepEqual(mention.getChannels(), 4); + }); + }); + }); + describe('getChannel', () => { + const mention = new MentionsServer({}); + describe('constant', () => { + it('should return the informed value', () => { + mention.getChannel = true; + assert.deepEqual(mention.getChannel(), true); + }); + }); + describe('function', () => { + it('should return the informed value', () => { + mention.getChannel = () => true; + assert.deepEqual(mention.getChannel(), true); + }); + }); + }); + }); +}); diff --git a/app/message-action/client/index.js b/app/message-action/client/index.js new file mode 100644 index 0000000000000..7268ce4208786 --- /dev/null +++ b/app/message-action/client/index.js @@ -0,0 +1,2 @@ +import './messageAction.html'; +import './messageAction'; diff --git a/packages/rocketchat-message-action/client/messageAction.html b/app/message-action/client/messageAction.html similarity index 81% rename from packages/rocketchat-message-action/client/messageAction.html rename to app/message-action/client/messageAction.html index 872c52d58bb3a..8d6c735667721 100644 --- a/packages/rocketchat-message-action/client/messageAction.html +++ b/app/message-action/client/messageAction.html @@ -9,7 +9,7 @@ {{/if}} {{#if msg_in_chat_window}} - {{/if}} @@ -21,7 +21,7 @@ {{/if}} {{#if msg_in_chat_window}} - {{/if}} diff --git a/app/message-action/client/messageAction.js b/app/message-action/client/messageAction.js new file mode 100644 index 0000000000000..025c06db224ad --- /dev/null +++ b/app/message-action/client/messageAction.js @@ -0,0 +1,13 @@ +import { Template } from 'meteor/templating'; + +Template.messageAction.helpers({ + isButton() { + return this.type === 'button'; + }, + areButtonsHorizontal() { + return Template.parentData(1).button_alignment === 'horizontal'; + }, + jsActionButtonClassname(processingType) { + return `js-actionButton-${ processingType || 'sendMessage' }`; + }, +}); diff --git a/packages/rocketchat-message-action/client/stylesheets/messageAction.css b/app/message-action/client/stylesheets/messageAction.css similarity index 100% rename from packages/rocketchat-message-action/client/stylesheets/messageAction.css rename to app/message-action/client/stylesheets/messageAction.css diff --git a/app/message-action/index.js b/app/message-action/index.js new file mode 100644 index 0000000000000..40a7340d38877 --- /dev/null +++ b/app/message-action/index.js @@ -0,0 +1 @@ +export * from './client/index'; diff --git a/app/message-attachments/client/index.js b/app/message-attachments/client/index.js new file mode 100644 index 0000000000000..c0f9e1f529485 --- /dev/null +++ b/app/message-attachments/client/index.js @@ -0,0 +1,9 @@ +import './messageAttachment.html'; +import './messageAttachment'; +import './renderField.html'; +import { registerFieldTemplate } from './renderField'; + + +export { + registerFieldTemplate, +}; diff --git a/packages/rocketchat-message-attachments/client/messageAttachment.html b/app/message-attachments/client/messageAttachment.html similarity index 76% rename from packages/rocketchat-message-attachments/client/messageAttachment.html rename to app/message-attachments/client/messageAttachment.html index 99a8a56330554..d5165110ce890 100644 --- a/packages/rocketchat-message-attachments/client/messageAttachment.html +++ b/app/message-attachments/client/messageAttachment.html @@ -6,31 +6,34 @@ {{else}} {{pretext}} {{/if}} -

+
+
{{#if author_name}} {{#if author_link}}
{{#if author_icon}} - + {{/if}} - {{author_name}} + {{author_name}} {{#if ts}} {{#if message_link}} {{time}} {{else}} - - {{time}} - + {{#unless time}} + + {{time}} + + {{/unless}} {{/if}} {{/if}}
{{else}}
{{#if author_icon}} - + {{/if}} {{author_name}} {{#if ts}} @@ -39,9 +42,11 @@ {{time}} {{else}} - - {{time}} - + {{#unless time}} + + {{time}} + + {{/unless}} {{/if}} {{/if}}
@@ -50,9 +55,9 @@ {{#if title}}
{{#if title_link}} - {{#if isFile}} {{_ "Attachment_File_Uploaded"}}: {{/if}}{{title}} + {{title}} {{#if title_link_download}} - + {{> icon icon="download"}} {{/if}} {{else}} {{title}} @@ -69,7 +74,7 @@
{{#if thumb_url}}
- +
{{/if}} @@ -86,7 +91,7 @@
{{#if loadImage}}
- {{> lazyloadImage src=image_url preview=image_preview height=(getImageHeight image_dimensions.height) class="gallery-item" title=title description=description}} + {{> lazyloadImage src=(getURL image_url) preview=image_preview height=(getImageHeight image_dimensions.height) class="gallery-item" title=title description=description}} {{#if labels}}
{{#each labels}} @@ -112,7 +117,7 @@ {{#unless mediaCollapsed}}
@@ -123,7 +128,7 @@ {{#unless mediaCollapsed}}
@@ -141,11 +146,8 @@ {{#if fields}} {{#unless collapsed}}
- {{#each fields}} -
-
{{title}}
- {{{RocketChatMarkdown value}}} -
+ {{#each field in fields}} + {{> renderField field=field}} {{/each}}
{{/unless}} diff --git a/app/message-attachments/client/messageAttachment.js b/app/message-attachments/client/messageAttachment.js new file mode 100644 index 0000000000000..edda441cfe365 --- /dev/null +++ b/app/message-attachments/client/messageAttachment.js @@ -0,0 +1,82 @@ +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; + +import { DateFormat } from '../../lib'; +import { getUserPreference, getURL } from '../../utils/client'; +import { Users } from '../../models'; +import { renderMessageBody } from '../../ui-utils'; + +const colors = { + good: '#35AC19', + warning: '#FCB316', + danger: '#D30230', +}; + +Template.messageAttachment.helpers({ + parsedText() { + return renderMessageBody({ + msg: this.text, + }); + }, + markdownInPretext() { + return this.mrkdwn_in && this.mrkdwn_in.includes('pretext'); + }, + parsedPretext() { + return renderMessageBody({ + msg: this.pretext, + }); + }, + loadImage() { + if (this.downloadImages !== true) { + const user = Users.findOne({ _id: Meteor.userId() }, { fields: { 'settings.autoImageLoad': 1 } }); + if (getUserPreference(user, 'autoImageLoad') === false) { + return false; + } + if (Meteor.Device.isPhone() && getUserPreference(user, 'saveMobileBandwidth') !== true) { + return false; + } + } + return true; + }, + getImageHeight(height = 200) { + return height; + }, + color() { + return colors[this.color] || this.color; + }, + collapsed() { + if (this.collapsed != null) { + return this.collapsed; + } + return false; + }, + mediaCollapsed() { + if (this.collapsed != null) { + return this.collapsed; + } + return getUserPreference(Meteor.userId(), 'collapseMediaByDefault') === true; + }, + time() { + const messageDate = new Date(this.ts); + const today = new Date(); + if (messageDate.toDateString() === today.toDateString()) { + return DateFormat.formatTime(this.ts); + } + return DateFormat.formatDateAndTime(this.ts); + }, + injectIndex(data, previousIndex, index) { + data.index = `${ previousIndex }.attachments.${ index }`; + }, + + isFile() { + return this.type === 'file'; + }, + isPDF() { + if (this.type === 'file' && this.title_link.endsWith('.pdf') && Template.parentData().msg.file) { + this.fileId = Template.parentData().msg.file._id; + return true; + } + return false; + }, + getURL, +}); diff --git a/app/message-attachments/client/renderField.html b/app/message-attachments/client/renderField.html new file mode 100644 index 0000000000000..360d8b9761345 --- /dev/null +++ b/app/message-attachments/client/renderField.html @@ -0,0 +1,20 @@ + diff --git a/app/message-attachments/client/renderField.js b/app/message-attachments/client/renderField.js new file mode 100644 index 0000000000000..28bc9fd0adabf --- /dev/null +++ b/app/message-attachments/client/renderField.js @@ -0,0 +1,56 @@ +import { Template } from 'meteor/templating'; +import { Blaze } from 'meteor/blaze'; + +const renderers = {}; + +/** + * The field templates will be rendered non-reactive for all messages by the messages-list (@see rocketchat-nrr) + * Thus, we cannot provide helpers or events to the template, but we need to register this interactivity at the parent + * template which is the room. The event will be bubbled by the Blaze-framework + * @param fieldType + * @param templateName + * @param helpers + * @param events + */ +export function registerFieldTemplate(fieldType, templateName, events) { + renderers[fieldType] = templateName; + + // propagate helpers and events to the room template, changing the selectors + // loop at events. For each event (like 'click .accept'), copy the function to a function of the room events. + // While doing that, add the fieldType as class selector to the events function in order to avoid naming clashes + if (events != null) { + const uniqueEvents = {}; + // rename the event handlers so they are unique in the "parent" template to which the events bubble + for (const property in events) { + if (events.hasOwnProperty(property)) { + const event = property.substr(0, property.indexOf(' ')); + const selector = property.substr(property.indexOf(' ') + 1); + Object.defineProperty(uniqueEvents, + `${ event } .${ fieldType } ${ selector }`, + { + value: events[property], + enumerable: true, // assign as a own property + }); + } + } + Template.room.events(uniqueEvents); + } +} + +// onRendered is not being executed (no idea why). Consequently, we cannot use Blaze.renderWithData(), since we don't +// have access to the DOM outside onRendered. Therefore, we can only translate the content of the field to HTML and +// embed it non-reactively. +// This in turn means that onRendered of the field template will not be processed either. +// I guess it may have someting to do with rocketchat-nrr +Template.renderField.helpers({ + specializedRendering({ hash: { field, message } }) { + let html = ''; + if (field.type && renderers[field.type]) { + html = Blaze.toHTMLWithData(Template[renderers[field.type]], { field, message }); + } else { + // consider the value already formatted as html + html = field.value; + } + return `
${ html }
`; + }, +}); diff --git a/packages/rocketchat-message-attachments/client/stylesheets/messageAttachments.css b/app/message-attachments/client/stylesheets/messageAttachments.css similarity index 92% rename from packages/rocketchat-message-attachments/client/stylesheets/messageAttachments.css rename to app/message-attachments/client/stylesheets/messageAttachments.css index b381f30b41fa3..b716942634425 100644 --- a/packages/rocketchat-message-attachments/client/stylesheets/messageAttachments.css +++ b/app/message-attachments/client/stylesheets/messageAttachments.css @@ -65,13 +65,12 @@ html.rtl .attachment { } & .attachment-title { + + color: #1d74f5; + font-size: 1.02rem; font-weight: 500; line-height: 1.5rem; - - & > a { - font-weight: 500; - } } & .attachment-text { @@ -90,6 +89,8 @@ html.rtl .attachment { display: flex; margin-top: 4px; + + align-items: center; flex-wrap: wrap; & .attachment-field { @@ -138,11 +139,7 @@ html.rtl .attachment { } & .attachment-download-icon { - margin-left: 5px; - padding: 2px 5px; - - border-width: 1px; - border-radius: 5px; + padding: 0 5px; } & .attachment-canvas { @@ -152,9 +149,11 @@ html.rtl .attachment { & .attachment-pdf-loading { display: none; - animation: spin 1s linear infinite; - font-size: 1.5rem; + + svg { + animation: spin 1s linear infinite; + } } & .actions-container { diff --git a/app/message-attachments/index.js b/app/message-attachments/index.js new file mode 100644 index 0000000000000..40a7340d38877 --- /dev/null +++ b/app/message-attachments/index.js @@ -0,0 +1 @@ +export * from './client/index'; diff --git a/app/message-mark-as-unread/client/actionButton.js b/app/message-mark-as-unread/client/actionButton.js new file mode 100644 index 0000000000000..6725215753bdb --- /dev/null +++ b/app/message-mark-as-unread/client/actionButton.js @@ -0,0 +1,37 @@ +import { Meteor } from 'meteor/meteor'; +import { FlowRouter } from 'meteor/kadira:flow-router'; + +import { RoomManager, MessageAction } from '../../ui-utils'; +import { messageArgs } from '../../ui-utils/client/lib/messageArgs'; +import { handleError } from '../../utils'; +import { ChatSubscription } from '../../models'; + +Meteor.startup(() => { + MessageAction.addButton({ + id: 'mark-message-as-unread', + icon: 'flag', + label: 'Mark_unread', + context: ['message', 'message-mobile'], + action() { + const { msg: message } = messageArgs(this); + return Meteor.call('unreadMessages', message, function(error) { + if (error) { + return handleError(error); + } + const subscription = ChatSubscription.findOne({ + rid: message.rid, + }); + if (subscription == null) { + return; + } + RoomManager.close(subscription.t + subscription.name); + return FlowRouter.go('home'); + }); + }, + condition({ msg, u }) { + return msg.u._id !== u._id; + }, + order: 10, + group: 'menu', + }); +}); diff --git a/packages/rocketchat-message-mark-as-unread/client/index.js b/app/message-mark-as-unread/client/index.js similarity index 100% rename from packages/rocketchat-message-mark-as-unread/client/index.js rename to app/message-mark-as-unread/client/index.js diff --git a/packages/rocketchat-message-mark-as-unread/server/index.js b/app/message-mark-as-unread/server/index.js similarity index 100% rename from packages/rocketchat-message-mark-as-unread/server/index.js rename to app/message-mark-as-unread/server/index.js diff --git a/app/message-mark-as-unread/server/logger.js b/app/message-mark-as-unread/server/logger.js new file mode 100644 index 0000000000000..1327ecda6e8cc --- /dev/null +++ b/app/message-mark-as-unread/server/logger.js @@ -0,0 +1,9 @@ +import { Logger } from '../../logger'; + +const logger = new Logger('MessageMarkAsUnread', { + sections: { + connection: 'Connection', + events: 'Events', + }, +}); +export default logger; diff --git a/app/message-mark-as-unread/server/unreadMessages.js b/app/message-mark-as-unread/server/unreadMessages.js new file mode 100644 index 0000000000000..4eee05fd230d4 --- /dev/null +++ b/app/message-mark-as-unread/server/unreadMessages.js @@ -0,0 +1,49 @@ +import { Meteor } from 'meteor/meteor'; + +import logger from './logger'; +import { Messages, Subscriptions } from '../../models'; + +Meteor.methods({ + unreadMessages(firstUnreadMessage, room) { + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'unreadMessages', + }); + } + + if (room) { + const lastMessage = Messages.findVisibleByRoomId(room, { limit: 1, sort: { ts: -1 } }).fetch()[0]; + + if (lastMessage == null) { + throw new Meteor.Error('error-action-not-allowed', 'Not allowed', { + method: 'unreadMessages', + action: 'Unread_messages', + }); + } + + return Subscriptions.setAsUnreadByRoomIdAndUserId(lastMessage.rid, userId, lastMessage.ts); + } + + const originalMessage = Messages.findOneById(firstUnreadMessage._id, { + fields: { + u: 1, + rid: 1, + file: 1, + ts: 1, + }, + }); + if (originalMessage == null || userId === originalMessage.u._id) { + throw new Meteor.Error('error-action-not-allowed', 'Not allowed', { + method: 'unreadMessages', + action: 'Unread_messages', + }); + } + const lastSeen = Subscriptions.findOneByRoomIdAndUserId(originalMessage.rid, userId).ls; + if (firstUnreadMessage.ts >= lastSeen) { + return logger.connection.debug('Provided message is already marked as unread'); + } + logger.connection.debug(`Updating unread message of ${ originalMessage.ts } as the first unread`); + return Subscriptions.setAsUnreadByRoomIdAndUserId(originalMessage.rid, userId, originalMessage.ts); + }, +}); diff --git a/app/message-pin/client/actionButton.js b/app/message-pin/client/actionButton.js new file mode 100644 index 0000000000000..1fcf1f19cc210 --- /dev/null +++ b/app/message-pin/client/actionButton.js @@ -0,0 +1,99 @@ +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; +import { TAPi18n } from 'meteor/tap:i18n'; +import toastr from 'toastr'; + +import { RoomHistoryManager, MessageAction } from '../../ui-utils'; +import { messageArgs } from '../../ui-utils/client/lib/messageArgs'; +import { handleError } from '../../utils'; +import { settings } from '../../settings'; +import { hasAtLeastOnePermission } from '../../authorization'; + +Meteor.startup(function() { + MessageAction.addButton({ + id: 'pin-message', + icon: 'pin', + label: 'Pin', + context: ['pinned', 'message', 'message-mobile'], + action() { + const { msg: message } = messageArgs(this); + message.pinned = true; + Meteor.call('pinMessage', message, function(error) { + if (error) { + return handleError(error); + } + }); + }, + condition({ msg, subscription }) { + if (!settings.get('Message_AllowPinning') || msg.pinned || !subscription) { + return false; + } + + return hasAtLeastOnePermission('pin-message', msg.rid); + }, + order: 7, + group: 'menu', + }); + + MessageAction.addButton({ + id: 'unpin-message', + icon: 'pin', + label: 'Unpin', + context: ['pinned', 'message', 'message-mobile'], + action() { + const { msg: message } = messageArgs(this); + message.pinned = false; + Meteor.call('unpinMessage', message, function(error) { + if (error) { + return handleError(error); + } + }); + }, + condition({ msg, subscription }) { + if (!subscription || !settings.get('Message_AllowPinning') || !msg.pinned) { + return false; + } + + return hasAtLeastOnePermission('pin-message', msg.rid); + }, + order: 8, + group: 'menu', + }); + + MessageAction.addButton({ + id: 'jump-to-pin-message', + icon: 'jump', + label: 'Jump_to_message', + context: ['pinned'], + action() { + const { msg: message } = messageArgs(this); + if (window.matchMedia('(max-width: 500px)').matches) { + Template.instance().tabBar.close(); + } + return RoomHistoryManager.getSurroundingMessages(message, 50); + }, + condition({ subscription }) { + return !!subscription; + }, + order: 100, + group: 'menu', + }); + + MessageAction.addButton({ + id: 'permalink-pinned', + icon: 'permalink', + label: 'Get_link', + classes: 'clipboard', + context: ['pinned'], + async action(event) { + const { msg: message } = messageArgs(this); + $(event.currentTarget).attr('data-clipboard-text', await MessageAction.getPermaLink(message._id)); + toastr.success(TAPi18n.__('Copied')); + }, + condition({ subscription }) { + return !!subscription; + }, + order: 101, + group: 'menu', + }); +}); diff --git a/app/message-pin/client/index.js b/app/message-pin/client/index.js new file mode 100644 index 0000000000000..9d154e9efedb5 --- /dev/null +++ b/app/message-pin/client/index.js @@ -0,0 +1,6 @@ +import './actionButton'; +import './messageType'; +import './pinMessage'; +import './tabBar'; +import './views/pinnedMessages.html'; +import './views/pinnedMessages'; diff --git a/packages/rocketchat-message-pin/client/lib/PinnedMessage.js b/app/message-pin/client/lib/PinnedMessage.js similarity index 100% rename from packages/rocketchat-message-pin/client/lib/PinnedMessage.js rename to app/message-pin/client/lib/PinnedMessage.js diff --git a/app/message-pin/client/messageType.js b/app/message-pin/client/messageType.js new file mode 100644 index 0000000000000..29effe5e83963 --- /dev/null +++ b/app/message-pin/client/messageType.js @@ -0,0 +1,11 @@ +import { Meteor } from 'meteor/meteor'; + +import { MessageTypes } from '../../ui-utils'; + +Meteor.startup(function() { + MessageTypes.registerType({ + id: 'message_pinned', + system: true, + message: 'Pinned_a_message', + }); +}); diff --git a/app/message-pin/client/pinMessage.js b/app/message-pin/client/pinMessage.js new file mode 100644 index 0000000000000..7822c2875a2d0 --- /dev/null +++ b/app/message-pin/client/pinMessage.js @@ -0,0 +1,43 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../settings'; +import { ChatMessage, Subscriptions } from '../../models'; + +Meteor.methods({ + pinMessage(message) { + if (!Meteor.userId()) { + return false; + } + if (!settings.get('Message_AllowPinning')) { + return false; + } + if (Subscriptions.findOne({ rid: message.rid }) == null) { + return false; + } + return ChatMessage.update({ + _id: message._id, + }, { + $set: { + pinned: true, + }, + }); + }, + unpinMessage(message) { + if (!Meteor.userId()) { + return false; + } + if (!settings.get('Message_AllowPinning')) { + return false; + } + if (Subscriptions.findOne({ rid: message.rid }) == null) { + return false; + } + return ChatMessage.update({ + _id: message._id, + }, { + $set: { + pinned: false, + }, + }); + }, +}); diff --git a/app/message-pin/client/tabBar.js b/app/message-pin/client/tabBar.js new file mode 100644 index 0000000000000..2830549a1fe04 --- /dev/null +++ b/app/message-pin/client/tabBar.js @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; + +import { settings } from '../../settings'; +import { TabBar } from '../../ui-utils'; + +Meteor.startup(function() { + return Tracker.autorun(function() { + if (settings.get('Message_AllowPinning')) { + TabBar.addButton({ + groups: ['channel', 'group', 'direct'], + id: 'pinned-messages', + i18nTitle: 'Pinned_Messages', + icon: 'pin', + template: 'pinnedMessages', + order: 10, + }); + } else { + TabBar.removeButton('pinned-messages'); + } + }); +}); diff --git a/app/message-pin/client/views/pinnedMessages.html b/app/message-pin/client/views/pinnedMessages.html new file mode 100644 index 0000000000000..7ad911de4ab08 --- /dev/null +++ b/app/message-pin/client/views/pinnedMessages.html @@ -0,0 +1,22 @@ + diff --git a/app/message-pin/client/views/pinnedMessages.js b/app/message-pin/client/views/pinnedMessages.js new file mode 100644 index 0000000000000..2a1d13ee349b2 --- /dev/null +++ b/app/message-pin/client/views/pinnedMessages.js @@ -0,0 +1,53 @@ +import _ from 'underscore'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { Template } from 'meteor/templating'; + +import { PinnedMessage } from '../lib/PinnedMessage'; +import { messageContext } from '../../../ui-utils/client/lib/messageContext'; + +Template.pinnedMessages.helpers({ + hasMessages() { + return Template.instance().cursor.count() > 0; + }, + messages() { + return Template.instance().cursor; + }, + message() { + return _.extend(this, { customClass: 'pinned', actionContext: 'pinned' }); + }, + hasMore() { + return Template.instance().hasMore.get(); + }, + messageContext, +}); + +Template.pinnedMessages.onCreated(function() { + this.rid = this.data.rid; + + this.cursor = PinnedMessage.find({ + rid: this.data.rid, + }, { + sort: { + ts: -1, + }, + }); + + this.hasMore = new ReactiveVar(true); + this.limit = new ReactiveVar(50); + return this.autorun(() => { + const data = Template.currentData(); + return this.subscribe('pinnedMessages', data.rid, this.limit.get(), () => { + if (this.cursor.count() < this.limit.get()) { + return this.hasMore.set(false); + } + }); + }); +}); + +Template.pinnedMessages.events({ + 'scroll .js-list': _.throttle(function(e, instance) { + if (e.target.scrollTop >= e.target.scrollHeight - e.target.clientHeight && instance.hasMore.get()) { + return instance.limit.set(instance.limit.get() + 50); + } + }, 200), +}); diff --git a/packages/rocketchat-message-pin/client/views/stylesheets/messagepin.css b/app/message-pin/client/views/stylesheets/messagepin.css similarity index 100% rename from packages/rocketchat-message-pin/client/views/stylesheets/messagepin.css rename to app/message-pin/client/views/stylesheets/messagepin.css diff --git a/app/message-pin/server/index.js b/app/message-pin/server/index.js new file mode 100644 index 0000000000000..e4160692c732d --- /dev/null +++ b/app/message-pin/server/index.js @@ -0,0 +1,4 @@ +import './settings'; +import './pinMessage'; +import './publications/pinnedMessages'; +import './startup/indexes'; diff --git a/app/message-pin/server/pinMessage.js b/app/message-pin/server/pinMessage.js new file mode 100644 index 0000000000000..8af0fbe99b97f --- /dev/null +++ b/app/message-pin/server/pinMessage.js @@ -0,0 +1,164 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../settings'; +import { callbacks } from '../../callbacks'; +import { isTheLastMessage } from '../../lib'; +import { getUserAvatarURL } from '../../utils/lib/getUserAvatarURL'; +import { hasPermission } from '../../authorization'; +import { Subscriptions, Messages, Users, Rooms } from '../../models'; + +const recursiveRemove = (msg, deep = 1) => { + if (!msg) { + return; + } + + if (deep > settings.get('Message_QuoteChainLimit')) { + delete msg.attachments; + return msg; + } + + msg.attachments = Array.isArray(msg.attachments) ? msg.attachments.map( + (nestedMsg) => recursiveRemove(nestedMsg, deep + 1) + ) : null; + + return msg; +}; + +const shouldAdd = (attachments, attachment) => !attachments.some(({ message_link }) => message_link && message_link === attachment.message_link); + +Meteor.methods({ + pinMessage(message, pinnedAt) { + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'pinMessage', + }); + } + + if (!settings.get('Message_AllowPinning')) { + throw new Meteor.Error('error-action-not-allowed', 'Message pinning not allowed', { + method: 'pinMessage', + action: 'Message_pinning', + }); + } + + if (!hasPermission(Meteor.userId(), 'pin-message', message.rid)) { + throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'pinMessage' }); + } + + const subscription = Subscriptions.findOneByRoomIdAndUserId(message.rid, Meteor.userId(), { fields: { _id: 1 } }); + if (!subscription) { + return false; + } + + let originalMessage = Messages.findOneById(message._id); + if (originalMessage == null || originalMessage._id == null) { + throw new Meteor.Error('error-invalid-message', 'Message you are pinning was not found', { + method: 'pinMessage', + action: 'Message_pinning', + }); + } + + const me = Users.findOneById(userId); + + // If we keep history of edits, insert a new message to store history information + if (settings.get('Message_KeepHistory')) { + Messages.cloneAndSaveAsHistoryById(message._id, me); + } + const room = Meteor.call('canAccessRoom', message.rid, Meteor.userId()); + + originalMessage.pinned = true; + originalMessage.pinnedAt = pinnedAt || Date.now; + originalMessage.pinnedBy = { + _id: userId, + username: me.username, + }; + + originalMessage = callbacks.run('beforeSaveMessage', originalMessage); + + Messages.setPinnedByIdAndUserId(originalMessage._id, originalMessage.pinnedBy, originalMessage.pinned); + if (isTheLastMessage(room, message)) { + Rooms.setLastMessagePinned(room._id, originalMessage.pinnedBy, originalMessage.pinned); + } + + const attachments = []; + + if (Array.isArray(originalMessage.attachments)) { + originalMessage.attachments.forEach((attachment) => { + if (!attachment.message_link || shouldAdd(attachments, attachment)) { + attachments.push(attachment); + } + }); + } + + return Messages.createWithTypeRoomIdMessageAndUser( + 'message_pinned', + originalMessage.rid, + '', + me, + { + attachments: [ + { + text: originalMessage.msg, + author_name: originalMessage.u.username, + author_icon: getUserAvatarURL(originalMessage.u.username), + ts: originalMessage.ts, + attachments: recursiveRemove(attachments), + }, + ], + } + ); + }, + unpinMessage(message) { + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'unpinMessage', + }); + } + + if (!settings.get('Message_AllowPinning')) { + throw new Meteor.Error('error-action-not-allowed', 'Message pinning not allowed', { + method: 'unpinMessage', + action: 'Message_pinning', + }); + } + + if (!hasPermission(Meteor.userId(), 'pin-message', message.rid)) { + throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'pinMessage' }); + } + + const subscription = Subscriptions.findOneByRoomIdAndUserId(message.rid, Meteor.userId(), { fields: { _id: 1 } }); + if (!subscription) { + return false; + } + + let originalMessage = Messages.findOneById(message._id); + + if (originalMessage == null || originalMessage._id == null) { + throw new Meteor.Error('error-invalid-message', 'Message you are unpinning was not found', { + method: 'unpinMessage', + action: 'Message_pinning', + }); + } + + const me = Users.findOneById(Meteor.userId()); + + // If we keep history of edits, insert a new message to store history information + if (settings.get('Message_KeepHistory')) { + Messages.cloneAndSaveAsHistoryById(originalMessage._id, me); + } + + originalMessage.pinned = false; + originalMessage.pinnedBy = { + _id: Meteor.userId(), + username: me.username, + }; + originalMessage = callbacks.run('beforeSaveMessage', originalMessage); + const room = Meteor.call('canAccessRoom', message.rid, Meteor.userId()); + if (isTheLastMessage(room, message)) { + Rooms.setLastMessagePinned(room._id, originalMessage.pinnedBy, originalMessage.pinned); + } + + return Messages.setPinnedByIdAndUserId(originalMessage._id, originalMessage.pinnedBy, originalMessage.pinned); + }, +}); diff --git a/app/message-pin/server/publications/pinnedMessages.js b/app/message-pin/server/publications/pinnedMessages.js new file mode 100644 index 0000000000000..7dce895eb76e9 --- /dev/null +++ b/app/message-pin/server/publications/pinnedMessages.js @@ -0,0 +1,33 @@ +import { Meteor } from 'meteor/meteor'; + +import { Users, Messages } from '../../../models'; + +Meteor.publish('pinnedMessages', function(rid, limit = 50) { + if (!this.userId) { + return this.ready(); + } + const publication = this; + + const user = Users.findOneById(this.userId); + if (!user) { + return this.ready(); + } + if (!Meteor.call('canAccessRoom', rid, this.userId)) { + return this.ready(); + } + const cursorHandle = Messages.findPinnedByRoom(rid, { sort: { ts: -1 }, limit }).observeChanges({ + added(_id, record) { + return publication.added('rocketchat_pinned_message', _id, record); + }, + changed(_id, record) { + return publication.changed('rocketchat_pinned_message', _id, record); + }, + removed(_id) { + return publication.removed('rocketchat_pinned_message', _id); + }, + }); + this.ready(); + return this.onStop(function() { + return cursorHandle.stop(); + }); +}); diff --git a/app/message-pin/server/settings.js b/app/message-pin/server/settings.js new file mode 100644 index 0000000000000..c2af7eaf27613 --- /dev/null +++ b/app/message-pin/server/settings.js @@ -0,0 +1,17 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../settings'; +import { Permissions } from '../../models'; + +Meteor.startup(function() { + settings.add('Message_AllowPinning', true, { + type: 'boolean', + group: 'Message', + public: true, + }); + return Permissions.upsert('pin-message', { + $setOnInsert: { + roles: ['owner', 'moderator', 'admin'], + }, + }); +}); diff --git a/app/message-pin/server/startup/indexes.js b/app/message-pin/server/startup/indexes.js new file mode 100644 index 0000000000000..2036ca0b294cd --- /dev/null +++ b/app/message-pin/server/startup/indexes.js @@ -0,0 +1,13 @@ +import { Meteor } from 'meteor/meteor'; + +import { Messages } from '../../../models'; + +Meteor.startup(function() { + return Meteor.defer(function() { + return Messages.tryEnsureIndex({ + 'pinnedBy._id': 1, + }, { + sparse: 1, + }); + }); +}); diff --git a/app/message-snippet/client/actionButton.js b/app/message-snippet/client/actionButton.js new file mode 100644 index 0000000000000..342ff2b2854dd --- /dev/null +++ b/app/message-snippet/client/actionButton.js @@ -0,0 +1,69 @@ +// import { Meteor } from 'meteor/meteor'; +// import { MessageAction, modal } from '../../ui-utils'; +// import { messageArgs } from '../../ui-utils/client/lib/messageArgs'; +// import { t, handleError } from '../../utils'; +// import { settings } from '../../settings'; +// import { Subscriptions } from '../../models'; +// import { hasAtLeastOnePermission } from '../../authorization'; +// +// Meteor.startup(function() { +// MessageAction.addButton({ +// id: 'snippeted-message', +// icon: 'code', +// label: 'Snippet', +// context: [ +// 'snippeted', +// 'message', +// 'message-mobile', +// ], +// order: 10, +// group: 'menu', +// action() { +// const { msg: message } = messageArgs(this); +// +// modal.open({ +// title: 'Create a Snippet', +// text: 'The name of your snippet (with file extension):', +// type: 'input', +// showCancelButton: true, +// closeOnConfirm: false, +// inputPlaceholder: 'Snippet name', +// }, function(filename) { +// if (filename === false) { +// return false; +// } +// if (filename === '') { +// modal.showInputError('You need to write something!'); +// return false; +// } +// message.snippeted = true; +// Meteor.call('snippetMessage', message, filename, function(error) { +// if (error) { +// return handleError(error); +// } +// modal.open({ +// title: t('Nice'), +// text: `Snippet '${ filename }' created.`, +// type: 'success', +// timer: 2000, +// }); +// }); +// }); +// +// }, +// condition(message) { +// if (Subscriptions.findOne({ rid: message.rid, 'u._id': Meteor.userId() }) === undefined) { +// return false; +// } +// +// if (message.snippeted || ((settings.get('Message_AllowSnippeting') === undefined) || +// (settings.get('Message_AllowSnippeting') === null) || +// (settings.get('Message_AllowSnippeting')) === false)) { +// return false; +// } +// +// return hasAtLeastOnePermission('snippet-message', message.rid); +// }, +// }); +// +// }); diff --git a/app/message-snippet/client/index.js b/app/message-snippet/client/index.js new file mode 100644 index 0000000000000..5da7893284ead --- /dev/null +++ b/app/message-snippet/client/index.js @@ -0,0 +1,9 @@ +import './actionButton'; +import './messageType'; +import './snippetMessage'; +import './router'; +import './page/snippetPage.html'; +import './page/snippetPage'; +import './tabBar/tabBar'; +import './tabBar/views/snippetedMessages.html'; +import './tabBar/views/snippetedMessages'; diff --git a/packages/rocketchat-message-snippet/client/lib/collections.js b/app/message-snippet/client/lib/collections.js similarity index 100% rename from packages/rocketchat-message-snippet/client/lib/collections.js rename to app/message-snippet/client/lib/collections.js diff --git a/app/message-snippet/client/messageType.js b/app/message-snippet/client/messageType.js new file mode 100644 index 0000000000000..7c5240ac38580 --- /dev/null +++ b/app/message-snippet/client/messageType.js @@ -0,0 +1,16 @@ +import { Meteor } from 'meteor/meteor'; +import s from 'underscore.string'; + +import { MessageTypes } from '../../ui-utils'; + +Meteor.startup(function() { + MessageTypes.registerType({ + id: 'message_snippeted', + system: true, + message: 'Snippeted_a_message', + data(message) { + const snippetLink = `${ s.escapeHTML(message.snippetName) }`; + return { snippetLink }; + }, + }); +}); diff --git a/packages/rocketchat-message-snippet/client/page/snippetPage.html b/app/message-snippet/client/page/snippetPage.html similarity index 100% rename from packages/rocketchat-message-snippet/client/page/snippetPage.html rename to app/message-snippet/client/page/snippetPage.html diff --git a/packages/rocketchat-message-snippet/client/page/snippetPage.js b/app/message-snippet/client/page/snippetPage.js similarity index 80% rename from packages/rocketchat-message-snippet/client/page/snippetPage.js rename to app/message-snippet/client/page/snippetPage.js index a985b453be8f5..208b8e5b38cf2 100644 --- a/packages/rocketchat-message-snippet/client/page/snippetPage.js +++ b/app/message-snippet/client/page/snippetPage.js @@ -1,11 +1,13 @@ import { Meteor } from 'meteor/meteor'; -import { DateFormat } from 'meteor/rocketchat:lib'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { Template } from 'meteor/templating'; -import { RocketChat } from 'meteor/rocketchat:lib'; -import { SnippetedMessages } from '../lib/collections'; import moment from 'moment'; +import { DateFormat } from '../../../lib'; +import { settings } from '../../../settings'; +import { Markdown } from '../../../markdown/client'; +import { SnippetedMessages } from '../lib/collections'; + Template.snippetPage.helpers({ snippet() { return SnippetedMessages.findOne({ _id: FlowRouter.getParam('snippetId') }); @@ -16,13 +18,13 @@ Template.snippetPage.helpers({ return null; } message.html = message.msg; - const markdown = RocketChat.Markdown.parse(message); + const markdown = Markdown.parse(message); return markdown.tokens[0].text; }, date() { const snippet = SnippetedMessages.findOne({ _id: FlowRouter.getParam('snippetId') }); if (snippet !== undefined) { - return moment(snippet.ts).format(RocketChat.settings.get('Message_DateFormat')); + return moment(snippet.ts).format(settings.get('Message_DateFormat')); } }, time() { diff --git a/packages/rocketchat-message-snippet/client/page/stylesheets/snippetPage.css b/app/message-snippet/client/page/stylesheets/snippetPage.css similarity index 100% rename from packages/rocketchat-message-snippet/client/page/stylesheets/snippetPage.css rename to app/message-snippet/client/page/stylesheets/snippetPage.css diff --git a/app/message-snippet/client/router.js b/app/message-snippet/client/router.js new file mode 100644 index 0000000000000..a93b559308827 --- /dev/null +++ b/app/message-snippet/client/router.js @@ -0,0 +1,19 @@ +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { BlazeLayout } from 'meteor/kadira:blaze-layout'; + +import { TabBar } from '../../ui-utils'; + +FlowRouter.route('/snippet/:snippetId/:snippetName', { + name: 'snippetView', + action() { + BlazeLayout.render('main', { center: 'snippetPage', flexTabBar: null }); + }, + triggersEnter: [function() { + TabBar.hide(); + }], + triggersExit: [ + function() { + TabBar.show(); + }, + ], +}); diff --git a/app/message-snippet/client/snippetMessage.js b/app/message-snippet/client/snippetMessage.js new file mode 100644 index 0000000000000..6a7f0a10702aa --- /dev/null +++ b/app/message-snippet/client/snippetMessage.js @@ -0,0 +1,30 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../settings'; +import { ChatMessage, Subscriptions } from '../../models'; + +Meteor.methods({ + snippetMessage(message) { + if (typeof Meteor.userId() === 'undefined' || Meteor.userId() === null) { + return false; + } + if ((typeof settings.get('Message_AllowSnippeting') === 'undefined') + || (settings.get('Message_AllowSnippeting') === null) + || (settings.get('Message_AllowSnippeting') === false)) { + return false; + } + + const subscription = Subscriptions.findOne({ rid: message.rid, 'u._id': Meteor.userId() }); + + if (subscription === undefined) { + return false; + } + ChatMessage.update({ + _id: message._id, + }, { + $set: { + snippeted: true, + }, + }); + }, +}); diff --git a/app/message-snippet/client/tabBar/tabBar.js b/app/message-snippet/client/tabBar/tabBar.js new file mode 100644 index 0000000000000..465518bd42442 --- /dev/null +++ b/app/message-snippet/client/tabBar/tabBar.js @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; + +import { settings } from '../../../settings'; +import { TabBar } from '../../../ui-utils'; + +Meteor.startup(function() { + Tracker.autorun(function() { + if (settings.get('Message_AllowSnippeting')) { + TabBar.addButton({ + groups: ['channel', 'group', 'direct'], + id: 'snippeted-messages', + i18nTitle: 'snippet-message', + icon: 'code', + template: 'snippetedMessages', + order: 20, + }); + } else { + TabBar.removeButton('snippeted-messages'); + } + }); +}); diff --git a/app/message-snippet/client/tabBar/views/snippetedMessages.html b/app/message-snippet/client/tabBar/views/snippetedMessages.html new file mode 100644 index 0000000000000..539dabf6e0f7f --- /dev/null +++ b/app/message-snippet/client/tabBar/views/snippetedMessages.html @@ -0,0 +1,24 @@ + diff --git a/app/message-snippet/client/tabBar/views/snippetedMessages.js b/app/message-snippet/client/tabBar/views/snippetedMessages.js new file mode 100644 index 0000000000000..a463f8f237d61 --- /dev/null +++ b/app/message-snippet/client/tabBar/views/snippetedMessages.js @@ -0,0 +1,37 @@ +import _ from 'underscore'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { Template } from 'meteor/templating'; + +import { SnippetedMessages } from '../../lib/collections'; +import { messageContext } from '../../../../ui-utils/client/lib/messageContext'; + +Template.snippetedMessages.helpers({ + hasMessages() { + return Template.instance().cursor.count() > 0; + }, + messages() { + return Template.instance().cursor; + }, + message() { + return _.extend(this, { customClass: 'snippeted', actionContext: 'snippeted' }); + }, + hasMore() { + return Template.instance().hasMore.get(); + }, + messageContext, +}); + +Template.snippetedMessages.onCreated(function() { + this.rid = this.data.rid; + this.cursor = SnippetedMessages.find({ snippeted: true, rid: this.data.rid }, { sort: { ts: -1 } }); + this.hasMore = new ReactiveVar(true); + this.limit = new ReactiveVar(50); + this.autorun(() => { + const data = Template.currentData(); + this.subscribe('snippetedMessages', data.rid, this.limit.get(), function() { + if (this.cursor.count() < this.limit.get()) { + return this.hasMore.set(false); + } + }); + }); +}); diff --git a/app/message-snippet/server/index.js b/app/message-snippet/server/index.js new file mode 100644 index 0000000000000..4e46076a914f8 --- /dev/null +++ b/app/message-snippet/server/index.js @@ -0,0 +1,5 @@ +import './startup/settings'; +import './methods/snippetMessage'; +import './requests'; +import './publications/snippetedMessagesByRoom'; +import './publications/snippetedMessage'; diff --git a/app/message-snippet/server/methods/snippetMessage.js b/app/message-snippet/server/methods/snippetMessage.js new file mode 100644 index 0000000000000..f33d59cfe687d --- /dev/null +++ b/app/message-snippet/server/methods/snippetMessage.js @@ -0,0 +1,54 @@ +import { Meteor } from 'meteor/meteor'; + +import { Subscriptions, Messages, Users, Rooms } from '../../../models'; +import { settings } from '../../../settings'; +import { callbacks } from '../../../callbacks'; +import { isTheLastMessage } from '../../../lib'; + +Meteor.methods({ + snippetMessage(message, filename) { + if (Meteor.userId() == null) { + // noinspection JSUnresolvedFunction + throw new Meteor.Error('error-invalid-user', 'Invalid user', + { method: 'snippetMessage' }); + } + + const room = Rooms.findOne({ _id: message.rid }); + + if ((typeof room === 'undefined') || (room === null)) { + return false; + } + + const subscription = Subscriptions.findOneByRoomIdAndUserId(message.rid, Meteor.userId(), { fields: { _id: 1 } }); + if (!subscription) { + return false; + } + + const me = Users.findOneById(Meteor.userId()); + + // If we keep history of edits, insert a new message to store history information + if (settings.get('Message_KeepHistory')) { + Messages.cloneAndSaveAsHistoryById(message._id, me); + } + + message.snippeted = true; + message.snippetedAt = Date.now; + message.snippetedBy = { + _id: Meteor.userId(), + username: me.username, + }; + + message = callbacks.run('beforeSaveMessage', message); + + // Create the SnippetMessage + Messages.setSnippetedByIdAndUserId(message, filename, message.snippetedBy, + message.snippeted, Date.now, filename); + if (isTheLastMessage(room, message)) { + Rooms.setLastMessageSnippeted(room._id, message, filename, message.snippetedBy, + message.snippeted, Date.now, filename); + } + + Messages.createWithTypeRoomIdMessageAndUser( + 'message_snippeted', message.rid, '', me, { snippetId: message._id, snippetName: filename }); + }, +}); diff --git a/app/message-snippet/server/publications/snippetedMessage.js b/app/message-snippet/server/publications/snippetedMessage.js new file mode 100644 index 0000000000000..968d7e84401ed --- /dev/null +++ b/app/message-snippet/server/publications/snippetedMessage.js @@ -0,0 +1,55 @@ +import { Meteor } from 'meteor/meteor'; + +import { Messages, Users, Rooms } from '../../../models'; + +Meteor.publish('snippetedMessage', function(_id) { + if (typeof this.userId === 'undefined' || this.userId === null) { + return this.ready(); + } + + const snippet = Messages.findOne({ _id, snippeted: true }); + const user = Users.findOneById(this.userId); + const roomSnippetQuery = { + _id: snippet.rid, + usernames: { + $in: [ + user.username, + ], + }, + }; + + if (!Meteor.call('canAccessRoom', snippet.rid, this.userId)) { + return this.ready(); + } + + if (Rooms.findOne(roomSnippetQuery) === undefined) { + return this.ready(); + } + + const publication = this; + + + if (typeof user === 'undefined' || user === null) { + return this.ready(); + } + + const cursor = Messages.find( + { _id } + ).observeChanges({ + added(_id, record) { + publication.added('rocketchat_snippeted_message', _id, record); + }, + changed(_id, record) { + publication.changed('rocketchat_snippeted_message', _id, record); + }, + removed(_id) { + publication.removed('rocketchat_snippeted_message', _id); + }, + }); + + this.ready(); + + this.onStop = function() { + cursor.stop(); + }; +}); diff --git a/packages/rocketchat-message-snippet/server/publications/snippetedMessagesByRoom.js b/app/message-snippet/server/publications/snippetedMessagesByRoom.js similarity index 81% rename from packages/rocketchat-message-snippet/server/publications/snippetedMessagesByRoom.js rename to app/message-snippet/server/publications/snippetedMessagesByRoom.js index b7b6c5a769d3f..a9caf315de4ef 100644 --- a/packages/rocketchat-message-snippet/server/publications/snippetedMessagesByRoom.js +++ b/app/message-snippet/server/publications/snippetedMessagesByRoom.js @@ -1,5 +1,6 @@ import { Meteor } from 'meteor/meteor'; -import { RocketChat } from 'meteor/rocketchat:lib'; + +import { Users, Messages } from '../../../models'; Meteor.publish('snippetedMessages', function(rid, limit = 50) { if (typeof this.userId === 'undefined' || this.userId === null) { @@ -8,7 +9,7 @@ Meteor.publish('snippetedMessages', function(rid, limit = 50) { const publication = this; - const user = RocketChat.models.Users.findOneById(this.userId); + const user = Users.findOneById(this.userId); if (typeof user === 'undefined' || user === null) { return this.ready(); @@ -18,7 +19,7 @@ Meteor.publish('snippetedMessages', function(rid, limit = 50) { return this.ready(); } - const cursorHandle = RocketChat.models.Messages.findSnippetedByRoom( + const cursorHandle = Messages.findSnippetedByRoom( rid, { sort: { ts: -1 }, diff --git a/app/message-snippet/server/requests.js b/app/message-snippet/server/requests.js new file mode 100644 index 0000000000000..19cc7c47005d5 --- /dev/null +++ b/app/message-snippet/server/requests.js @@ -0,0 +1,65 @@ +import { WebApp } from 'meteor/webapp'; +import { Cookies } from 'meteor/ostrio:cookies'; + +import { Users, Rooms, Messages } from '../../models'; + +WebApp.connectHandlers.use('/snippet/download', function(req, res) { + let rawCookies; + let token; + let uid; + const cookie = new Cookies(); + + if (req.headers && req.headers.cookie !== null) { + rawCookies = req.headers.cookie; + } + + if (rawCookies !== null) { + uid = cookie.get('rc_uid', rawCookies); + } + + if (rawCookies !== null) { + token = cookie.get('rc_token', rawCookies); + } + + if (uid === null) { + uid = req.query.rc_uid; + token = req.query.rc_token; + } + + const user = Users.findOneByIdAndLoginToken(uid, token); + + if (!(uid && token && user)) { + res.writeHead(403); + res.end(); + return false; + } + const match = /^\/([^\/]+)\/(.*)/.exec(req.url); + + if (match[1]) { + const snippet = Messages.findOne( + { + _id: match[1], + snippeted: true, + } + ); + const room = Rooms.findOne({ _id: snippet.rid, usernames: { $in: [user.username] } }); + if (room === undefined) { + res.writeHead(403); + res.end(); + return false; + } + + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${ encodeURIComponent(snippet.snippetName) }`); + res.setHeader('Content-Type', 'application/octet-stream'); + + // Removing the ``` contained in the msg. + const snippetContent = snippet.msg.substr(3, snippet.msg.length - 6); + res.setHeader('Content-Length', snippetContent.length); + res.write(snippetContent); + res.end(); + return; + } + + res.writeHead(404); + res.end(); +}); diff --git a/app/message-snippet/server/startup/settings.js b/app/message-snippet/server/startup/settings.js new file mode 100644 index 0000000000000..04047eb56bfc5 --- /dev/null +++ b/app/message-snippet/server/startup/settings.js @@ -0,0 +1,17 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../settings'; +import { Permissions } from '../../../models'; + +Meteor.startup(function() { + settings.add('Message_AllowSnippeting', false, { + type: 'boolean', + public: true, + group: 'Message', + }); + Permissions.upsert('snippet-message', { + $setOnInsert: { + roles: ['owner', 'moderator', 'admin'], + }, + }); +}); diff --git a/app/message-star/client/actionButton.js b/app/message-star/client/actionButton.js new file mode 100644 index 0000000000000..a999f56eaf853 --- /dev/null +++ b/app/message-star/client/actionButton.js @@ -0,0 +1,106 @@ +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; +import { TAPi18n } from 'meteor/tap:i18n'; +import toastr from 'toastr'; + +import { handleError } from '../../utils'; +import { settings } from '../../settings'; +import { RoomHistoryManager, MessageAction } from '../../ui-utils'; +import { messageArgs } from '../../ui-utils/client/lib/messageArgs'; + +Meteor.startup(function() { + MessageAction.addButton({ + id: 'star-message', + icon: 'star', + label: 'Star', + context: ['starred', 'message', 'message-mobile', 'threads'], + action() { + const { msg: message } = messageArgs(this); + message.starred = Meteor.userId(); + Meteor.call('starMessage', message, function(error) { + if (error) { + return handleError(error); + } + }); + }, + condition({ msg: message, subscription, u }) { + if (subscription == null && settings.get('Message_AllowStarring')) { + return false; + } + + return !message.starred || !message.starred.find((star) => star._id === u._id); + }, + order: 9, + group: 'menu', + }); + + MessageAction.addButton({ + id: 'unstar-message', + icon: 'star', + label: 'Unstar_Message', + context: ['starred', 'message', 'message-mobile', 'threads'], + action() { + const { msg: message } = messageArgs(this); + message.starred = false; + Meteor.call('starMessage', message, function(error) { + if (error) { + handleError(error); + } + }); + }, + condition({ msg: message, subscription, u }) { + if (subscription == null && settings.get('Message_AllowStarring')) { + return false; + } + + return message.starred && message.starred.find((star) => star._id === u._id); + }, + order: 9, + group: 'menu', + }); + + MessageAction.addButton({ + id: 'jump-to-star-message', + icon: 'jump', + label: 'Jump_to_message', + context: ['starred'], + action() { + const { msg: message } = messageArgs(this); + if (window.matchMedia('(max-width: 500px)').matches) { + Template.instance().tabBar.close(); + } + RoomHistoryManager.getSurroundingMessages(message, 50); + }, + condition({ msg, subscription, u }) { + if (subscription == null || !settings.get('Message_AllowStarring')) { + return false; + } + + return msg.starred && msg.starred.find((star) => star._id === u._id); + }, + order: 100, + group: 'menu', + }); + + MessageAction.addButton({ + id: 'permalink-star', + icon: 'permalink', + label: 'Get_link', + classes: 'clipboard', + context: ['starred'], + async action(event) { + const { msg: message } = messageArgs(this); + $(event.currentTarget).attr('data-clipboard-text', await MessageAction.getPermaLink(message._id)); + toastr.success(TAPi18n.__('Copied')); + }, + condition({ msg, subscription, u }) { + if (subscription == null) { + return false; + } + + return msg.starred && msg.starred.find((star) => star._id === u._id); + }, + order: 101, + group: 'menu', + }); +}); diff --git a/app/message-star/client/index.js b/app/message-star/client/index.js new file mode 100644 index 0000000000000..80ae3d282bdce --- /dev/null +++ b/app/message-star/client/index.js @@ -0,0 +1,5 @@ +import './actionButton'; +import './starMessage'; +import './tabBar'; +import './views/starredMessages.html'; +import './views/starredMessages'; diff --git a/packages/rocketchat-message-star/client/lib/StarredMessage.js b/app/message-star/client/lib/StarredMessage.js similarity index 100% rename from packages/rocketchat-message-star/client/lib/StarredMessage.js rename to app/message-star/client/lib/StarredMessage.js diff --git a/app/message-star/client/starMessage.js b/app/message-star/client/starMessage.js new file mode 100644 index 0000000000000..03c52935f9119 --- /dev/null +++ b/app/message-star/client/starMessage.js @@ -0,0 +1,25 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../settings'; +import { ChatMessage, Subscriptions } from '../../models'; + +Meteor.methods({ + starMessage(message) { + if (!Meteor.userId()) { + return false; + } + if (Subscriptions.findOne({ rid: message.rid }) == null) { + return false; + } + if (!settings.get('Message_AllowStarring')) { + return false; + } + return ChatMessage.update({ + _id: message._id, + }, { + $set: { + starred: !!message.starred, + }, + }); + }, +}); diff --git a/app/message-star/client/tabBar.js b/app/message-star/client/tabBar.js new file mode 100644 index 0000000000000..dbf6af6c4b890 --- /dev/null +++ b/app/message-star/client/tabBar.js @@ -0,0 +1,14 @@ +import { Meteor } from 'meteor/meteor'; + +import { TabBar } from '../../ui-utils'; + +Meteor.startup(function() { + TabBar.addButton({ + groups: ['channel', 'group', 'direct'], + id: 'starred-messages', + i18nTitle: 'Starred_Messages', + icon: 'star', + template: 'starredMessages', + order: 3, + }); +}); diff --git a/app/message-star/client/views/starredMessages.html b/app/message-star/client/views/starredMessages.html new file mode 100644 index 0000000000000..89a8e8c7159dc --- /dev/null +++ b/app/message-star/client/views/starredMessages.html @@ -0,0 +1,21 @@ + diff --git a/app/message-star/client/views/starredMessages.js b/app/message-star/client/views/starredMessages.js new file mode 100644 index 0000000000000..75858a9fbc99a --- /dev/null +++ b/app/message-star/client/views/starredMessages.js @@ -0,0 +1,52 @@ +import _ from 'underscore'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { Template } from 'meteor/templating'; + +import { StarredMessage } from '../lib/StarredMessage'; +import { messageContext } from '../../../ui-utils/client/lib/messageContext'; + +Template.starredMessages.helpers({ + hasMessages() { + return Template.instance().cursor.count() > 0; + }, + messages() { + return Template.instance().cursor; + }, + message() { + return _.extend(this, { actionContext: 'starred' }); + }, + hasMore() { + return Template.instance().hasMore.get(); + }, + messageContext, +}); + +Template.starredMessages.onCreated(function() { + this.rid = this.data.rid; + + this.cursor = StarredMessage.find({ + rid: this.data.rid, + }, { + sort: { + ts: -1, + }, + }); + this.hasMore = new ReactiveVar(true); + this.limit = new ReactiveVar(50); + this.autorun(() => { + const sub = this.subscribe('starredMessages', this.data.rid, this.limit.get()); + if (sub.ready()) { + if (this.cursor.count() < this.limit.get()) { + return this.hasMore.set(false); + } + } + }); +}); + +Template.starredMessages.events({ + 'scroll .js-list': _.throttle(function(e, instance) { + if (e.target.scrollTop >= e.target.scrollHeight - e.target.clientHeight) { + return instance.limit.set(instance.limit.get() + 50); + } + }, 200), +}); diff --git a/packages/rocketchat-message-star/client/views/stylesheets/messagestar.css b/app/message-star/client/views/stylesheets/messagestar.css similarity index 100% rename from packages/rocketchat-message-star/client/views/stylesheets/messagestar.css rename to app/message-star/client/views/stylesheets/messagestar.css diff --git a/app/message-star/server/index.js b/app/message-star/server/index.js new file mode 100644 index 0000000000000..ceeedf7d8669e --- /dev/null +++ b/app/message-star/server/index.js @@ -0,0 +1,4 @@ +import './settings'; +import './starMessage'; +import './publications/starredMessages'; +import './startup/indexes'; diff --git a/app/message-star/server/publications/starredMessages.js b/app/message-star/server/publications/starredMessages.js new file mode 100644 index 0000000000000..f02411e1b10c1 --- /dev/null +++ b/app/message-star/server/publications/starredMessages.js @@ -0,0 +1,37 @@ +import { Meteor } from 'meteor/meteor'; + +import { Users, Messages } from '../../../models'; + +Meteor.publish('starredMessages', function(rid, limit = 50) { + if (!this.userId) { + return this.ready(); + } + const publication = this; + const user = Users.findOneById(this.userId); + if (!user) { + return this.ready(); + } + if (!Meteor.call('canAccessRoom', rid, this.userId)) { + return this.ready(); + } + const cursorHandle = Messages.findStarredByUserAtRoom(this.userId, rid, { + sort: { + ts: -1, + }, + limit, + }).observeChanges({ + added(_id, record) { + return publication.added('rocketchat_starred_message', _id, record); + }, + changed(_id, record) { + return publication.changed('rocketchat_starred_message', _id, record); + }, + removed(_id) { + return publication.removed('rocketchat_starred_message', _id); + }, + }); + this.ready(); + return this.onStop(function() { + return cursorHandle.stop(); + }); +}); diff --git a/app/message-star/server/settings.js b/app/message-star/server/settings.js new file mode 100644 index 0000000000000..a951d82fc2733 --- /dev/null +++ b/app/message-star/server/settings.js @@ -0,0 +1,11 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../settings'; + +Meteor.startup(function() { + return settings.add('Message_AllowStarring', true, { + type: 'boolean', + group: 'Message', + public: true, + }); +}); diff --git a/app/message-star/server/starMessage.js b/app/message-star/server/starMessage.js new file mode 100644 index 0000000000000..9ec8893d3280c --- /dev/null +++ b/app/message-star/server/starMessage.js @@ -0,0 +1,33 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../settings'; +import { isTheLastMessage } from '../../lib'; +import { Subscriptions, Rooms, Messages } from '../../models'; + +Meteor.methods({ + starMessage(message) { + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'starMessage', + }); + } + + if (!settings.get('Message_AllowStarring')) { + throw new Meteor.Error('error-action-not-allowed', 'Message starring not allowed', { + method: 'pinMessage', + action: 'Message_starring', + }); + } + + const subscription = Subscriptions.findOneByRoomIdAndUserId(message.rid, Meteor.userId(), { fields: { _id: 1 } }); + if (!subscription) { + return false; + } + const room = Meteor.call('canAccessRoom', message.rid, Meteor.userId()); + if (isTheLastMessage(room, message)) { + Rooms.updateLastMessageStar(room._id, Meteor.userId(), message.starred); + } + + return Messages.updateUserStarById(message._id, Meteor.userId(), message.starred); + }, +}); diff --git a/app/message-star/server/startup/indexes.js b/app/message-star/server/startup/indexes.js new file mode 100644 index 0000000000000..b4ade9b0dd052 --- /dev/null +++ b/app/message-star/server/startup/indexes.js @@ -0,0 +1,13 @@ +import { Meteor } from 'meteor/meteor'; + +import { Messages } from '../../../models'; + +Meteor.startup(function() { + return Meteor.defer(function() { + return Messages.tryEnsureIndex({ + 'starred._id': 1, + }, { + sparse: 1, + }); + }); +}); diff --git a/packages/meteor-accounts-saml/CHANGELOG.md b/app/meteor-accounts-saml/CHANGELOG.md similarity index 100% rename from packages/meteor-accounts-saml/CHANGELOG.md rename to app/meteor-accounts-saml/CHANGELOG.md diff --git a/packages/meteor-accounts-saml/README.md b/app/meteor-accounts-saml/README.md similarity index 100% rename from packages/meteor-accounts-saml/README.md rename to app/meteor-accounts-saml/README.md diff --git a/packages/meteor-accounts-saml/client/index.js b/app/meteor-accounts-saml/client/index.js similarity index 100% rename from packages/meteor-accounts-saml/client/index.js rename to app/meteor-accounts-saml/client/index.js diff --git a/app/meteor-accounts-saml/client/saml_client.js b/app/meteor-accounts-saml/client/saml_client.js new file mode 100644 index 0000000000000..6c95bb8a8f337 --- /dev/null +++ b/app/meteor-accounts-saml/client/saml_client.js @@ -0,0 +1,123 @@ +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; +import { Random } from 'meteor/random'; +import { ServiceConfiguration } from 'meteor/service-configuration'; + +if (!Accounts.saml) { + Accounts.saml = {}; +} + +// Override the standard logout behaviour. +// +// If we find a samlProvider, and we are using single +// logout we will initiate logout from rocketchat via saml. +// If not using single logout, we just do the standard logout. +// This can be overridden by a configured logout behaviour. +// +// TODO: This may need some work as it is not clear if we are really +// logging out of the idp when doing the standard logout. + +const MeteorLogout = Meteor.logout; +const logoutBehaviour = { + TERMINATE_SAML: 'SAML', + ONLY_RC: 'Local', +}; + +Meteor.logout = function(...args) { + const samlService = ServiceConfiguration.configurations.findOne({ service: 'saml' }); + if (samlService) { + const provider = samlService.clientConfig && samlService.clientConfig.provider; + if (provider) { + if (samlService.logoutBehaviour == null || samlService.logoutBehaviour === logoutBehaviour.TERMINATE_SAML) { + if (samlService.idpSLORedirectURL) { + console.info('SAML session terminated via SLO'); + return Meteor.logoutWithSaml({ provider }); + } + } + + if (samlService.logoutBehaviour === logoutBehaviour.ONLY_RC) { + console.info('SAML session not terminated, only the Rocket.Chat session is going to be killed'); + } + } + } + return MeteorLogout.apply(Meteor, args); +}; + +const openCenteredPopup = function(url, width, height) { + const screenX = typeof window.screenX !== 'undefined' ? window.screenX : window.screenLeft; + const screenY = typeof window.screenY !== 'undefined' ? window.screenY : window.screenTop; + const outerWidth = typeof window.outerWidth !== 'undefined' ? window.outerWidth : document.body.clientWidth; + const outerHeight = typeof window.outerHeight !== 'undefined' ? window.outerHeight : document.body.clientHeight - 22; + // XXX what is the 22? + + // Use `outerWidth - width` and `outerHeight - height` for help in + // positioning the popup centered relative to the current window + const left = screenX + (outerWidth - width) / 2; + const top = screenY + (outerHeight - height) / 2; + const features = `width=${ width },height=${ height + },left=${ left },top=${ top },scrollbars=yes`; + + const newwindow = window.open(url, 'Login', features); + if (newwindow.focus) { + newwindow.focus(); + } + + return newwindow; +}; + +Accounts.saml.initiateLogin = function(options, callback, dimensions) { + // default dimensions that worked well for facebook and google + const popup = openCenteredPopup( + Meteor.absoluteUrl(`_saml/authorize/${ options.provider }/${ options.credentialToken }`), (dimensions && dimensions.width) || 650, (dimensions && dimensions.height) || 500); + + const checkPopupOpen = setInterval(function() { + let popupClosed; + try { + // Fix for #328 - added a second test criteria (popup.closed === undefined) + // to humour this Android quirk: + // http://code.google.com/p/android/issues/detail?id=21061 + popupClosed = popup.closed || popup.closed === undefined; + } catch (e) { + // For some unknown reason, IE9 (and others?) sometimes (when + // the popup closes too quickly?) throws 'SCRIPT16386: No such + // interface supported' when trying to read 'popup.closed'. Try + // again in 100ms. + return; + } + + if (popupClosed) { + clearInterval(checkPopupOpen); + callback(options.credentialToken); + } + }, 100); +}; + + +Meteor.loginWithSaml = function(options, callback) { + options = options || {}; + const credentialToken = `id-${ Random.id() }`; + options.credentialToken = credentialToken; + + Accounts.saml.initiateLogin(options, function(/* error, result*/) { + Accounts.callLoginMethod({ + methodArguments: [{ + saml: true, + credentialToken, + }], + userCallback: callback, + }); + }); +}; + +Meteor.logoutWithSaml = function(options/* , callback*/) { + // Accounts.saml.idpInitiatedSLO(options, callback); + Meteor.call('samlLogout', options.provider, function(err, result) { + if (err || !result) { + MeteorLogout.apply(Meteor); + return; + } + // A nasty bounce: 'result' has the SAML LogoutRequest but we need a proper 302 to redirected from the server. + // window.location.replace(Meteor.absoluteUrl('_saml/sloRedirect/' + options.provider + '/?redirect='+result)); + window.location.replace(Meteor.absoluteUrl(`_saml/sloRedirect/${ options.provider }/?redirect=${ encodeURIComponent(result) }`)); + }); +}; diff --git a/packages/meteor-accounts-saml/server/index.js b/app/meteor-accounts-saml/server/index.js similarity index 100% rename from packages/meteor-accounts-saml/server/index.js rename to app/meteor-accounts-saml/server/index.js diff --git a/app/meteor-accounts-saml/server/saml_rocketchat.js b/app/meteor-accounts-saml/server/saml_rocketchat.js new file mode 100644 index 0000000000000..fdb9ad837f884 --- /dev/null +++ b/app/meteor-accounts-saml/server/saml_rocketchat.js @@ -0,0 +1,229 @@ +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; +import { ServiceConfiguration } from 'meteor/service-configuration'; + +import { Logger } from '../../logger'; +import { settings } from '../../settings'; + +const logger = new Logger('steffo:meteor-accounts-saml', { + methods: { + updated: { + type: 'info', + }, + }, +}); + +settings.addGroup('SAML'); + +Meteor.methods({ + addSamlService(name) { + settings.add(`SAML_Custom_${ name }`, false, { + type: 'boolean', + group: 'SAML', + section: name, + i18nLabel: 'Accounts_OAuth_Custom_Enable', + }); + settings.add(`SAML_Custom_${ name }_provider`, 'provider-name', { + type: 'string', + group: 'SAML', + section: name, + i18nLabel: 'SAML_Custom_Provider', + }); + settings.add(`SAML_Custom_${ name }_entry_point`, 'https://example.com/simplesaml/saml2/idp/SSOService.php', { + type: 'string', + group: 'SAML', + section: name, + i18nLabel: 'SAML_Custom_Entry_point', + }); + settings.add(`SAML_Custom_${ name }_idp_slo_redirect_url`, 'https://example.com/simplesaml/saml2/idp/SingleLogoutService.php', { + type: 'string', + group: 'SAML', + section: name, + i18nLabel: 'SAML_Custom_IDP_SLO_Redirect_URL', + }); + settings.add(`SAML_Custom_${ name }_issuer`, 'https://your-rocket-chat/_saml/metadata/provider-name', { + type: 'string', + group: 'SAML', + section: name, + i18nLabel: 'SAML_Custom_Issuer', + }); + settings.add(`SAML_Custom_${ name }_cert`, '', { + type: 'string', + group: 'SAML', + section: name, + i18nLabel: 'SAML_Custom_Cert', + multiline: true, + secret: true, + }); + settings.add(`SAML_Custom_${ name }_public_cert`, '', { + type: 'string', + group: 'SAML', + section: name, + multiline: true, + i18nLabel: 'SAML_Custom_Public_Cert', + }); + settings.add(`SAML_Custom_${ name }_private_key`, '', { + type: 'string', + group: 'SAML', + section: name, + multiline: true, + i18nLabel: 'SAML_Custom_Private_Key', + secret: true, + }); + settings.add(`SAML_Custom_${ name }_button_label_text`, '', { + type: 'string', + group: 'SAML', + section: name, + i18nLabel: 'Accounts_OAuth_Custom_Button_Label_Text', + }); + settings.add(`SAML_Custom_${ name }_button_label_color`, '#FFFFFF', { + type: 'string', + group: 'SAML', + section: name, + i18nLabel: 'Accounts_OAuth_Custom_Button_Label_Color', + }); + settings.add(`SAML_Custom_${ name }_button_color`, '#1d74f5', { + type: 'string', + group: 'SAML', + section: name, + i18nLabel: 'Accounts_OAuth_Custom_Button_Color', + }); + settings.add(`SAML_Custom_${ name }_generate_username`, false, { + type: 'boolean', + group: 'SAML', + section: name, + i18nLabel: 'SAML_Custom_Generate_Username', + }); + settings.add(`SAML_Custom_${ name }_debug`, false, { + type: 'boolean', + group: 'SAML', + section: name, + i18nLabel: 'SAML_Custom_Debug', + }); + settings.add(`SAML_Custom_${ name }_name_overwrite`, false, { + type: 'boolean', + group: 'SAML', + section: name, + i18nLabel: 'SAML_Custom_name_overwrite', + }); + settings.add(`SAML_Custom_${ name }_mail_overwrite`, false, { + type: 'boolean', + group: 'SAML', + section: name, + i18nLabel: 'SAML_Custom_mail_overwrite', + }); + settings.add(`SAML_Custom_${ name }_logout_behaviour`, 'SAML', { + type: 'select', + values: [ + { key: 'SAML', i18nLabel: 'SAML_Custom_Logout_Behaviour_Terminate_SAML_Session' }, + { key: 'Local', i18nLabel: 'SAML_Custom_Logout_Behaviour_End_Only_RocketChat' }, + ], + group: 'SAML', + section: name, + i18nLabel: 'SAML_Custom_Logout_Behaviour', + }); + }, +}); + +const normalizeCert = function(cert) { + if (typeof cert === 'string') { + return cert.replace('-----BEGIN CERTIFICATE-----', '').replace('-----END CERTIFICATE-----', '').trim(); + } + + return cert; +}; + +const getSamlConfigs = function(service) { + return { + buttonLabelText: settings.get(`${ service.key }_button_label_text`), + buttonLabelColor: settings.get(`${ service.key }_button_label_color`), + buttonColor: settings.get(`${ service.key }_button_color`), + clientConfig: { + provider: settings.get(`${ service.key }_provider`), + }, + entryPoint: settings.get(`${ service.key }_entry_point`), + idpSLORedirectURL: settings.get(`${ service.key }_idp_slo_redirect_url`), + generateUsername: settings.get(`${ service.key }_generate_username`), + debug: settings.get(`${ service.key }_debug`), + nameOverwrite: settings.get(`${ service.key }_name_overwrite`), + mailOverwrite: settings.get(`${ service.key }_mail_overwrite`), + issuer: settings.get(`${ service.key }_issuer`), + logoutBehaviour: settings.get(`${ service.key }_logout_behaviour`), + secret: { + privateKey: settings.get(`${ service.key }_private_key`), + publicCert: settings.get(`${ service.key }_public_cert`), + // People often overlook the instruction to remove the header and footer of the certificate on this specific setting, so let's do it for them. + cert: normalizeCert(settings.get(`${ service.key }_cert`)), + }, + }; +}; + +const debounce = (fn, delay) => { + let timer = null; + return () => { + if (timer != null) { + Meteor.clearTimeout(timer); + } + timer = Meteor.setTimeout(fn, delay); + return timer; + }; +}; +const serviceName = 'saml'; + +const configureSamlService = function(samlConfigs) { + let privateCert = false; + let privateKey = false; + if (samlConfigs.secret.privateKey && samlConfigs.secret.publicCert) { + privateKey = samlConfigs.secret.privateKey; + privateCert = samlConfigs.secret.publicCert; + } else if (samlConfigs.secret.privateKey || samlConfigs.secret.publicCert) { + logger.error('You must specify both cert and key files.'); + } + // TODO: the function configureSamlService is called many times and Accounts.saml.settings.generateUsername keeps just the last value + Accounts.saml.settings.generateUsername = samlConfigs.generateUsername; + Accounts.saml.settings.nameOverwrite = samlConfigs.nameOverwrite; + Accounts.saml.settings.mailOverwrite = samlConfigs.mailOverwrite; + Accounts.saml.settings.debug = samlConfigs.debug; + + return { + provider: samlConfigs.clientConfig.provider, + entryPoint: samlConfigs.entryPoint, + idpSLORedirectURL: samlConfigs.idpSLORedirectURL, + issuer: samlConfigs.issuer, + cert: samlConfigs.secret.cert, + privateCert, + privateKey, + }; +}; + +const updateServices = debounce(() => { + const services = settings.get(/^(SAML_Custom_)[a-z]+$/i); + Accounts.saml.settings.providers = services.map((service) => { + if (service.value === true) { + const samlConfigs = getSamlConfigs(service); + logger.updated(service.key); + ServiceConfiguration.configurations.upsert({ + service: serviceName.toLowerCase(), + }, { + $set: samlConfigs, + }); + return configureSamlService(samlConfigs); + } + return ServiceConfiguration.configurations.remove({ + service: serviceName.toLowerCase(), + }); + }).filter((e) => e); +}, 2000); + + +settings.get(/^SAML_.+/, updateServices); + +Meteor.startup(() => Meteor.call('addSamlService', 'Default')); + +export { + updateServices, + configureSamlService, + getSamlConfigs, + debounce, + logger, +}; diff --git a/app/meteor-accounts-saml/server/saml_server.js b/app/meteor-accounts-saml/server/saml_server.js new file mode 100644 index 0000000000000..f212f5afb5a25 --- /dev/null +++ b/app/meteor-accounts-saml/server/saml_server.js @@ -0,0 +1,487 @@ +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; +import { Random } from 'meteor/random'; +import { WebApp } from 'meteor/webapp'; +import { RoutePolicy } from 'meteor/routepolicy'; +import bodyParser from 'body-parser'; +import fiber from 'fibers'; +import _ from 'underscore'; + +import { SAML } from './saml_utils'; +import { CredentialTokens } from '../../models'; +import { generateUsernameSuggestion } from '../../lib'; + +if (!Accounts.saml) { + Accounts.saml = { + settings: { + debug: false, + generateUsername: false, + nameOverwrite: false, + mailOverwrite: false, + providers: [], + }, + }; +} + +RoutePolicy.declare('/_saml/', 'network'); + +/** + * Fetch SAML provider configs for given 'provider'. + */ +function getSamlProviderConfig(provider) { + if (! provider) { + throw new Meteor.Error('no-saml-provider', + 'SAML internal error', + { method: 'getSamlProviderConfig' }); + } + const samlProvider = function(element) { + return element.provider === provider; + }; + return Accounts.saml.settings.providers.filter(samlProvider)[0]; +} + +Meteor.methods({ + samlLogout(provider) { + // Make sure the user is logged in before initiate SAML SLO + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'samlLogout' }); + } + const providerConfig = getSamlProviderConfig(provider); + + if (Accounts.saml.settings.debug) { + console.log(`Logout request from ${ JSON.stringify(providerConfig) }`); + } + // This query should respect upcoming array of SAML logins + const user = Meteor.users.findOne({ + _id: Meteor.userId(), + 'services.saml.provider': provider, + }, { + 'services.saml': 1, + }); + let { nameID } = user.services.saml; + const sessionIndex = user.services.saml.idpSession; + nameID = sessionIndex; + if (Accounts.saml.settings.debug) { + console.log(`NameID for user ${ Meteor.userId() } found: ${ JSON.stringify(nameID) }`); + } + + const _saml = new SAML(providerConfig); + + const request = _saml.generateLogoutRequest({ + nameID, + sessionIndex, + }); + + // request.request: actual XML SAML Request + // request.id: comminucation id which will be mentioned in the ResponseTo field of SAMLResponse + + Meteor.users.update({ + _id: Meteor.userId(), + }, { + $set: { + 'services.saml.inResponseTo': request.id, + }, + }); + + const _syncRequestToUrl = Meteor.wrapAsync(_saml.requestToUrl, _saml); + const result = _syncRequestToUrl(request.request, 'logout'); + if (Accounts.saml.settings.debug) { + console.log(`SAML Logout Request ${ result }`); + } + + + return result; + }, +}); + +Accounts.registerLoginHandler(function(loginRequest) { + if (!loginRequest.saml || !loginRequest.credentialToken) { + return undefined; + } + + const loginResult = Accounts.saml.retrieveCredential(loginRequest.credentialToken); + if (Accounts.saml.settings.debug) { + console.log(`RESULT :${ JSON.stringify(loginResult) }`); + } + + if (loginResult === undefined) { + return { + type: 'saml', + error: new Meteor.Error(Accounts.LoginCancelledError.numericError, 'No matching login attempt found'), + }; + } + + if (loginResult && loginResult.profile && loginResult.profile.email) { + const emailList = Array.isArray(loginResult.profile.email) ? loginResult.profile.email : [loginResult.profile.email]; + const emailRegex = new RegExp(emailList.map((email) => `^${ RegExp.escape(email) }$`).join('|'), 'i'); + + const eduPersonPrincipalName = loginResult.profile.eppn; + const fullName = loginResult.profile.cn || loginResult.profile.username || loginResult.profile.displayName; + + let eppnMatch = false; + + // Check eppn + let user = Meteor.users.findOne({ + eppn: eduPersonPrincipalName, + }); + + if (user) { + eppnMatch = true; + } + + // If eppn is not exist + if (!user) { + user = Meteor.users.findOne({ + 'emails.address': emailRegex, + }); + } + + if (!user) { + const newUser = { + name: fullName, + active: true, + eppn: eduPersonPrincipalName, + globalRoles: ['user'], + emails: emailList.map((email) => ({ + address: email, + verified: true, + })), + }; + + if (Accounts.saml.settings.generateUsername === true) { + const username = generateUsernameSuggestion(newUser); + if (username) { + newUser.username = username; + } + } else if (loginResult.profile.username) { + newUser.username = loginResult.profile.username; + } + + const userId = Accounts.insertUserDoc({}, newUser); + user = Meteor.users.findOne(userId); + } + + // If eppn is not exist then update + if (eppnMatch === false) { + Meteor.users.update({ + _id: user._id, + }, { + $set: { + eppn: eduPersonPrincipalName, + }, + }); + } + + // creating the token and adding to the user + const stampedToken = Accounts._generateStampedLoginToken(); + Meteor.users.update(user, { + $push: { + 'services.resume.loginTokens': stampedToken, + }, + }); + + const samlLogin = { + provider: Accounts.saml.RelayState, + idp: loginResult.profile.issuer, + idpSession: loginResult.profile.sessionIndex, + nameID: loginResult.profile.nameID, + }; + + Meteor.users.update({ + _id: user._id, + }, { + $set: { + // TBD this should be pushed, otherwise we're only able to SSO into a single IDP at a time + 'services.saml': samlLogin, + }, + }); + + // Overwrite fullname if needed + if (Accounts.saml.settings.nameOverwrite === true) { + Meteor.users.update({ + _id: user._id, + }, { + $set: { + name: fullName, + }, + }); + } + + // Overwrite mail if needed + if (Accounts.saml.settings.mailOverwrite === true && eppnMatch === true) { + Meteor.users.update({ + _id: user._id, + }, { + $set: { + emails: emailList.map((email) => ({ + address: email, + verified: true, + })), + }, + }); + } + + // sending token along with the userId + const result = { + userId: user._id, + token: stampedToken.token, + }; + + return result; + } + throw new Error('SAML Profile did not contain an email address'); +}); + +Accounts.saml.hasCredential = function(credentialToken) { + return CredentialTokens.findOneById(credentialToken) != null; +}; + +Accounts.saml.retrieveCredential = function(credentialToken) { + // The credentialToken in all these functions corresponds to SAMLs inResponseTo field and is mandatory to check. + const data = CredentialTokens.findOneById(credentialToken); + if (data) { + return data.userInfo; + } +}; + +Accounts.saml.storeCredential = function(credentialToken, loginResult) { + CredentialTokens.create(credentialToken, loginResult); +}; + +const closePopup = function(res, err) { + res.writeHead(200, { + 'Content-Type': 'text/html', + }); + let content = '

Verified

'; + if (err) { + content = `

Sorry, an annoying error occured

${ err }
Close Window`; + } + res.end(content, 'utf-8'); +}; + +const samlUrlToObject = function(url) { + // req.url will be '/_saml///' + if (!url) { + return null; + } + + const splitUrl = url.split('?'); + const splitPath = splitUrl[0].split('/'); + + // Any non-saml request will continue down the default + // middlewares. + if (splitPath[1] !== '_saml') { + return null; + } + + const result = { + actionName: splitPath[2], + serviceName: splitPath[3], + credentialToken: splitPath[4], + }; + if (Accounts.saml.settings.debug) { + console.log(result); + } + return result; +}; + +const logoutRemoveTokens = function(userId) { + if (Accounts.saml.settings.debug) { + console.log(`Found user ${ userId }`); + } + + Meteor.users.update({ + _id: userId, + }, { + $set: { + 'services.resume.loginTokens': [], + }, + }); + + Meteor.users.update({ + _id: userId, + }, { + $unset: { + 'services.saml': '', + }, + }); +}; + +const middleware = function(req, res, next) { + // Make sure to catch any exceptions because otherwise we'd crash + // the runner + try { + const samlObject = samlUrlToObject(req.url); + if (!samlObject || !samlObject.serviceName) { + next(); + return; + } + + if (!samlObject.actionName) { + throw new Error('Missing SAML action'); + } + + if (Accounts.saml.settings.debug) { + console.log(Accounts.saml.settings.providers); + console.log(samlObject.serviceName); + } + const service = _.find(Accounts.saml.settings.providers, function(samlSetting) { + return samlSetting.provider === samlObject.serviceName; + }); + + // Skip everything if there's no service set by the saml middleware + if (!service) { + throw new Error(`Unexpected SAML service ${ samlObject.serviceName }`); + } + let _saml; + switch (samlObject.actionName) { + case 'metadata': + _saml = new SAML(service); + service.callbackUrl = Meteor.absoluteUrl(`_saml/validate/${ service.provider }`); + res.writeHead(200); + res.write(_saml.generateServiceProviderMetadata(service.callbackUrl)); + res.end(); + // closePopup(res); + break; + case 'logout': + // This is where we receive SAML LogoutResponse + _saml = new SAML(service); + if (req.query.SAMLRequest) { + _saml.validateLogoutRequest(req.query.SAMLRequest, function(err, result) { + if (err) { + console.error(err); + throw new Meteor.Error('Unable to Validate Logout Request'); + } + + const logOutUser = function(samlInfo) { + const loggedOutUser = Meteor.users.find({ + $or: [ + { 'services.saml.nameID': samlInfo.nameID }, + { 'services.saml.idpSession': samlInfo.idpSession }, + ], + }).fetch(); + + if (loggedOutUser.length === 1) { + logoutRemoveTokens(loggedOutUser[0]._id); + } + }; + + fiber(function() { + logOutUser(result); + }).run(); + + const { response } = _saml.generateLogoutResponse({ + nameID: result.nameID, + sessionIndex: result.idpSession, + }); + + _saml.logoutResponseToUrl(response, function(err, url) { + if (err) { + console.error(err); + throw new Meteor.Error('Unable to generate SAML logout Response Url'); + } + + res.writeHead(302, { + Location: url, + }); + res.end(); + }); + }); + } else { + _saml.validateLogoutResponse(req.query.SAMLResponse, function(err, result) { + if (!err) { + const logOutUser = function(inResponseTo) { + if (Accounts.saml.settings.debug) { + console.log(`Logging Out user via inResponseTo ${ inResponseTo }`); + } + const loggedOutUser = Meteor.users.find({ + 'services.saml.inResponseTo': inResponseTo, + }).fetch(); + if (loggedOutUser.length === 1) { + logoutRemoveTokens(loggedOutUser[0]._id); + } else { + throw new Meteor.Error('Found multiple users matching SAML inResponseTo fields'); + } + }; + + fiber(function() { + logOutUser(result); + }).run(); + + + res.writeHead(302, { + Location: req.query.RelayState, + }); + res.end(); + } + // else { + // // TBD thinking of sth meaning full. + // } + }); + } + break; + case 'sloRedirect': + res.writeHead(302, { + // credentialToken here is the SAML LogOut Request that we'll send back to IDP + Location: req.query.redirect, + }); + res.end(); + break; + case 'authorize': + service.callbackUrl = Meteor.absoluteUrl(`_saml/validate/${ service.provider }`); + service.id = samlObject.credentialToken; + _saml = new SAML(service); + _saml.getAuthorizeUrl(req, function(err, url) { + if (err) { + throw new Error('Unable to generate authorize url'); + } + res.writeHead(302, { + Location: url, + }); + res.end(); + }); + break; + case 'validate': + _saml = new SAML(service); + Accounts.saml.RelayState = req.body.RelayState; + _saml.validateResponse(req.body.SAMLResponse, req.body.RelayState, function(err, profile/* , loggedOut*/) { + if (err) { + throw new Error(`Unable to validate response url: ${ err }`); + } + + const credentialToken = (profile.inResponseToId && profile.inResponseToId.value) || profile.inResponseToId || profile.InResponseTo || samlObject.credentialToken; + const loginResult = { + profile, + }; + if (!credentialToken) { + // No credentialToken in IdP-initiated SSO + const saml_idp_credentialToken = Random.id(); + Accounts.saml.storeCredential(saml_idp_credentialToken, loginResult); + + const url = `${ Meteor.absoluteUrl('home') }?saml_idp_credentialToken=${ saml_idp_credentialToken }`; + res.writeHead(302, { + Location: url, + }); + res.end(); + } else { + Accounts.saml.storeCredential(credentialToken, loginResult); + closePopup(res); + } + }); + break; + default: + throw new Error(`Unexpected SAML action ${ samlObject.actionName }`); + } + } catch (err) { + closePopup(res, err); + } +}; + +// Listen to incoming SAML http requests +WebApp.connectHandlers.use(bodyParser.json()).use(function(req, res, next) { + // Need to create a fiber since we're using synchronous http calls and nothing + // else is wrapping this in a fiber automatically + fiber(function() { + middleware(req, res, next); + }).run(); +}); diff --git a/app/meteor-accounts-saml/server/saml_utils.js b/app/meteor-accounts-saml/server/saml_utils.js new file mode 100644 index 0000000000000..b910ff6c15d5e --- /dev/null +++ b/app/meteor-accounts-saml/server/saml_utils.js @@ -0,0 +1,657 @@ +import zlib from 'zlib'; +import crypto from 'crypto'; +import querystring from 'querystring'; + +import { Meteor } from 'meteor/meteor'; +import xmlCrypto from 'xml-crypto'; +import xmldom from 'xmldom'; +import xmlbuilder from 'xmlbuilder'; +import array2string from 'arraybuffer-to-string'; +import xmlenc from 'xml-encryption'; +// var prefixMatch = new RegExp(/(?!xmlns)^.*:/); + + +export const SAML = function(options) { + this.options = this.initialize(options); +}; + +function debugLog(...args) { + if (Meteor.settings.debug) { + console.log.apply(this, args); + } +} + +// var stripPrefix = function(str) { +// return str.replace(prefixMatch, ''); +// }; + +SAML.prototype.initialize = function(options) { + if (!options) { + options = {}; + } + + if (!options.protocol) { + options.protocol = 'https://'; + } + + if (!options.path) { + options.path = '/saml/consume'; + } + + if (!options.issuer) { + options.issuer = 'onelogin_saml'; + } + + if (options.identifierFormat === undefined) { + options.identifierFormat = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'; + } + + if (options.authnContext === undefined) { + options.authnContext = 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'; + } + + return options; +}; + +SAML.prototype.generateUniqueID = function() { + const chars = 'abcdef0123456789'; + let uniqueID = 'id-'; + for (let i = 0; i < 20; i++) { + uniqueID += chars.substr(Math.floor(Math.random() * 15), 1); + } + return uniqueID; +}; + +SAML.prototype.generateInstant = function() { + return new Date().toISOString(); +}; + +SAML.prototype.signRequest = function(xml) { + const signer = crypto.createSign('RSA-SHA1'); + signer.update(xml); + return signer.sign(this.options.privateKey, 'base64'); +}; + +SAML.prototype.generateAuthorizeRequest = function(req) { + let id = `_${ this.generateUniqueID() }`; + const instant = this.generateInstant(); + + // Post-auth destination + let callbackUrl; + if (this.options.callbackUrl) { + callbackUrl = this.options.callbackUrl; + } else { + callbackUrl = this.options.protocol + req.headers.host + this.options.path; + } + + if (this.options.id) { + id = this.options.id; + } + + let request = `` + + `${ this.options.issuer }\n`; + + if (this.options.identifierFormat) { + request += `\n`; + } + + request + += '' + + 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport\n' + + ''; + + return request; +}; + +SAML.prototype.generateLogoutResponse = function() { + const id = `_${ this.generateUniqueID() }`; + const instant = this.generateInstant(); + + + const response = `${ '' + + `${ this.options.issuer }` + + '' + + ''; + + debugLog('------- SAML Logout response -----------'); + debugLog(response); + + return { + response, + id, + }; +}; + +SAML.prototype.generateLogoutRequest = function(options) { + // options should be of the form + // nameId: + // sessionIndex: sessionIndex + // --- NO SAMLsettings: ' + + `${ this.options.issuer }` + + '${ + options.nameID }` + + `${ options.sessionIndex }` + + ''; + + debugLog('------- SAML Logout request -----------'); + debugLog(request); + + return { + request, + id, + }; +}; + +SAML.prototype.logoutResponseToUrl = function(response, callback) { + const self = this; + + zlib.deflateRaw(response, function(err, buffer) { + if (err) { + return callback(err); + } + + const base64 = buffer.toString('base64'); + let target = self.options.idpSLORedirectURL; + + if (target.indexOf('?') > 0) { + target += '&'; + } else { + target += '?'; + } + + // TBD. We should really include a proper RelayState here + const relayState = Meteor.absoluteUrl(); + + const samlResponse = { + SAMLResponse: base64, + RelayState: relayState, + }; + + if (self.options.privateCert) { + samlResponse.SigAlg = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'; + samlResponse.Signature = self.signRequest(querystring.stringify(samlResponse)); + } + + target += querystring.stringify(samlResponse); + + return callback(null, target); + }); +}; + +SAML.prototype.requestToUrl = function(request, operation, callback) { + const self = this; + zlib.deflateRaw(request, function(err, buffer) { + if (err) { + return callback(err); + } + + const base64 = buffer.toString('base64'); + let target = self.options.entryPoint; + + if (operation === 'logout') { + if (self.options.idpSLORedirectURL) { + target = self.options.idpSLORedirectURL; + } + } + + if (target.indexOf('?') > 0) { + target += '&'; + } else { + target += '?'; + } + + // TBD. We should really include a proper RelayState here + let relayState; + if (operation === 'logout') { + // in case of logout we want to be redirected back to the Meteor app. + relayState = Meteor.absoluteUrl(); + } else { + relayState = self.options.provider; + } + + const samlRequest = { + SAMLRequest: base64, + RelayState: relayState, + }; + + if (self.options.privateCert) { + samlRequest.SigAlg = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'; + samlRequest.Signature = self.signRequest(querystring.stringify(samlRequest)); + } + + target += querystring.stringify(samlRequest); + + debugLog(`requestToUrl: ${ target }`); + + if (operation === 'logout') { + // in case of logout we want to be redirected back to the Meteor app. + return callback(null, target); + } + callback(null, target); + }); +}; + +SAML.prototype.getAuthorizeUrl = function(req, callback) { + const request = this.generateAuthorizeRequest(req); + + this.requestToUrl(request, 'authorize', callback); +}; + +SAML.prototype.getLogoutUrl = function(req, callback) { + const request = this.generateLogoutRequest(req); + + this.requestToUrl(request, 'logout', callback); +}; + +SAML.prototype.certToPEM = function(cert) { + cert = cert.match(/.{1,64}/g).join('\n'); + cert = `-----BEGIN CERTIFICATE-----\n${ cert }`; + cert = `${ cert }\n-----END CERTIFICATE-----\n`; + return cert; +}; + +// functionfindChilds(node, localName, namespace) { +// var res = []; +// for (var i = 0; i < node.childNodes.length; i++) { +// var child = node.childNodes[i]; +// if (child.localName === localName && (child.namespaceURI === namespace || !namespace)) { +// res.push(child); +// } +// } +// return res; +// } + +SAML.prototype.validateStatus = function(doc) { + let successStatus = false; + let status = ''; + let messageText = ''; + const statusNodes = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusCode'); + + if (statusNodes.length) { + const statusNode = statusNodes[0]; + const statusMessage = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage')[0]; + + if (statusMessage) { + messageText = statusMessage.firstChild.textContent; + } + + status = statusNode.getAttribute('Value'); + + if (status === 'urn:oasis:names:tc:SAML:2.0:status:Success') { + successStatus = true; + } + } + return { + success: successStatus, + message: messageText, + statusCode: status, + }; +}; + +SAML.prototype.validateSignature = function(xml, cert) { + const self = this; + + const doc = new xmldom.DOMParser().parseFromString(xml); + const signature = xmlCrypto.xpath(doc, '//*[local-name(.)=\'Signature\' and namespace-uri(.)=\'http://www.w3.org/2000/09/xmldsig#\']')[0]; + + const sig = new xmlCrypto.SignedXml(); + + sig.keyInfoProvider = { + getKeyInfo(/* key*/) { + return ''; + }, + getKey(/* keyInfo*/) { + return self.certToPEM(cert); + }, + }; + + sig.loadSignature(signature); + + return sig.checkSignature(xml); +}; + +SAML.prototype.validateLogoutRequest = function(samlRequest, callback) { + const compressedSAMLRequest = new Buffer(samlRequest, 'base64'); + zlib.inflateRaw(compressedSAMLRequest, function(err, decoded) { + if (err) { + debugLog(`Error while inflating. ${ err }`); + return callback(err, null); + } + + debugLog(`LogoutRequest: ${ decoded }`); + const doc = new xmldom.DOMParser().parseFromString(array2string(decoded), 'text/xml'); + if (!doc) { + return callback('No Doc Found'); + } + + const request = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'LogoutRequest')[0]; + if (!request) { + return callback('No Request Found'); + } + + try { + const sessionNode = request.getElementsByTagName('samlp:SessionIndex')[0]; + const nameIdNode = request.getElementsByTagName('saml:NameID')[0]; + + const idpSession = sessionNode.childNodes[0].nodeValue; + const nameID = nameIdNode.childNodes[0].nodeValue; + + return callback(null, { idpSession, nameID }); + } catch (e) { + debugLog(`Caught error: ${ e }`); + + const msg = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage'); + debugLog(`Unexpected msg from IDP. Does your session still exist at IDP? Idp returned: \n ${ msg }`); + + return callback(e, null); + } + }); +}; + +SAML.prototype.validateLogoutResponse = function(samlResponse, callback) { + const self = this; + const compressedSAMLResponse = new Buffer(samlResponse, 'base64'); + zlib.inflateRaw(compressedSAMLResponse, function(err, decoded) { + if (err) { + debugLog(`Error while inflating. ${ err }`); + return callback(err, null); + } + + debugLog(`LogoutResponse: ${ decoded }`); + const doc = new xmldom.DOMParser().parseFromString(array2string(decoded), 'text/xml'); + if (!doc) { + return callback('No Doc Found'); + } + + const response = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'LogoutResponse')[0]; + if (!response) { + return callback('No Response Found', null); + } + + // TBD. Check if this msg corresponds to one we sent + let inResponseTo; + try { + inResponseTo = response.getAttribute('InResponseTo'); + debugLog(`In Response to: ${ inResponseTo }`); + } catch (e) { + debugLog(`Caught error: ${ e }`); + const msg = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage'); + debugLog(`Unexpected msg from IDP. Does your session still exist at IDP? Idp returned: \n ${ msg }`); + } + + const statusValidateObj = self.validateStatus(doc); + if (!statusValidateObj.success) { + return callback('Error. Logout not confirmed by IDP', null); + } + return callback(null, inResponseTo); + }); +}; + +SAML.prototype.mapAttributes = function(attributeStatement, profile) { + debugLog(`Attribute Statement found in SAML response: ${ attributeStatement }`); + const attributes = attributeStatement.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Attribute'); + debugLog(`Attributes will be processed: ${ attributes.length }`); + + if (attributes) { + for (let i = 0; i < attributes.length; i++) { + const values = attributes[i].getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AttributeValue'); + let value; + if (values.length === 1) { + value = values[0].textContent; + } else { + value = []; + for (let j = 0; j < values.length; j++) { + value.push(values[j].textContent); + } + } + + const key = attributes[i].getAttribute('Name'); + + debugLog(`Name: ${ attributes[i] }`); + debugLog(`Adding attribute from SAML response to profile: ${ key } = ${ value }`); + profile[key] = value; + } + } else { + debugLog('No Attributes found in SAML attribute statement.'); + } + + if (!profile.mail && profile['urn:oid:0.9.2342.19200300.100.1.3']) { + // See http://www.incommonfederation.org/attributesummary.html for definition of attribute OIDs + profile.mail = profile['urn:oid:0.9.2342.19200300.100.1.3']; + } + + if (!profile.email && profile['urn:oid:1.2.840.113549.1.9.1']) { + profile.email = profile['urn:oid:1.2.840.113549.1.9.1']; + } + + if (!profile.displayName && profile['urn:oid:2.16.840.1.113730.3.1.241']) { + profile.displayName = profile['urn:oid:2.16.840.1.113730.3.1.241']; + } + + if (!profile.eppn && profile['urn:oid:1.3.6.1.4.1.5923.1.1.1.6']) { + profile.eppn = profile['urn:oid:1.3.6.1.4.1.5923.1.1.1.6']; + } + + if (!profile.email && profile.mail) { + profile.email = profile.mail; + } + + if (!profile.cn && profile['urn:oid:2.5.4.3']) { + profile.cn = profile['urn:oid:2.5.4.3']; + } +}; + +SAML.prototype.validateResponse = function(samlResponse, relayState, callback) { + const self = this; + const xml = new Buffer(samlResponse, 'base64').toString('utf8'); + // We currently use RelayState to save SAML provider + debugLog(`Validating response with relay state: ${ xml }`); + + const doc = new xmldom.DOMParser().parseFromString(xml, 'text/xml'); + if (!doc) { + return callback('No Doc Found'); + } + + debugLog('Verify status'); + const statusValidateObj = self.validateStatus(doc); + + if (!statusValidateObj.success) { + return callback(new Error(`Status is: ${ statusValidateObj.statusCode }`), null, false); + } + debugLog('Status ok'); + + // Verify signature + debugLog('Verify signature'); + if (self.options.cert && !self.validateSignature(xml, self.options.cert)) { + debugLog('Signature WRONG'); + return callback(new Error('Invalid signature'), null, false); + } + debugLog('Signature OK'); + + const response = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'Response')[0]; + if (!response) { + const logoutResponse = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'LogoutResponse'); + + if (!logoutResponse) { + return callback(new Error('Unknown SAML response message'), null, false); + } + return callback(null, null, true); + } + debugLog('Got response'); + + let assertion = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Assertion')[0]; + const encAssertion = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedAssertion')[0]; + + const options = { key: this.options.privateKey }; + + if (typeof encAssertion !== 'undefined') { + xmlenc.decrypt(encAssertion.getElementsByTagNameNS('*', 'EncryptedData')[0], options, function(err, result) { + assertion = new xmldom.DOMParser().parseFromString(result, 'text/xml'); + }); + } + + if (!assertion) { + return callback(new Error('Missing SAML assertion'), null, false); + } + + const profile = {}; + + if (response.hasAttribute('InResponseTo')) { + profile.inResponseToId = response.getAttribute('InResponseTo'); + } + + const issuer = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Issuer')[0]; + if (issuer) { + profile.issuer = issuer.textContent; + } + + let subject = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Subject')[0]; + const encSubject = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedID')[0]; + + if (typeof encSubject !== 'undefined') { + xmlenc.decrypt(encSubject.getElementsByTagNameNS('*', 'EncryptedData')[0], options, function(err, result) { + subject = new xmldom.DOMParser().parseFromString(result, 'text/xml'); + }); + } + + if (subject) { + const nameID = subject.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'NameID')[0]; + if (nameID) { + profile.nameID = nameID.textContent; + + if (nameID.hasAttribute('Format')) { + profile.nameIDFormat = nameID.getAttribute('Format'); + } + } + } + + const authnStatement = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AuthnStatement')[0]; + + if (authnStatement) { + if (authnStatement.hasAttribute('SessionIndex')) { + profile.sessionIndex = authnStatement.getAttribute('SessionIndex'); + debugLog(`Session Index: ${ profile.sessionIndex }`); + } else { + debugLog('No Session Index Found'); + } + } else { + debugLog('No AuthN Statement found'); + } + + const attributeStatement = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AttributeStatement')[0]; + if (attributeStatement) { + this.mapAttributes(attributeStatement, profile); + } else { + debugLog('No Attribute Statement found in SAML response.'); + } + + if (!profile.email && profile.nameID && profile.nameIDFormat && profile.nameIDFormat.indexOf('emailAddress') >= 0) { + profile.email = profile.nameID; + } + + const profileKeys = Object.keys(profile); + for (let i = 0; i < profileKeys.length; i++) { + const key = profileKeys[i]; + + if (key.match(/\./)) { + profile[key.replace(/\./g, '-')] = profile[key]; + delete profile[key]; + } + } + + debugLog(`NameID: ${ JSON.stringify(profile) }`); + return callback(null, profile, false); +}; + +let decryptionCert; +SAML.prototype.generateServiceProviderMetadata = function(callbackUrl) { + if (!decryptionCert) { + decryptionCert = this.options.privateCert; + } + + if (!this.options.callbackUrl && !callbackUrl) { + throw new Error( + 'Unable to generate service provider metadata when callbackUrl option is not set'); + } + + const metadata = { + EntityDescriptor: { + '@xmlns': 'urn:oasis:names:tc:SAML:2.0:metadata', + '@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#', + '@entityID': this.options.issuer, + SPSSODescriptor: { + '@protocolSupportEnumeration': 'urn:oasis:names:tc:SAML:2.0:protocol', + SingleLogoutService: { + '@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + '@Location': `${ Meteor.absoluteUrl() }_saml/logout/${ this.options.provider }/`, + '@ResponseLocation': `${ Meteor.absoluteUrl() }_saml/logout/${ this.options.provider }/`, + }, + NameIDFormat: this.options.identifierFormat, + AssertionConsumerService: { + '@index': '1', + '@isDefault': 'true', + '@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + '@Location': callbackUrl, + }, + }, + }, + }; + + if (this.options.privateKey) { + if (!decryptionCert) { + throw new Error( + 'Missing decryptionCert while generating metadata for decrypting service provider'); + } + + decryptionCert = decryptionCert.replace(/-+BEGIN CERTIFICATE-+\r?\n?/, ''); + decryptionCert = decryptionCert.replace(/-+END CERTIFICATE-+\r?\n?/, ''); + decryptionCert = decryptionCert.replace(/\r\n/g, '\n'); + + metadata.EntityDescriptor.SPSSODescriptor.KeyDescriptor = { + 'ds:KeyInfo': { + 'ds:X509Data': { + 'ds:X509Certificate': { + '#text': decryptionCert, + }, + }, + }, + EncryptionMethod: [ + // this should be the set that the xmlenc library supports + { + '@Algorithm': 'http://www.w3.org/2001/04/xmlenc#aes256-cbc', + }, + { + '@Algorithm': 'http://www.w3.org/2001/04/xmlenc#aes128-cbc', + }, + { + '@Algorithm': 'http://www.w3.org/2001/04/xmlenc#tripledes-cbc', + }, + ], + }; + } + + return xmlbuilder.create(metadata).end({ + pretty: true, + indent: ' ', + newline: '\n', + }); +}; diff --git a/app/metrics/index.js b/app/metrics/index.js new file mode 100644 index 0000000000000..ca39cd0df4b1a --- /dev/null +++ b/app/metrics/index.js @@ -0,0 +1 @@ +export * from './server/index'; diff --git a/app/metrics/server/callbacksMetrics.js b/app/metrics/server/callbacksMetrics.js new file mode 100644 index 0000000000000..8402f6fa9f8ef --- /dev/null +++ b/app/metrics/server/callbacksMetrics.js @@ -0,0 +1,31 @@ + +import { metrics } from './lib/metrics'; +import StatsTracker from './lib/statsTracker'; +import { callbacks } from '../../callbacks'; + +const { + run: originalRun, + runItem: originalRunItem, +} = callbacks; + +callbacks.run = function(hook, item, constant) { + const rocketchatHooksEnd = metrics.rocketchatHooks.startTimer({ hook, callbacks_length: callbacks.length }); + + const result = originalRun(hook, item, constant); + + rocketchatHooksEnd(); + + return result; +}; + +callbacks.runItem = function({ callback, result, constant, hook, time }) { + const rocketchatCallbacksEnd = metrics.rocketchatCallbacks.startTimer({ hook, callback: callback.id }); + + const newResult = originalRunItem({ callback, result, constant }); + + StatsTracker.timing('callbacks.time', Date.now() - time, [`hook:${ hook }`, `callback:${ callback.id }`]); + + rocketchatCallbacksEnd(); + + return newResult; +}; diff --git a/app/metrics/server/index.js b/app/metrics/server/index.js new file mode 100644 index 0000000000000..3f176d6291638 --- /dev/null +++ b/app/metrics/server/index.js @@ -0,0 +1,9 @@ +import { metrics } from './lib/metrics'; +import StatsTracker from './lib/statsTracker'; + +import './callbacksMetrics'; + +export { + metrics, + StatsTracker, +}; diff --git a/app/metrics/server/lib/metrics.js b/app/metrics/server/lib/metrics.js new file mode 100644 index 0000000000000..6ce3cf53df7b1 --- /dev/null +++ b/app/metrics/server/lib/metrics.js @@ -0,0 +1,183 @@ +import http from 'http'; + +import client from 'prom-client'; +import connect from 'connect'; +import _ from 'underscore'; +import { Meteor } from 'meteor/meteor'; + +import { Info } from '../../../utils'; +import { Migrations } from '../../../migrations'; +import { settings } from '../../../settings'; +import { Statistics } from '../../../models'; + +client.collectDefaultMetrics(); + +export const metrics = {}; + +metrics.meteorMethods = new client.Summary({ + name: 'rocketchat_meteor_methods', + help: 'summary of meteor methods count and time', + labelNames: ['method', 'has_connection', 'has_user'], +}); + +metrics.rocketchatCallbacks = new client.Summary({ + name: 'rocketchat_callbacks', + help: 'summary of rocketchat callbacks count and time', + labelNames: ['hook', 'callback'], +}); + +metrics.rocketchatHooks = new client.Summary({ + name: 'rocketchat_hooks', + help: 'summary of rocketchat hooks count and time', + labelNames: ['hook', 'callbacks_length'], +}); + +metrics.rocketchatRestApi = new client.Summary({ + name: 'rocketchat_rest_api', + help: 'summary of rocketchat rest api count and time', + labelNames: ['method', 'entrypoint', 'user_agent', 'status', 'version'], +}); + +metrics.meteorSubscriptions = new client.Summary({ + name: 'rocketchat_meteor_subscriptions', + help: 'summary of meteor subscriptions count and time', + labelNames: ['subscription'], +}); + +metrics.messagesSent = new client.Counter({ name: 'rocketchat_message_sent', help: 'cumulated number of messages sent' }); +metrics.notificationsSent = new client.Counter({ name: 'rocketchat_notification_sent', labelNames: ['notification_type'], help: 'cumulated number of notifications sent' }); + +metrics.ddpSessions = new client.Gauge({ name: 'rocketchat_ddp_sessions_count', help: 'number of open ddp sessions' }); +metrics.ddpAthenticatedSessions = new client.Gauge({ name: 'rocketchat_ddp_sessions_auth', help: 'number of authenticated open ddp sessions' }); +metrics.ddpConnectedUsers = new client.Gauge({ name: 'rocketchat_ddp_connected_users', help: 'number of unique connected users' }); +metrics.ddpRateLimitExceeded = new client.Counter({ name: 'rocketchat_ddp_rate_limit_exceeded', labelNames: ['limit_name', 'user_id', 'client_address', 'type', 'name', 'connection_id'], help: 'number of times a ddp rate limiter was exceeded' }); + +metrics.version = new client.Gauge({ name: 'rocketchat_version', labelNames: ['version'], help: 'Rocket.Chat version' }); +metrics.migration = new client.Gauge({ name: 'rocketchat_migration', help: 'migration versoin' }); +metrics.instanceCount = new client.Gauge({ name: 'rocketchat_instance_count', help: 'instances running' }); +metrics.oplogEnabled = new client.Gauge({ name: 'rocketchat_oplog_enabled', labelNames: ['enabled'], help: 'oplog enabled' }); + +// User statistics +metrics.totalUsers = new client.Gauge({ name: 'rocketchat_users_total', help: 'total of users' }); +metrics.activeUsers = new client.Gauge({ name: 'rocketchat_users_active', help: 'total of active users' }); +metrics.nonActiveUsers = new client.Gauge({ name: 'rocketchat_users_non_active', help: 'total of non active users' }); +metrics.onlineUsers = new client.Gauge({ name: 'rocketchat_users_online', help: 'total of users online' }); +metrics.awayUsers = new client.Gauge({ name: 'rocketchat_users_away', help: 'total of users away' }); +metrics.offlineUsers = new client.Gauge({ name: 'rocketchat_users_offline', help: 'total of users offline' }); + +// Room statistics +metrics.totalRooms = new client.Gauge({ name: 'rocketchat_rooms_total', help: 'total of rooms' }); +metrics.totalChannels = new client.Gauge({ name: 'rocketchat_channels_total', help: 'total of public rooms/channels' }); +metrics.totalPrivateGroups = new client.Gauge({ name: 'rocketchat_private_groups_total', help: 'total of private rooms' }); +metrics.totalDirect = new client.Gauge({ name: 'rocketchat_direct_total', help: 'total of direct rooms' }); +metrics.totalLivechat = new client.Gauge({ name: 'rocketchat_livechat_total', help: 'total of livechat rooms' }); + +// Message statistics +metrics.totalMessages = new client.Gauge({ name: 'rocketchat_messages_total', help: 'total of messages' }); +metrics.totalChannelMessages = new client.Gauge({ name: 'rocketchat_channel_messages_total', help: 'total of messages in public rooms' }); +metrics.totalPrivateGroupMessages = new client.Gauge({ name: 'rocketchat_private_group_messages_total', help: 'total of messages in private rooms' }); +metrics.totalDirectMessages = new client.Gauge({ name: 'rocketchat_direct_messages_total', help: 'total of messages in direct rooms' }); +metrics.totalLivechatMessages = new client.Gauge({ name: 'rocketchat_livechat_messages_total', help: 'total of messages in livechat rooms' }); + +const setPrometheusData = async () => { + client.register.setDefaultLabels({ + uniqueId: settings.get('uniqueID'), + siteUrl: settings.get('Site_Url'), + }); + const date = new Date(); + client.register.setDefaultLabels({ + unique_id: settings.get('uniqueID'), + site_url: settings.get('Site_Url'), + version: Info.version, + }); + + const sessions = Object.values(Meteor.server.sessions); + const authenticatedSessions = sessions.filter((s) => s.userId); + metrics.ddpSessions.set(sessions.length, date); + metrics.ddpAthenticatedSessions.set(authenticatedSessions.length, date); + metrics.ddpConnectedUsers.set(_.unique(authenticatedSessions.map((s) => s.userId)).length, date); + + const statistics = Statistics.findLast(); + if (!statistics) { + return; + } + + metrics.version.set({ version: statistics.version }, 1, date); + metrics.migration.set(Migrations._getControl().version, date); + metrics.instanceCount.set(statistics.instanceCount, date); + metrics.oplogEnabled.set({ enabled: statistics.oplogEnabled }, 1, date); + + // User statistics + metrics.totalUsers.set(statistics.totalUsers, date); + metrics.activeUsers.set(statistics.activeUsers, date); + metrics.nonActiveUsers.set(statistics.nonActiveUsers, date); + metrics.onlineUsers.set(statistics.onlineUsers, date); + metrics.awayUsers.set(statistics.awayUsers, date); + metrics.offlineUsers.set(statistics.offlineUsers, date); + + // Room statistics + metrics.totalRooms.set(statistics.totalRooms, date); + metrics.totalChannels.set(statistics.totalChannels, date); + metrics.totalPrivateGroups.set(statistics.totalPrivateGroups, date); + metrics.totalDirect.set(statistics.totalDirect, date); + metrics.totalLivechat.set(statistics.totalLivechat, date); + + // Message statistics + metrics.totalMessages.set(statistics.totalMessages, date); + metrics.totalChannelMessages.set(statistics.totalChannelMessages, date); + metrics.totalPrivateGroupMessages.set(statistics.totalPrivateGroupMessages, date); + metrics.totalDirectMessages.set(statistics.totalDirectMessages, date); + metrics.totalLivechatMessages.set(statistics.totalLivechatMessages, date); +}; + +const app = connect(); + +// const compression = require('compression'); +// app.use(compression()); + +app.use('/metrics', (req, res) => { + res.setHeader('Content-Type', 'text/plain'); + res.end(client.register.metrics()); +}); + +app.use('/', (req, res) => { + const html = ` + + Rocket.Chat Prometheus Exporter + + +

Rocket.Chat Prometheus Exporter

+

Metrics

+ + `; + + res.write(html); + res.end(); +}); + +const server = http.createServer(app); + +let timer; +const updatePrometheusConfig = async () => { + const port = process.env.PROMETHEUS_PORT || settings.get('Prometheus_Port'); + const enabled = settings.get('Prometheus_Enabled'); + if (port == null || enabled == null) { + return; + } + + if (enabled === true) { + server.listen({ + port, + host: process.env.BIND_IP || '0.0.0.0', + }); + timer = Meteor.setInterval(setPrometheusData, 5000); + } else { + server.close(); + Meteor.clearInterval(timer); + } +}; + +Meteor.startup(async () => { + settings.get('Prometheus_Enabled', updatePrometheusConfig); + settings.get('Prometheus_Port', updatePrometheusConfig); +}); diff --git a/packages/rocketchat-lib/server/startup/statsTracker.js b/app/metrics/server/lib/statsTracker.js similarity index 87% rename from packages/rocketchat-lib/server/startup/statsTracker.js rename to app/metrics/server/lib/statsTracker.js index db7e215a83726..5ef117991f449 100644 --- a/packages/rocketchat-lib/server/startup/statsTracker.js +++ b/app/metrics/server/lib/statsTracker.js @@ -1,6 +1,6 @@ import { StatsD } from 'node-dogstatsd'; -RocketChat.statsTracker = new (class StatsTracker { +export class StatsTracker { constructor() { this.StatsD = StatsD; this.dogstatsd = new this.StatsD(); @@ -12,7 +12,7 @@ RocketChat.statsTracker = new (class StatsTracker { now() { const hrtime = process.hrtime(); - return (hrtime[0] * 1000000 + hrtime[1] / 1000); + return hrtime[0] * 1000000 + hrtime[1] / 1000; } timing(stats, time, tags) { @@ -42,4 +42,6 @@ RocketChat.statsTracker = new (class StatsTracker { set(stats, time, tags) { this.track('set', stats, time, tags); } -}); +} + +export default new StatsTracker(); diff --git a/app/migrations/index.js b/app/migrations/index.js new file mode 100644 index 0000000000000..ca39cd0df4b1a --- /dev/null +++ b/app/migrations/index.js @@ -0,0 +1 @@ +export * from './server/index'; diff --git a/app/migrations/server/index.js b/app/migrations/server/index.js new file mode 100644 index 0000000000000..bd8a290dd2b39 --- /dev/null +++ b/app/migrations/server/index.js @@ -0,0 +1,3 @@ +import { Migrations } from './migrations'; + +export { Migrations }; diff --git a/packages/rocketchat-migrations/server/migrations.js b/app/migrations/server/migrations.js similarity index 90% rename from packages/rocketchat-migrations/server/migrations.js rename to app/migrations/server/migrations.js index 085de7faf8a8b..a797c1aeed29f 100644 --- a/packages/rocketchat-migrations/server/migrations.js +++ b/app/migrations/server/migrations.js @@ -2,11 +2,12 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; import { Mongo } from 'meteor/mongo'; -import { RocketChat } from 'meteor/rocketchat:lib'; import { Log } from 'meteor/logging'; import _ from 'underscore'; import s from 'underscore.string'; import moment from 'moment'; + +import { Info } from '../../utils'; /* Adds migration capabilities. Migrations are defined like: @@ -45,7 +46,7 @@ const DefaultMigration = { }, }; -const Migrations = this.Migrations = { +export const Migrations = { _list: [DefaultMigration], options: { // false disables logging @@ -79,10 +80,10 @@ function makeABox(message, color = 'red') { const len = _(message).reduce(function(memo, msg) { return Math.max(memo, msg.length); }, 0) + 4; - const text = message.map((msg) => '|' [color] + s.lrpad(msg, len)[color] + '|' [color]).join('\n'); - const topLine = '+' [color] + s.pad('', len, '-')[color] + '+' [color]; - const separator = '|' [color] + s.pad('', len, '') + '|' [color]; - const bottomLine = '+' [color] + s.pad('', len, '-')[color] + '+' [color]; + const text = message.map((msg) => '|'[color] + s.lrpad(msg, len)[color] + '|'[color]).join('\n'); + const topLine = '+'[color] + s.pad('', len, '-')[color] + '+'[color]; + const separator = '|'[color] + s.pad('', len, '') + '|'[color]; + const bottomLine = '+'[color] + s.pad('', len, '-')[color] + '+'[color]; return `\n${ topLine }\n${ separator }\n${ text }\n${ separator }\n${ bottomLine }\n`; } @@ -110,13 +111,11 @@ function createLogger(prefix) { const logger = Migrations.options && Migrations.options.logger; if (logger && _.isFunction(logger)) { - logger({ level, message, tag: prefix, }); - } else { Log[level]({ message: `${ prefix }: ${ message }`, @@ -179,7 +178,7 @@ Migrations.migrateTo = function(command) { if (version === 'latest') { migrated = this._migrateTo(_.last(this._list).version); } else { - migrated = this._migrateTo(parseInt(version), (subcommands.includes('rerun'))); + migrated = this._migrateTo(parseInt(version), subcommands.includes('rerun')); } if (migrated) { break; @@ -203,14 +202,14 @@ Migrations.migrateTo = function(command) { 'Please make sure you are running the latest version and try again.', 'If the problem persists, please contact support.', '', - `This Rocket.Chat version: ${ RocketChat.Info.version }`, + `This Rocket.Chat version: ${ Info.version }`, `Database locked at version: ${ control.version }`, `Database target version: ${ version === 'latest' ? _.last(this._list).version : version }`, '', - `Commit: ${ RocketChat.Info.commit.hash }`, - `Date: ${ RocketChat.Info.commit.date }`, - `Branch: ${ RocketChat.Info.commit.branch }`, - `Tag: ${ RocketChat.Info.commit.tag }`, + `Commit: ${ Info.commit.hash }`, + `Date: ${ Info.commit.date }`, + `Branch: ${ Info.commit.branch }`, + `Tag: ${ Info.commit.tag }`, ])); process.exit(1); } @@ -285,14 +284,14 @@ Migrations._migrateTo = function(version, rerun) { 'Please make sure you are running the latest version and try again.', 'If the problem persists, please contact support.', '', - `This Rocket.Chat version: ${ RocketChat.Info.version }`, + `This Rocket.Chat version: ${ Info.version }`, `Database locked at version: ${ control.version }`, `Database target version: ${ version }`, '', - `Commit: ${ RocketChat.Info.commit.hash }`, - `Date: ${ RocketChat.Info.commit.date }`, - `Branch: ${ RocketChat.Info.commit.branch }`, - `Tag: ${ RocketChat.Info.commit.tag }`, + `Commit: ${ Info.commit.hash }`, + `Date: ${ Info.commit.date }`, + `Branch: ${ Info.commit.branch }`, + `Tag: ${ Info.commit.tag }`, ])); process.exit(1); } @@ -302,7 +301,7 @@ Migrations._migrateTo = function(version, rerun) { function lock() { const date = new Date(); const dateMinusInterval = moment(date).subtract(self.options.lockExpiration, 'minutes').toDate(); - const build = RocketChat.Info ? RocketChat.Info.build.date : date; + const build = Info ? Info.build.date : date; // This is atomic. The selector ensures only one caller at a time will see // the unlocked control, and locking occurs in the same update's modifier. @@ -411,5 +410,3 @@ Migrations._reset = function() { }]; this._collection.remove({}); }; - -RocketChat.Migrations = Migrations; diff --git a/app/models/client/index.js b/app/models/client/index.js new file mode 100644 index 0000000000000..fbcbee481f5c4 --- /dev/null +++ b/app/models/client/index.js @@ -0,0 +1,56 @@ +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { Base } from './models/_Base'; +import Avatars from './models/Avatars'; +import Uploads from './models/Uploads'; +import UserDataFiles from './models/UserDataFiles'; +import { Roles } from './models/Roles'; +import { Subscriptions as subscriptions } from './models/Subscriptions'; +import { Users as users } from './models/Users'; +import { CachedChannelList } from './models/CachedChannelList'; +import { CachedChatRoom } from './models/CachedChatRoom'; +import { CachedChatSubscription } from './models/CachedChatSubscription'; +import { CachedUserList } from './models/CachedUserList'; +import { ChatRoom } from './models/ChatRoom'; +import { ChatSubscription } from './models/ChatSubscription'; +import { ChatMessage } from './models/ChatMessage'; +import { RoomRoles } from './models/RoomRoles'; +import { UserAndRoom } from './models/UserAndRoom'; +import { UserRoles } from './models/UserRoles'; +import { AuthzCachedCollection, ChatPermissions } from './models/ChatPermissions'; +import { WebdavAccounts } from './models/WebdavAccounts'; +import CustomSounds from './models/CustomSounds'; +import EmojiCustom from './models/EmojiCustom'; + +const Users = _.extend({}, users, Meteor.users); +const Subscriptions = _.extend({}, subscriptions, ChatSubscription); +const Messages = _.extend({}, ChatMessage); +const Rooms = _.extend({}, ChatRoom); + +export { + Base, + Avatars, + Uploads, + UserDataFiles, + Roles, + Subscriptions, + Users, + Messages, + CachedChannelList, + CachedChatRoom, + CachedChatSubscription, + CachedUserList, + ChatRoom, + RoomRoles, + UserAndRoom, + UserRoles, + AuthzCachedCollection, + ChatPermissions, + ChatMessage, + ChatSubscription, + Rooms, + CustomSounds, + EmojiCustom, + WebdavAccounts, +}; diff --git a/app/models/client/models/Avatars.js b/app/models/client/models/Avatars.js new file mode 100644 index 0000000000000..73a35965754a3 --- /dev/null +++ b/app/models/client/models/Avatars.js @@ -0,0 +1,10 @@ +import { Base } from './_Base'; + +export class Avatars extends Base { + constructor() { + super(); + this._initModel('avatars'); + } +} + +export default new Avatars(); diff --git a/app/models/client/models/CachedChannelList.js b/app/models/client/models/CachedChannelList.js new file mode 100644 index 0000000000000..1a34d88e6aebf --- /dev/null +++ b/app/models/client/models/CachedChannelList.js @@ -0,0 +1,3 @@ +import { Mongo } from 'meteor/mongo'; + +export const CachedChannelList = new Mongo.Collection(null); diff --git a/app/models/client/models/CachedChatRoom.js b/app/models/client/models/CachedChatRoom.js new file mode 100644 index 0000000000000..6ed43bc157495 --- /dev/null +++ b/app/models/client/models/CachedChatRoom.js @@ -0,0 +1,3 @@ +import { CachedCollection } from '../../../ui-cached-collection'; + +export const CachedChatRoom = new CachedCollection({ name: 'rooms' }); diff --git a/app/models/client/models/CachedChatSubscription.js b/app/models/client/models/CachedChatSubscription.js new file mode 100644 index 0000000000000..a237624ba113d --- /dev/null +++ b/app/models/client/models/CachedChatSubscription.js @@ -0,0 +1,3 @@ +import { CachedCollection } from '../../../ui-cached-collection'; + +export const CachedChatSubscription = new CachedCollection({ name: 'subscriptions' }); diff --git a/app/models/client/models/CachedUserList.js b/app/models/client/models/CachedUserList.js new file mode 100644 index 0000000000000..0233659b47d51 --- /dev/null +++ b/app/models/client/models/CachedUserList.js @@ -0,0 +1,3 @@ +import { Mongo } from 'meteor/mongo'; + +export const CachedUserList = new Mongo.Collection(null); diff --git a/app/models/client/models/ChatMessage.js b/app/models/client/models/ChatMessage.js new file mode 100644 index 0000000000000..9109ffabfc2bb --- /dev/null +++ b/app/models/client/models/ChatMessage.js @@ -0,0 +1,11 @@ +import { Mongo } from 'meteor/mongo'; + +export const ChatMessage = new Mongo.Collection(null); + +ChatMessage.setReactions = function(messageId, reactions) { + return this.update({ _id: messageId }, { $set: { reactions } }); +}; + +ChatMessage.unsetReactions = function(messageId) { + return this.update({ _id: messageId }, { $unset: { reactions: 1 } }); +}; diff --git a/app/models/client/models/ChatPermissions.js b/app/models/client/models/ChatPermissions.js new file mode 100644 index 0000000000000..e50ce6f66ba3f --- /dev/null +++ b/app/models/client/models/ChatPermissions.js @@ -0,0 +1,8 @@ +import { CachedCollection } from '../../../ui-cached-collection'; + +export const AuthzCachedCollection = new CachedCollection({ + name: 'permissions', + eventType: 'onLogged', +}); + +export const ChatPermissions = AuthzCachedCollection.collection; diff --git a/app/models/client/models/ChatRoom.js b/app/models/client/models/ChatRoom.js new file mode 100644 index 0000000000000..e55c3e94f9308 --- /dev/null +++ b/app/models/client/models/ChatRoom.js @@ -0,0 +1,11 @@ +import { CachedChatRoom } from './CachedChatRoom'; + +export const ChatRoom = CachedChatRoom.collection; + +ChatRoom.setReactionsInLastMessage = function(roomId, lastMessage) { + return this.update({ _id: roomId }, { $set: { lastMessage } }); +}; + +ChatRoom.unsetReactionsInLastMessage = function(roomId) { + return this.update({ _id: roomId }, { $unset: { lastMessage: { reactions: 1 } } }); +}; diff --git a/app/models/client/models/ChatSubscription.js b/app/models/client/models/ChatSubscription.js new file mode 100644 index 0000000000000..964f622db4958 --- /dev/null +++ b/app/models/client/models/ChatSubscription.js @@ -0,0 +1,3 @@ +import { CachedChatSubscription } from './CachedChatSubscription'; + +export const ChatSubscription = CachedChatSubscription.collection; diff --git a/app/models/client/models/CustomSounds.js b/app/models/client/models/CustomSounds.js new file mode 100644 index 0000000000000..e708f7e52fca6 --- /dev/null +++ b/app/models/client/models/CustomSounds.js @@ -0,0 +1,10 @@ +import { Base } from './_Base'; + +export class CustomSounds extends Base { + constructor() { + super(); + this._initModel('custom_sounds'); + } +} + +export default new CustomSounds(); diff --git a/app/models/client/models/EmojiCustom.js b/app/models/client/models/EmojiCustom.js new file mode 100644 index 0000000000000..bb70c24fa1a56 --- /dev/null +++ b/app/models/client/models/EmojiCustom.js @@ -0,0 +1,22 @@ +import { Base } from './_Base'; + +export class EmojiCustom extends Base { + constructor() { + super(); + this._initModel('custom_emoji'); + } + + // find + findByNameOrAlias(name, options) { + const query = { + $or: [ + { name }, + { aliases: name }, + ], + }; + + return this.find(query, options); + } +} + +export default new EmojiCustom(); diff --git a/app/models/client/models/FullUser.js b/app/models/client/models/FullUser.js new file mode 100644 index 0000000000000..f9b765b57b0ad --- /dev/null +++ b/app/models/client/models/FullUser.js @@ -0,0 +1,10 @@ +import { Base } from './_Base'; + +class FullUser extends Base { + constructor() { + super(); + this._initModel('full_user'); + } +} + +export default new FullUser(); diff --git a/app/models/client/models/Roles.js b/app/models/client/models/Roles.js new file mode 100644 index 0000000000000..0d720ac90f93e --- /dev/null +++ b/app/models/client/models/Roles.js @@ -0,0 +1,26 @@ +import { Mongo } from 'meteor/mongo'; + +import * as Models from '..'; + +const Roles = new Mongo.Collection('rocketchat_roles'); + +Object.assign(Roles, { + findUsersInRole(name, scope, options) { + const role = this.findOne(name); + const roleScope = (role && role.scope) || 'Users'; + const model = Models[roleScope]; + return model && model.findUsersInRoles && model.findUsersInRoles(name, scope, options); + }, + + isUserInRoles(userId, roles, scope) { + roles = [].concat(roles); + return roles.some((roleName) => { + const role = this.findOne(roleName); + const roleScope = (role && role.scope) || 'Users'; + const model = Models[roleScope]; + return model && model.isUserInRole && model.isUserInRole(userId, roleName, scope); + }); + }, +}); + +export { Roles }; diff --git a/app/models/client/models/RoomRoles.js b/app/models/client/models/RoomRoles.js new file mode 100644 index 0000000000000..4085638913bd6 --- /dev/null +++ b/app/models/client/models/RoomRoles.js @@ -0,0 +1,3 @@ +import { Mongo } from 'meteor/mongo'; + +export const RoomRoles = new Mongo.Collection(null); diff --git a/app/models/client/models/Subscriptions.js b/app/models/client/models/Subscriptions.js new file mode 100644 index 0000000000000..661aa004920e3 --- /dev/null +++ b/app/models/client/models/Subscriptions.js @@ -0,0 +1,46 @@ +import { Users } from '..'; + +import _ from 'underscore'; +import mem from 'mem'; + +const Subscriptions = {}; + +Object.assign(Subscriptions, { + isUserInRole: mem(function(userId, roleName, roomId) { + if (roomId == null) { + return false; + } + + const query = { + rid: roomId, + }; + + const subscription = this.findOne(query, { fields: { roles: 1 } }); + + return subscription && Array.isArray(subscription.roles) && subscription.roles.includes(roleName); + }, { maxAge: 1000 }), + + findUsersInRoles: mem(function(roles, scope, options) { + roles = [].concat(roles); + + const query = { + roles: { $in: roles }, + }; + + if (scope) { + query.rid = scope; + } + + const subscriptions = this.find(query).fetch(); + + const users = _.compact(_.map(subscriptions, function(subscription) { + if (typeof subscription.u !== 'undefined' && typeof subscription.u._id !== 'undefined') { + return subscription.u._id; + } + })); + + return Users.find({ _id: { $in: users } }, options); + }, { maxAge: 1000 }), +}); + +export { Subscriptions }; diff --git a/app/models/client/models/Uploads.js b/app/models/client/models/Uploads.js new file mode 100644 index 0000000000000..2c3ee0285d982 --- /dev/null +++ b/app/models/client/models/Uploads.js @@ -0,0 +1,10 @@ +import { Base } from './_Base'; + +export class Uploads extends Base { + constructor() { + super(); + this._initModel('uploads'); + } +} + +export default new Uploads(); diff --git a/app/models/client/models/UserAndRoom.js b/app/models/client/models/UserAndRoom.js new file mode 100644 index 0000000000000..f43b2c0fdbe09 --- /dev/null +++ b/app/models/client/models/UserAndRoom.js @@ -0,0 +1,3 @@ +import { Mongo } from 'meteor/mongo'; + +export const UserAndRoom = new Mongo.Collection(null); diff --git a/app/models/client/models/UserDataFiles.js b/app/models/client/models/UserDataFiles.js new file mode 100644 index 0000000000000..93c1b1d44720c --- /dev/null +++ b/app/models/client/models/UserDataFiles.js @@ -0,0 +1,10 @@ +import { Base } from './_Base'; + +export class UserDataFiles extends Base { + constructor() { + super(); + this._initModel('userDataFiles'); + } +} + +export default new UserDataFiles(); diff --git a/app/models/client/models/UserRoles.js b/app/models/client/models/UserRoles.js new file mode 100644 index 0000000000000..de71e371567be --- /dev/null +++ b/app/models/client/models/UserRoles.js @@ -0,0 +1,3 @@ +import { Mongo } from 'meteor/mongo'; + +export const UserRoles = new Mongo.Collection(null); diff --git a/app/models/client/models/Users.js b/app/models/client/models/Users.js new file mode 100644 index 0000000000000..2c4f75c8a8778 --- /dev/null +++ b/app/models/client/models/Users.js @@ -0,0 +1,37 @@ +import { Meteor } from 'meteor/meteor'; +import { Mongo } from 'meteor/mongo'; + +export const Users = { + isUserInRole(userId, roleName) { + const query = { + _id: userId, + }; + + const user = this.findOne(query, { fields: { roles: 1 } }); + return user && Array.isArray(user.roles) && user.roles.includes(roleName); + }, + + findUsersInRoles(roles, scope, options) { + roles = [].concat(roles); + + const query = { + roles: { $in: roles }, + }; + + return this.find(query, options); + }, +}; + +// overwrite Meteor.users collection so records on it don't get erased whenever the client reconnects to websocket +Meteor.users = new Mongo.Collection(null); +Meteor.user = () => Meteor.users.findOne({ _id: Meteor.userId() }); + +// logged user data will come to this collection +const OwnUser = new Mongo.Collection('own_user'); + +// register an observer to logged user's collection and populate "original" Meteor.users with it +OwnUser.find().observe({ + added: (record) => Meteor.users.upsert({ _id: record._id }, record), + changed: (record) => Meteor.users.update({ _id: record._id }, record), + removed: (_id) => Meteor.users.remove({ _id }), +}); diff --git a/app/models/client/models/WebdavAccounts.js b/app/models/client/models/WebdavAccounts.js new file mode 100644 index 0000000000000..fafe9bf792d0e --- /dev/null +++ b/app/models/client/models/WebdavAccounts.js @@ -0,0 +1,3 @@ +import { Mongo } from 'meteor/mongo'; + +export const WebdavAccounts = new Mongo.Collection('rocketchat_webdav_accounts'); diff --git a/app/models/client/models/_Base.js b/app/models/client/models/_Base.js new file mode 100644 index 0000000000000..4c81d84ae8634 --- /dev/null +++ b/app/models/client/models/_Base.js @@ -0,0 +1,54 @@ +import { check } from 'meteor/check'; +import { Mongo } from 'meteor/mongo'; + +export class Base { + _baseName() { + return 'rocketchat_'; + } + + _initModel(name) { + check(name, String); + this.model = new Mongo.Collection(this._baseName() + name); + return this.model; + } + + find(...args) { + return this.model.find.apply(this.model, args); + } + + findOne(...args) { + return this.model.findOne.apply(this.model, args); + } + + insert(...args) { + return this.model.insert.apply(this.model, args); + } + + update(...args) { + return this.model.update.apply(this.model, args); + } + + upsert(...args) { + return this.model.upsert.apply(this.model, args); + } + + remove(...args) { + return this.model.remove.apply(this.model, args); + } + + allow(...args) { + return this.model.allow.apply(this.model, args); + } + + deny(...args) { + return this.model.deny.apply(this.model, args); + } + + ensureIndex() {} + + dropIndex() {} + + tryEnsureIndex() {} + + tryDropIndex() {} +} diff --git a/app/models/index.js b/app/models/index.js new file mode 100644 index 0000000000000..a67eca871efbb --- /dev/null +++ b/app/models/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/models/server/index.js b/app/models/server/index.js new file mode 100644 index 0000000000000..74b882789e004 --- /dev/null +++ b/app/models/server/index.js @@ -0,0 +1,77 @@ +import { Base } from './models/_Base'; +import { BaseDb } from './models/_BaseDb'; +import Avatars from './models/Avatars'; +import ExportOperations from './models/ExportOperations'; +import Messages from './models/Messages'; +import Reports from './models/Reports'; +import Rooms from './models/Rooms'; +import Settings from './models/Settings'; +import Subscriptions from './models/Subscriptions'; +import Uploads from './models/Uploads'; +import UserDataFiles from './models/UserDataFiles'; +import Users from './models/Users'; +import Sessions from './models/Sessions'; +import Statistics from './models/Statistics'; +import Permissions from './models/Permissions'; +import Roles from './models/Roles'; +import CustomSounds from './models/CustomSounds'; +import Integrations from './models/Integrations'; +import IntegrationHistory from './models/IntegrationHistory'; +import CredentialTokens from './models/CredentialTokens'; +import EmojiCustom from './models/EmojiCustom'; +import OAuthApps from './models/OAuthApps'; +import OEmbedCache from './models/OEmbedCache'; +import SmarshHistory from './models/SmarshHistory'; +import WebdavAccounts from './models/WebdavAccounts'; +import LivechatCustomField from './models/LivechatCustomField'; +import LivechatDepartment from './models/LivechatDepartment'; +import LivechatDepartmentAgents from './models/LivechatDepartmentAgents'; +import LivechatOfficeHour from './models/LivechatOfficeHour'; +import LivechatPageVisited from './models/LivechatPageVisited'; +import LivechatTrigger from './models/LivechatTrigger'; +import LivechatVisitors from './models/LivechatVisitors'; +import ReadReceipts from './models/ReadReceipts'; + +export { AppsLogsModel } from './models/apps-logs-model'; +export { AppsPersistenceModel } from './models/apps-persistence-model'; +export { AppsModel } from './models/apps-model'; +export { FederationDNSCache } from './models/FederationDNSCache'; +export { FederationEvents } from './models/FederationEvents'; +export { FederationKeys } from './models/FederationKeys'; +export { FederationPeers } from './models/FederationPeers'; + +export { + Base, + BaseDb, + Avatars, + ExportOperations, + Messages, + Reports, + Rooms, + Settings, + Subscriptions, + Uploads, + UserDataFiles, + Users, + Sessions, + Statistics, + Permissions, + Roles, + CustomSounds, + Integrations, + IntegrationHistory, + CredentialTokens, + EmojiCustom, + OAuthApps, + OEmbedCache, + SmarshHistory, + WebdavAccounts, + LivechatCustomField, + LivechatDepartment, + LivechatDepartmentAgents, + LivechatOfficeHour, + LivechatPageVisited, + LivechatTrigger, + LivechatVisitors, + ReadReceipts, +}; diff --git a/app/models/server/models/Avatars.js b/app/models/server/models/Avatars.js new file mode 100644 index 0000000000000..d1dcecdcc97d2 --- /dev/null +++ b/app/models/server/models/Avatars.js @@ -0,0 +1,113 @@ +import _ from 'underscore'; +import s from 'underscore.string'; +import { InstanceStatus } from 'meteor/konecty:multiple-instances-status'; + +import { Base } from './_Base'; + +export class Avatars extends Base { + constructor() { + super('avatars'); + + this.model.before.insert((userId, doc) => { + doc.instanceId = InstanceStatus.id(); + }); + + this.tryEnsureIndex({ name: 1 }); + } + + insertAvatarFileInit(name, userId, store, file, extra) { + const fileData = { + _id: name, + name, + userId, + store, + complete: false, + uploading: true, + progress: 0, + extension: s.strRightBack(file.name, '.'), + uploadedAt: new Date(), + }; + + _.extend(fileData, file, extra); + + return this.insertOrUpsert(fileData); + } + + updateFileComplete(fileId, userId, file) { + if (!fileId) { + return; + } + + const filter = { + _id: fileId, + userId, + }; + + const update = { + $set: { + complete: true, + uploading: false, + progress: 1, + }, + }; + + update.$set = _.extend(file, update.$set); + + if (this.model.direct && this.model.direct.update) { + return this.model.direct.update(filter, update); + } + return this.update(filter, update); + } + + findOneByName(name) { + return this.findOne({ name }); + } + + updateFileNameById(fileId, name) { + const filter = { _id: fileId }; + const update = { + $set: { + name, + }, + }; + if (this.model.direct && this.model.direct.update) { + return this.model.direct.update(filter, update); + } + return this.update(filter, update); + } + + // @TODO deprecated + updateFileCompleteByNameAndUserId(name, userId, url) { + if (!name) { + return; + } + + const filter = { + name, + userId, + }; + + const update = { + $set: { + complete: true, + uploading: false, + progress: 1, + url, + }, + }; + + if (this.model.direct && this.model.direct.update) { + return this.model.direct.update(filter, update); + } + return this.update(filter, update); + } + + deleteFile(fileId) { + if (this.model.direct && this.model.direct.remove) { + return this.model.direct.remove({ _id: fileId }); + } + return this.remove({ _id: fileId }); + } +} + +export default new Avatars(); diff --git a/packages/rocketchat-cas/server/models/CredentialTokens.js b/app/models/server/models/CredentialTokens.js similarity index 78% rename from packages/rocketchat-cas/server/models/CredentialTokens.js rename to app/models/server/models/CredentialTokens.js index a484eb978b396..7659538e032eb 100644 --- a/packages/rocketchat-cas/server/models/CredentialTokens.js +++ b/app/models/server/models/CredentialTokens.js @@ -1,6 +1,6 @@ -import { RocketChat } from 'meteor/rocketchat:lib'; +import { Base } from './_Base'; -RocketChat.models.CredentialTokens = new class extends RocketChat.models._Base { +export class CredentialTokens extends Base { constructor() { super('credential_tokens'); @@ -27,4 +27,6 @@ RocketChat.models.CredentialTokens = new class extends RocketChat.models._Base { return this.findOne(query); } -}; +} + +export default new CredentialTokens(); diff --git a/app/models/server/models/CustomSounds.js b/app/models/server/models/CustomSounds.js new file mode 100644 index 0000000000000..40b25d5dc80a6 --- /dev/null +++ b/app/models/server/models/CustomSounds.js @@ -0,0 +1,56 @@ +import { Base } from './_Base'; + +class CustomSounds extends Base { + constructor() { + super('custom_sounds'); + + this.tryEnsureIndex({ name: 1 }); + } + + // find one + findOneByID(_id, options) { + return this.findOne(_id, options); + } + + // find + findByName(name, options) { + const query = { + name, + }; + + return this.find(query, options); + } + + findByNameExceptID(name, except, options) { + const query = { + _id: { $nin: [except] }, + name, + }; + + return this.find(query, options); + } + + // update + setName(_id, name) { + const update = { + $set: { + name, + }, + }; + + return this.update({ _id }, update); + } + + // INSERT + create(data) { + return this.insert(data); + } + + + // REMOVE + removeByID(_id) { + return this.remove(_id); + } +} + +export default new CustomSounds(); diff --git a/app/models/server/models/EmojiCustom.js b/app/models/server/models/EmojiCustom.js new file mode 100644 index 0000000000000..8f9f676072f56 --- /dev/null +++ b/app/models/server/models/EmojiCustom.js @@ -0,0 +1,91 @@ +import { Base } from './_Base'; + +class EmojiCustom extends Base { + constructor() { + super('custom_emoji'); + + this.tryEnsureIndex({ name: 1 }); + this.tryEnsureIndex({ aliases: 1 }); + this.tryEnsureIndex({ extension: 1 }); + } + + // find one + findOneByID(_id, options) { + return this.findOne(_id, options); + } + + // find + findByNameOrAlias(emojiName, options) { + let name = emojiName; + + if (typeof emojiName === 'string') { + name = emojiName.replace(/:/g, ''); + } + + const query = { + $or: [ + { name }, + { aliases: name }, + ], + }; + + return this.find(query, options); + } + + findByNameOrAliasExceptID(name, except, options) { + const query = { + _id: { $nin: [except] }, + $or: [ + { name }, + { aliases: name }, + ], + }; + + return this.find(query, options); + } + + + // update + setName(_id, name) { + const update = { + $set: { + name, + }, + }; + + return this.update({ _id }, update); + } + + setAliases(_id, aliases) { + const update = { + $set: { + aliases, + }, + }; + + return this.update({ _id }, update); + } + + setExtension(_id, extension) { + const update = { + $set: { + extension, + }, + }; + + return this.update({ _id }, update); + } + + // INSERT + create(data) { + return this.insert(data); + } + + + // REMOVE + removeByID(_id) { + return this.remove(_id); + } +} + +export default new EmojiCustom(); diff --git a/packages/rocketchat-lib/server/models/ExportOperations.js b/app/models/server/models/ExportOperations.js similarity index 86% rename from packages/rocketchat-lib/server/models/ExportOperations.js rename to app/models/server/models/ExportOperations.js index 095e8ec885b05..a72fdc486e201 100644 --- a/packages/rocketchat-lib/server/models/ExportOperations.js +++ b/app/models/server/models/ExportOperations.js @@ -1,6 +1,8 @@ import _ from 'underscore'; -RocketChat.models.ExportOperations = new class ModelExportOperations extends RocketChat.models._Base { +import { Base } from './_Base'; + +export class ExportOperations extends Base { constructor() { super('export_operations'); @@ -21,7 +23,7 @@ RocketChat.models.ExportOperations = new class ModelExportOperations extends Roc fullExport, }; - options.sort = { createdAt : -1 }; + options.sort = { createdAt: -1 }; return this.findOne(query, options); } @@ -62,7 +64,7 @@ RocketChat.models.ExportOperations = new class ModelExportOperations extends Roc // INSERT create(data) { const exportOperation = { - createdAt: new Date, + createdAt: new Date(), }; _.extend(exportOperation, data); @@ -75,4 +77,6 @@ RocketChat.models.ExportOperations = new class ModelExportOperations extends Roc removeById(_id) { return this.remove(_id); } -}; +} + +export default new ExportOperations(); diff --git a/app/models/server/models/FederationDNSCache.js b/app/models/server/models/FederationDNSCache.js new file mode 100644 index 0000000000000..155deed53b956 --- /dev/null +++ b/app/models/server/models/FederationDNSCache.js @@ -0,0 +1,13 @@ +import { Base } from './_Base'; + +class FederationDNSCacheModel extends Base { + constructor() { + super('federation_dns_cache'); + } + + findOneByDomain(domain) { + return this.findOne({ domain }); + } +} + +export const FederationDNSCache = new FederationDNSCacheModel(); diff --git a/app/models/server/models/FederationEvents.js b/app/models/server/models/FederationEvents.js new file mode 100644 index 0000000000000..82a09247c0e6a --- /dev/null +++ b/app/models/server/models/FederationEvents.js @@ -0,0 +1,263 @@ +import { Meteor } from 'meteor/meteor'; + +import { Base } from './_Base'; + +const normalizePeers = (basePeers, options) => { + const { peers: sentPeers, skipPeers } = options; + + let peers = sentPeers || basePeers || []; + + if (skipPeers) { + peers = peers.filter((p) => skipPeers.indexOf(p) === -1); + } + + return peers; +}; + +// +// We should create a time to live index in this table to remove fulfilled events +// +class FederationEventsModel extends Base { + constructor() { + super('federation_events'); + } + + // Sometimes events errored but the error is final + setEventAsErrored(e, error, fulfilled = false) { + this.update({ _id: e._id }, { + $set: { + fulfilled, + lastAttemptAt: new Date(), + error, + }, + }); + } + + setEventAsFullfilled(e) { + this.update({ _id: e._id }, { + $set: { fulfilled: true }, + $unset: { error: 1 }, + }); + } + + createEvent(type, payload, peer, options) { + const record = { + t: type, + ts: new Date(), + fulfilled: false, + payload, + peer, + options, + }; + + record._id = this.insert(record); + + Meteor.defer(() => { + this.emit('createEvent', record); + }); + + return record; + } + + createEventForPeers(type, payload, peers, options = {}) { + const records = []; + + for (const peer of peers) { + const record = this.createEvent(type, payload, peer, options); + + records.push(record); + } + + return records; + } + + // Create a `ping(png)` event + ping(peers) { + return this.createEventForPeers('png', {}, peers, { retry: { total: 1 } }); + } + + // Create a `directRoomCreated(drc)` event + directRoomCreated(federatedRoom, options = {}) { + const peers = normalizePeers(federatedRoom.getPeers(), options); + + const payload = { + room: federatedRoom.getRoom(), + owner: federatedRoom.getOwner(), + users: federatedRoom.getUsers(), + }; + + return this.createEventForPeers('drc', payload, peers); + } + + // Create a `roomCreated(roc)` event + roomCreated(federatedRoom, options = {}) { + const peers = normalizePeers(federatedRoom.getPeers(), options); + + const payload = { + room: federatedRoom.getRoom(), + owner: federatedRoom.getOwner(), + users: federatedRoom.getUsers(), + }; + + return this.createEventForPeers('roc', payload, peers); + } + + // Create a `userJoined(usj)` event + userJoined(federatedRoom, federatedUser, options = {}) { + const peers = normalizePeers(federatedRoom.getPeers(), options); + + const payload = { + federated_room_id: federatedRoom.getFederationId(), + user: federatedUser.getUser(), + }; + + return this.createEventForPeers('usj', payload, peers); + } + + // Create a `userAdded(usa)` event + userAdded(federatedRoom, federatedUser, federatedInviter, options = {}) { + const peers = normalizePeers(federatedRoom.getPeers(), options); + + const payload = { + federated_room_id: federatedRoom.getFederationId(), + federated_inviter_id: federatedInviter.getFederationId(), + user: federatedUser.getUser(), + }; + + return this.createEventForPeers('usa', payload, peers); + } + + // Create a `userLeft(usl)` event + userLeft(federatedRoom, federatedUser, options = {}) { + const peers = normalizePeers(federatedRoom.getPeers(), options); + + const payload = { + federated_room_id: federatedRoom.getFederationId(), + federated_user_id: federatedUser.getFederationId(), + }; + + return this.createEventForPeers('usl', payload, peers); + } + + // Create a `userRemoved(usr)` event + userRemoved(federatedRoom, federatedUser, federatedRemovedByUser, options = {}) { + const peers = normalizePeers(federatedRoom.getPeers(), options); + + const payload = { + federated_room_id: federatedRoom.getFederationId(), + federated_user_id: federatedUser.getFederationId(), + federated_removed_by_user_id: federatedRemovedByUser.getFederationId(), + }; + + return this.createEventForPeers('usr', payload, peers); + } + + // Create a `userMuted(usm)` event + userMuted(federatedRoom, federatedUser, federatedMutedByUser, options = {}) { + const peers = normalizePeers(federatedRoom.getPeers(), options); + + const payload = { + federated_room_id: federatedRoom.getFederationId(), + federated_user_id: federatedUser.getFederationId(), + federated_muted_by_user_id: federatedMutedByUser.getFederationId(), + }; + + return this.createEventForPeers('usm', payload, peers); + } + + // Create a `userUnmuted(usu)` event + userUnmuted(federatedRoom, federatedUser, federatedUnmutedByUser, options = {}) { + const peers = normalizePeers(federatedRoom.getPeers(), options); + + const payload = { + federated_room_id: federatedRoom.getFederationId(), + federated_user_id: federatedUser.getFederationId(), + federated_unmuted_by_user_id: federatedUnmutedByUser.getFederationId(), + }; + + return this.createEventForPeers('usu', payload, peers); + } + + // Create a `messageCreated(msc)` event + messageCreated(federatedRoom, federatedMessage, options = {}) { + const peers = normalizePeers(federatedRoom.getPeers(), options); + + const payload = { + message: federatedMessage.getMessage(), + }; + + return this.createEventForPeers('msc', payload, peers); + } + + // Create a `messageUpdated(msu)` event + messageUpdated(federatedRoom, federatedMessage, federatedUser, options = {}) { + const peers = normalizePeers(federatedRoom.getPeers(), options); + + const payload = { + message: federatedMessage.getMessage(), + federated_user_id: federatedUser.getFederationId(), + }; + + return this.createEventForPeers('msu', payload, peers); + } + + // Create a `deleteMessage(msd)` event + messageDeleted(federatedRoom, federatedMessage, options = {}) { + const peers = normalizePeers(federatedRoom.getPeers(), options); + + const payload = { + federated_message_id: federatedMessage.getFederationId(), + }; + + return this.createEventForPeers('msd', payload, peers); + } + + // Create a `messagesRead(msr)` event + messagesRead(federatedRoom, federatedUser, options = {}) { + const peers = normalizePeers(federatedRoom.getPeers(), options); + + const payload = { + federated_room_id: federatedRoom.getFederationId(), + federated_user_id: federatedUser.getFederationId(), + }; + + return this.createEventForPeers('msr', payload, peers); + } + + // Create a `messagesSetReaction(mrs)` event + messagesSetReaction(federatedRoom, federatedMessage, federatedUser, reaction, shouldReact, options = {}) { + const peers = normalizePeers(federatedRoom.getPeers(), options); + + const payload = { + federated_room_id: federatedRoom.getFederationId(), + federated_message_id: federatedMessage.getFederationId(), + federated_user_id: federatedUser.getFederationId(), + reaction, + shouldReact, + }; + + return this.createEventForPeers('mrs', payload, peers); + } + + // Create a `messagesUnsetReaction(mru)` event + messagesUnsetReaction(federatedRoom, federatedMessage, federatedUser, reaction, shouldReact, options = {}) { + const peers = normalizePeers(federatedRoom.getPeers(), options); + + const payload = { + federated_room_id: federatedRoom.getFederationId(), + federated_message_id: federatedMessage.getFederationId(), + federated_user_id: federatedUser.getFederationId(), + reaction, + shouldReact, + }; + + return this.createEventForPeers('mru', payload, peers); + } + + // Get all unfulfilled events + getUnfulfilled() { + return this.find({ fulfilled: false }, { sort: { ts: 1 } }).fetch(); + } +} + +export const FederationEvents = new FederationEventsModel(); diff --git a/app/models/server/models/FederationKeys.js b/app/models/server/models/FederationKeys.js new file mode 100644 index 0000000000000..8e2e9c26756d2 --- /dev/null +++ b/app/models/server/models/FederationKeys.js @@ -0,0 +1,69 @@ +import NodeRSA from 'node-rsa'; +import uuid from 'uuid/v4'; + +import { Base } from './_Base'; + +class FederationKeysModel extends Base { + constructor() { + super('federation_keys'); + } + + getKey(type) { + const keyResource = this.findOne({ type }); + + if (!keyResource) { return null; } + + return keyResource.key; + } + + loadKey(keyData, type) { + return new NodeRSA(keyData, `pkcs8-${ type }-pem`); + } + + generateKeys() { + const key = new NodeRSA({ b: 512 }); + + key.generateKeyPair(); + + this.update({ type: 'private' }, { type: 'private', key: key.exportKey('pkcs8-private-pem').replace(/\n|\r/g, '') }, { upsert: true }); + + this.update({ type: 'public' }, { type: 'public', key: key.exportKey('pkcs8-public-pem').replace(/\n|\r/g, '') }, { upsert: true }); + + return { + privateKey: this.getPrivateKey(), + publicKey: this.getPublicKey(), + }; + } + + generateUniqueId() { + const uniqueId = uuid(); + + this.update({ type: 'unique' }, { type: 'unique', key: uniqueId }, { upsert: true }); + } + + getUniqueId() { + return (this.findOne({ type: 'unique' }) || {}).key; + } + + getPrivateKey() { + const keyData = this.getKey('private'); + + return keyData && this.loadKey(keyData, 'private'); + } + + getPrivateKeyString() { + return this.getKey('private'); + } + + getPublicKey() { + const keyData = this.getKey('public'); + + return keyData && this.loadKey(keyData, 'public'); + } + + getPublicKeyString() { + return this.getKey('public'); + } +} + +export const FederationKeys = new FederationKeysModel(); diff --git a/app/models/server/models/FederationPeers.js b/app/models/server/models/FederationPeers.js new file mode 100644 index 0000000000000..75acdcd44e77c --- /dev/null +++ b/app/models/server/models/FederationPeers.js @@ -0,0 +1,53 @@ +import { Meteor } from 'meteor/meteor'; + +import { Base } from './_Base'; + +import { Users } from '..'; + +class FederationPeersModel extends Base { + constructor() { + super('federation_peers'); + } + + refreshPeers() { + const collectionObj = this.model.rawCollection(); + const findAndModify = Meteor.wrapAsync(collectionObj.findAndModify, collectionObj); + + const users = Users.find({ federation: { $exists: true } }, { fields: { federation: 1 } }).fetch(); + + const peers = [...new Set(users.map((u) => u.federation.peer))]; + + for (const peer of peers) { + findAndModify({ peer }, [], { + $setOnInsert: { + active: false, + peer, + last_seen_at: null, + last_failure_at: null, + }, + }, { upsert: true }); + } + + this.remove({ peer: { $nin: peers } }); + } + + updateStatuses(seenPeers) { + for (const peer of Object.keys(seenPeers)) { + const seen = seenPeers[peer]; + + const updateQuery = {}; + + if (seen) { + updateQuery.active = true; + updateQuery.last_seen_at = new Date(); + } else { + updateQuery.active = false; + updateQuery.last_failure_at = new Date(); + } + + this.update({ peer }, { $set: updateQuery }); + } + } +} + +export const FederationPeers = new FederationPeersModel(); diff --git a/packages/rocketchat-integrations/server/models/IntegrationHistory.js b/app/models/server/models/IntegrationHistory.js similarity index 85% rename from packages/rocketchat-integrations/server/models/IntegrationHistory.js rename to app/models/server/models/IntegrationHistory.js index 138979e29939a..817deae0d789a 100644 --- a/packages/rocketchat-integrations/server/models/IntegrationHistory.js +++ b/app/models/server/models/IntegrationHistory.js @@ -1,7 +1,8 @@ import { Meteor } from 'meteor/meteor'; -import { RocketChat } from 'meteor/rocketchat:lib'; -RocketChat.models.IntegrationHistory = new class IntegrationHistory extends RocketChat.models._Base { +import { Base } from './_Base'; + +export class IntegrationHistory extends Base { constructor() { super('integration_history'); } @@ -37,4 +38,6 @@ RocketChat.models.IntegrationHistory = new class IntegrationHistory extends Rock removeByIntegrationId(integrationId) { return this.remove({ 'integration._id': integrationId }); } -}; +} + +export default new IntegrationHistory(); diff --git a/app/models/server/models/Integrations.js b/app/models/server/models/Integrations.js new file mode 100644 index 0000000000000..0db8901b9594b --- /dev/null +++ b/app/models/server/models/Integrations.js @@ -0,0 +1,29 @@ +import { Meteor } from 'meteor/meteor'; + +import { Base } from './_Base'; + +export class Integrations extends Base { + constructor() { + super('integrations'); + } + + findByType(type, options) { + if (type !== 'webhook-incoming' && type !== 'webhook-outgoing') { + throw new Meteor.Error('invalid-type-to-find'); + } + + return this.find({ type }, options); + } + + disableByUserId(userId) { + return this.update({ userId }, { $set: { enabled: false } }, { multi: true }); + } + + updateRoomName(oldRoomName, newRoomName) { + const hashedOldRoomName = `#${ oldRoomName }`; + const hashedNewRoomName = `#${ newRoomName }`; + return this.update({ channel: hashedOldRoomName }, { $set: { 'channel.$': hashedNewRoomName } }, { multi: true }); + } +} + +export default new Integrations(); diff --git a/app/models/server/models/LivechatCustomField.js b/app/models/server/models/LivechatCustomField.js new file mode 100644 index 0000000000000..10414f1aa61dd --- /dev/null +++ b/app/models/server/models/LivechatCustomField.js @@ -0,0 +1,47 @@ +import _ from 'underscore'; + +import { Base } from './_Base'; + +/** + * Livechat Custom Fields model + */ +export class LivechatCustomField extends Base { + constructor() { + super('livechat_custom_field'); + } + + // FIND + findOneById(_id, options) { + const query = { _id }; + + return this.findOne(query, options); + } + + createOrUpdateCustomField(_id, field, label, scope, visibility, extraData) { + const record = { + label, + scope, + visibility, + }; + + _.extend(record, extraData); + + if (_id) { + this.update({ _id }, { $set: record }); + } else { + record._id = field; + _id = this.insert(record); + } + + return record; + } + + // REMOVE + removeById(_id) { + const query = { _id }; + + return this.remove(query); + } +} + +export default new LivechatCustomField(); diff --git a/app/models/server/models/LivechatDepartment.js b/app/models/server/models/LivechatDepartment.js new file mode 100644 index 0000000000000..984107ba3cc63 --- /dev/null +++ b/app/models/server/models/LivechatDepartment.js @@ -0,0 +1,98 @@ +import _ from 'underscore'; + +import { Base } from './_Base'; +import LivechatDepartmentAgents from './LivechatDepartmentAgents'; +/** + * Livechat Department model + */ +export class LivechatDepartment extends Base { + constructor() { + super('livechat_department'); + + this.tryEnsureIndex({ + numAgents: 1, + enabled: 1, + }); + } + + // FIND + findOneById(_id, options) { + const query = { _id }; + + return this.findOne(query, options); + } + + findByDepartmentId(_id, options) { + const query = { _id }; + + return this.find(query, options); + } + + createOrUpdateDepartment(_id, { enabled, name, description, showOnRegistration, email, showOnOfflineForm }, agents) { + agents = [].concat(agents); + + const record = { + enabled, + name, + description, + numAgents: agents.length, + showOnRegistration, + showOnOfflineForm, + email, + }; + + if (_id) { + this.update({ _id }, { $set: record }); + } else { + _id = this.insert(record); + } + + const savedAgents = _.pluck(LivechatDepartmentAgents.findByDepartmentId(_id).fetch(), 'agentId'); + const agentsToSave = _.pluck(agents, 'agentId'); + + // remove other agents + _.difference(savedAgents, agentsToSave).forEach((agentId) => { + LivechatDepartmentAgents.removeByDepartmentIdAndAgentId(_id, agentId); + }); + + agents.forEach((agent) => { + LivechatDepartmentAgents.saveAgent({ + agentId: agent.agentId, + departmentId: _id, + username: agent.username, + count: agent.count ? parseInt(agent.count) : 0, + order: agent.order ? parseInt(agent.order) : 0, + }); + }); + + return _.extend(record, { _id }); + } + + // REMOVE + removeById(_id) { + const query = { _id }; + + return this.remove(query); + } + + findEnabledWithAgents() { + const query = { + numAgents: { $gt: 0 }, + enabled: true, + }; + return this.find(query); + } + + findOneByIdOrName(_idOrName, options) { + const query = { + $or: [{ + _id: _idOrName, + }, { + name: _idOrName, + }], + }; + + return this.findOne(query, options); + } +} +export default new LivechatDepartment(); diff --git a/app/models/server/models/LivechatDepartmentAgents.js b/app/models/server/models/LivechatDepartmentAgents.js new file mode 100644 index 0000000000000..7899b6500c985 --- /dev/null +++ b/app/models/server/models/LivechatDepartmentAgents.js @@ -0,0 +1,131 @@ +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { Base } from './_Base'; +import Users from './Users'; +/** + * Livechat Department model + */ +export class LivechatDepartmentAgents extends Base { + constructor() { + super('livechat_department_agents'); + } + + findByDepartmentId(departmentId) { + return this.find({ departmentId }); + } + + saveAgent(agent) { + return this.upsert({ + agentId: agent.agentId, + departmentId: agent.departmentId, + }, { + $set: { + username: agent.username, + count: parseInt(agent.count), + order: parseInt(agent.order), + }, + }); + } + + removeByDepartmentIdAndAgentId(departmentId, agentId) { + this.remove({ departmentId, agentId }); + } + + getNextAgentForDepartment(departmentId) { + const agents = this.findByDepartmentId(departmentId).fetch(); + + if (agents.length === 0) { + return; + } + + const onlineUsers = Users.findOnlineUserFromList(_.pluck(agents, 'username')); + + const onlineUsernames = _.pluck(onlineUsers.fetch(), 'username'); + + const query = { + departmentId, + username: { + $in: onlineUsernames, + }, + }; + + const sort = { + count: 1, + order: 1, + username: 1, + }; + const update = { + $inc: { + count: 1, + }, + }; + + const collectionObj = this.model.rawCollection(); + const findAndModify = Meteor.wrapAsync(collectionObj.findAndModify, collectionObj); + + const agent = findAndModify(query, sort, update); + if (agent && agent.value) { + return { + agentId: agent.value.agentId, + username: agent.value.username, + }; + } + return null; + } + + getOnlineForDepartment(departmentId) { + const agents = this.findByDepartmentId(departmentId).fetch(); + + if (agents.length === 0) { + return; + } + + const onlineUsers = Users.findOnlineUserFromList(_.pluck(agents, 'username')); + + const onlineUsernames = _.pluck(onlineUsers.fetch(), 'username'); + + const query = { + departmentId, + username: { + $in: onlineUsernames, + }, + }; + + return this.find(query); + } + + findUsersInQueue(usersList) { + const query = {}; + + if (!_.isEmpty(usersList)) { + query.username = { + $in: usersList, + }; + } + + const options = { + sort: { + departmentId: 1, + count: 1, + order: 1, + username: 1, + }, + }; + + return this.find(query, options); + } + + replaceUsernameOfAgentByUserId(userId, username) { + const query = { agentId: userId }; + + const update = { + $set: { + username, + }, + }; + + return this.update(query, update, { multi: true }); + } +} +export default new LivechatDepartmentAgents(); diff --git a/packages/rocketchat-livechat/server/models/LivechatOfficeHour.js b/app/models/server/models/LivechatOfficeHour.js similarity index 77% rename from packages/rocketchat-livechat/server/models/LivechatOfficeHour.js rename to app/models/server/models/LivechatOfficeHour.js index 50e2cb5543bd0..ceb24ca79dce3 100644 --- a/packages/rocketchat-livechat/server/models/LivechatOfficeHour.js +++ b/app/models/server/models/LivechatOfficeHour.js @@ -1,6 +1,8 @@ import moment from 'moment'; -class LivechatOfficeHour extends RocketChat.models._Base { +import { Base } from './_Base'; + +export class LivechatOfficeHour extends Base { constructor() { super('livechat_office_hour'); @@ -11,13 +13,13 @@ class LivechatOfficeHour extends RocketChat.models._Base { // if there is nothing in the collection, add defaults if (this.find().count() === 0) { - this.insert({ day : 'Monday', start : '08:00', finish : '20:00', code : 1, open : true }); - this.insert({ day : 'Tuesday', start : '08:00', finish : '20:00', code : 2, open : true }); - this.insert({ day : 'Wednesday', start : '08:00', finish : '20:00', code : 3, open : true }); - this.insert({ day : 'Thursday', start : '08:00', finish : '20:00', code : 4, open : true }); - this.insert({ day : 'Friday', start : '08:00', finish : '20:00', code : 5, open : true }); - this.insert({ day : 'Saturday', start : '08:00', finish : '20:00', code : 6, open : false }); - this.insert({ day : 'Sunday', start : '08:00', finish : '20:00', code : 0, open : false }); + this.insert({ day: 'Monday', start: '08:00', finish: '20:00', code: 1, open: true }); + this.insert({ day: 'Tuesday', start: '08:00', finish: '20:00', code: 2, open: true }); + this.insert({ day: 'Wednesday', start: '08:00', finish: '20:00', code: 3, open: true }); + this.insert({ day: 'Thursday', start: '08:00', finish: '20:00', code: 4, open: true }); + this.insert({ day: 'Friday', start: '08:00', finish: '20:00', code: 5, open: true }); + this.insert({ day: 'Saturday', start: '08:00', finish: '20:00', code: 6, open: false }); + this.insert({ day: 'Sunday', start: '08:00', finish: '20:00', code: 0, open: false }); } } @@ -106,5 +108,4 @@ class LivechatOfficeHour extends RocketChat.models._Base { return finish.isSame(currentTime, 'minute'); } } - -RocketChat.models.LivechatOfficeHour = new LivechatOfficeHour(); +export default new LivechatOfficeHour(); diff --git a/app/models/server/models/LivechatPageVisited.js b/app/models/server/models/LivechatPageVisited.js new file mode 100644 index 0000000000000..6b6e3bf7d70f0 --- /dev/null +++ b/app/models/server/models/LivechatPageVisited.js @@ -0,0 +1,48 @@ +import { Base } from './_Base'; + +/** + * Livechat Page Visited model + */ +class LivechatPageVisited extends Base { + constructor() { + super('livechat_page_visited'); + + this.tryEnsureIndex({ token: 1 }); + this.tryEnsureIndex({ ts: 1 }); + + // keep history for 1 month if the visitor does not register + this.tryEnsureIndex({ expireAt: 1 }, { sparse: 1, expireAfterSeconds: 0 }); + } + + saveByToken(token, pageInfo) { + // keep history of unregistered visitors for 1 month + const keepHistoryMiliseconds = 2592000000; + + return this.insert({ + token, + page: pageInfo, + ts: new Date(), + expireAt: new Date().getTime() + keepHistoryMiliseconds, + }); + } + + findByToken(token) { + return this.find({ token }, { sort: { ts: -1 }, limit: 20 }); + } + + keepHistoryForToken(token) { + return this.update({ + token, + expireAt: { + $exists: true, + }, + }, { + $unset: { + expireAt: 1, + }, + }, { + multi: true, + }); + } +} +export default new LivechatPageVisited(); diff --git a/app/models/server/models/LivechatTrigger.js b/app/models/server/models/LivechatTrigger.js new file mode 100644 index 0000000000000..a5380016caddd --- /dev/null +++ b/app/models/server/models/LivechatTrigger.js @@ -0,0 +1,32 @@ +import { Base } from './_Base'; + +/** + * Livechat Trigger model + */ +export class LivechatTrigger extends Base { + constructor() { + super('livechat_trigger'); + } + + updateById(_id, data) { + return this.update({ _id }, { $set: data }); + } + + removeAll() { + return this.remove({}); + } + + findById(_id) { + return this.find({ _id }); + } + + removeById(_id) { + return this.remove({ _id }); + } + + findEnabled() { + return this.find({ enabled: true }); + } +} + +export default new LivechatTrigger(); diff --git a/packages/rocketchat-livechat/server/models/LivechatVisitors.js b/app/models/server/models/LivechatVisitors.js similarity index 90% rename from packages/rocketchat-livechat/server/models/LivechatVisitors.js rename to app/models/server/models/LivechatVisitors.js index b1838bcc2371d..91e763fac142d 100644 --- a/packages/rocketchat-livechat/server/models/LivechatVisitors.js +++ b/app/models/server/models/LivechatVisitors.js @@ -2,7 +2,10 @@ import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; import s from 'underscore.string'; -class LivechatVisitors extends RocketChat.models._Base { +import { Base } from './_Base'; +import Settings from './Settings'; + +export class LivechatVisitors extends Base { constructor() { super('livechat_visitor'); } @@ -31,6 +34,17 @@ class LivechatVisitors extends RocketChat.models._Base { return this.find(query, options); } + /** + * Find One visitor by _id + */ + findOneById(_id, options) { + const query = { + _id, + }; + + return this.findOne(query, options); + } + /** * Gets visitor by token * @param {string} token - Visitor token @@ -92,7 +106,7 @@ class LivechatVisitors extends RocketChat.models._Base { * @return {string} The next visitor name */ getNextVisitorUsername() { - const settingsRaw = RocketChat.models.Settings.model.rawCollection(); + const settingsRaw = Settings.model.rawCollection(); const findAndModify = Meteor.wrapAsync(settingsRaw.findAndModify, settingsRaw); const query = { @@ -198,6 +212,12 @@ class LivechatVisitors extends RocketChat.models._Base { return this.update({ _id }, update); } + + // REMOVE + removeById(_id) { + const query = { _id }; + return this.remove(query); + } } export default new LivechatVisitors(); diff --git a/app/models/server/models/Messages.js b/app/models/server/models/Messages.js new file mode 100644 index 0000000000000..728186d522069 --- /dev/null +++ b/app/models/server/models/Messages.js @@ -0,0 +1,1118 @@ +import { Match } from 'meteor/check'; +import _ from 'underscore'; + +import { Base } from './_Base'; +import Rooms from './Rooms'; +import { settings } from '../../../settings/server/functions/settings'; +import { FileUpload } from '../../../file-upload/server/lib/FileUpload'; + +export class Messages extends Base { + constructor() { + super('message'); + + this.tryEnsureIndex({ rid: 1, ts: 1 }); + this.tryEnsureIndex({ ts: 1 }); + this.tryEnsureIndex({ 'u._id': 1 }); + this.tryEnsureIndex({ editedAt: 1 }, { sparse: true }); + this.tryEnsureIndex({ 'editedBy._id': 1 }, { sparse: true }); + this.tryEnsureIndex({ rid: 1, t: 1, 'u._id': 1 }); + this.tryEnsureIndex({ expireAt: 1 }, { expireAfterSeconds: 0 }); + this.tryEnsureIndex({ msg: 'text' }); + this.tryEnsureIndex({ 'file._id': 1 }, { sparse: true }); + this.tryEnsureIndex({ 'mentions.username': 1 }, { sparse: true }); + this.tryEnsureIndex({ pinned: 1 }, { sparse: true }); + this.tryEnsureIndex({ snippeted: 1 }, { sparse: true }); + this.tryEnsureIndex({ location: '2dsphere' }); + this.tryEnsureIndex({ slackBotId: 1, slackTs: 1 }, { sparse: true }); + this.tryEnsureIndex({ unread: 1 }, { sparse: true }); + + // discussions + this.tryEnsureIndex({ drid: 1 }, { sparse: true }); + // threads + this.tryEnsureIndex({ tmid: 1 }, { sparse: true }); + this.tryEnsureIndex({ tcount: 1, tlm: 1 }, { sparse: true }); + } + + setReactions(messageId, reactions) { + return this.update({ _id: messageId }, { $set: { reactions } }); + } + + keepHistoryForToken(token) { + return this.update({ + 'navigation.token': token, + expireAt: { + $exists: true, + }, + }, { + $unset: { + expireAt: 1, + }, + }, { + multi: true, + }); + } + + setRoomIdByToken(token, rid) { + return this.update({ + 'navigation.token': token, + rid: null, + }, { + $set: { + rid, + }, + }, { + multi: true, + }); + } + + createRoomArchivedByRoomIdAndUser(roomId, user) { + return this.createWithTypeRoomIdMessageAndUser('room-archived', roomId, '', user); + } + + createRoomUnarchivedByRoomIdAndUser(roomId, user) { + return this.createWithTypeRoomIdMessageAndUser('room-unarchived', roomId, '', user); + } + + unsetReactions(messageId) { + return this.update({ _id: messageId }, { $unset: { reactions: 1 } }); + } + + deleteOldOTRMessages(roomId, ts) { + const query = { rid: roomId, t: 'otr', ts: { $lte: ts } }; + return this.remove(query); + } + + updateOTRAck(_id, otrAck) { + const query = { _id }; + const update = { $set: { otrAck } }; + return this.update(query, update); + } + + setGoogleVisionData(messageId, visionData) { + const updateObj = {}; + for (const index in visionData) { + if (visionData.hasOwnProperty(index)) { + updateObj[`attachments.0.${ index }`] = visionData[index]; + } + } + + return this.update({ _id: messageId }, { $set: updateObj }); + } + + createRoomSettingsChangedWithTypeRoomIdMessageAndUser(type, roomId, message, user, extraData) { + return this.createWithTypeRoomIdMessageAndUser(type, roomId, message, user, extraData); + } + + createRoomRenamedWithRoomIdRoomNameAndUser(roomId, roomName, user, extraData) { + return this.createWithTypeRoomIdMessageAndUser('r', roomId, roomName, user, extraData); + } + + addTranslations(messageId, translations) { + const updateObj = {}; + Object.keys(translations).forEach((key) => { + const translation = translations[key]; + updateObj[`translations.${ key }`] = translation; + }); + return this.update({ _id: messageId }, { $set: updateObj }); + } + + addAttachmentTranslations = function(messageId, attachmentIndex, translations) { + const updateObj = {}; + Object.keys(translations).forEach((key) => { + const translation = translations[key]; + updateObj[`attachments.${ attachmentIndex }.translations.${ key }`] = translation; + }); + return this.update({ _id: messageId }, { $set: updateObj }); + } + + countVisibleByRoomIdBetweenTimestampsInclusive(roomId, afterTimestamp, beforeTimestamp, options) { + const query = { + _hidden: { + $ne: true, + }, + rid: roomId, + ts: { + $gte: afterTimestamp, + $lte: beforeTimestamp, + }, + }; + + return this.find(query, options).count(); + } + + // FIND + findByMention(username, options) { + const query = { 'mentions.username': username }; + + return this.find(query, options); + } + + findFilesByUserId(userId, options = {}) { + const query = { + 'u._id': userId, + 'file._id': { $exists: true }, + }; + return this.find(query, { fields: { 'file._id': 1 }, ...options }); + } + + findFilesByRoomIdPinnedTimestampAndUsers(rid, excludePinned, ignoreDiscussion = true, ts, users = [], options = {}) { + const query = { + rid, + ts, + 'file._id': { $exists: true }, + }; + + if (excludePinned) { + query.pinned = { $ne: true }; + } + + if (ignoreDiscussion) { + query.drid = { $exists: 0 }; + } + + if (users.length) { + query['u.username'] = { $in: users }; + } + + return this.find(query, { fields: { 'file._id': 1 }, ...options }); + } + + findDiscussionByRoomIdPinnedTimestampAndUsers(rid, excludePinned, ts, users = [], options = {}) { + const query = { + rid, + ts, + drid: { $exists: 1 }, + }; + + if (excludePinned) { + query.pinned = { $ne: true }; + } + + if (users.length) { + query['u.username'] = { $in: users }; + } + + return this.find(query, options); + } + + findVisibleByMentionAndRoomId(username, rid, options) { + const query = { + _hidden: { $ne: true }, + 'mentions.username': username, + rid, + }; + + return this.find(query, options); + } + + findVisibleByRoomId(rid, options) { + const query = { + _hidden: { + $ne: true, + }, + + rid, + }; + + return this.find(query, options); + } + + findVisibleThreadByThreadId(tmid, options) { + const query = { + _hidden: { + $ne: true, + }, + + tmid, + }; + + return this.find(query, options); + } + + findVisibleByRoomIdNotContainingTypes(roomId, types, options) { + const query = { + _hidden: { + $ne: true, + }, + + rid: roomId, + }; + + if (Match.test(types, [String]) && (types.length > 0)) { + query.t = { $nin: types }; + } + + return this.find(query, options); + } + + findInvisibleByRoomId(roomId, options) { + const query = { + _hidden: true, + rid: roomId, + }; + + return this.find(query, options); + } + + findVisibleByRoomIdAfterTimestamp(roomId, timestamp, options) { + const query = { + _hidden: { + $ne: true, + }, + rid: roomId, + ts: { + $gt: timestamp, + }, + }; + + return this.find(query, options); + } + + findForUpdates(roomId, timestamp, options) { + const query = { + _hidden: { + $ne: true, + }, + rid: roomId, + _updatedAt: { + $gt: timestamp, + }, + }; + return this.find(query, options); + } + + findVisibleByRoomIdBeforeTimestamp(roomId, timestamp, options) { + const query = { + _hidden: { + $ne: true, + }, + rid: roomId, + ts: { + $lt: timestamp, + }, + }; + + return this.find(query, options); + } + + findVisibleByRoomIdBeforeTimestampInclusive(roomId, timestamp, options) { + const query = { + _hidden: { + $ne: true, + }, + rid: roomId, + ts: { + $lte: timestamp, + }, + }; + + return this.find(query, options); + } + + findVisibleByRoomIdBetweenTimestamps(roomId, afterTimestamp, beforeTimestamp, options) { + const query = { + _hidden: { + $ne: true, + }, + rid: roomId, + ts: { + $gt: afterTimestamp, + $lt: beforeTimestamp, + }, + }; + + return this.find(query, options); + } + + findVisibleByRoomIdBetweenTimestampsInclusive(roomId, afterTimestamp, beforeTimestamp, options) { + const query = { + _hidden: { + $ne: true, + }, + rid: roomId, + ts: { + $gte: afterTimestamp, + $lte: beforeTimestamp, + }, + }; + + return this.find(query, options); + } + + findVisibleByRoomIdBeforeTimestampNotContainingTypes(roomId, timestamp, types, options) { + const query = { + _hidden: { + $ne: true, + }, + rid: roomId, + ts: { + $lt: timestamp, + }, + }; + + if (Match.test(types, [String]) && (types.length > 0)) { + query.t = { $nin: types }; + } + + return this.find(query, options); + } + + findVisibleByRoomIdBetweenTimestampsNotContainingTypes(roomId, afterTimestamp, beforeTimestamp, types, options) { + const query = { + _hidden: { + $ne: true, + }, + rid: roomId, + ts: { + $gt: afterTimestamp, + $lt: beforeTimestamp, + }, + }; + + if (Match.test(types, [String]) && (types.length > 0)) { + query.t = { $nin: types }; + } + + return this.find(query, options); + } + + findVisibleCreatedOrEditedAfterTimestamp(timestamp, options) { + const query = { + _hidden: { $ne: true }, + $or: [{ + ts: { + $gt: timestamp, + }, + }, + { + editedAt: { + $gt: timestamp, + }, + }, + ], + }; + + return this.find(query, options); + } + + findStarredByUserAtRoom(userId, roomId, options) { + const query = { + _hidden: { $ne: true }, + 'starred._id': userId, + rid: roomId, + }; + + return this.find(query, options); + } + + findPinnedByRoom(roomId, options) { + const query = { + t: { $ne: 'rm' }, + _hidden: { $ne: true }, + pinned: true, + rid: roomId, + }; + + return this.find(query, options); + } + + findSnippetedByRoom(roomId, options) { + const query = { + _hidden: { $ne: true }, + snippeted: true, + rid: roomId, + }; + + return this.find(query, options); + } + + getLastTimestamp(options) { + if (options == null) { options = {}; } + const query = { ts: { $exists: 1 } }; + options.sort = { ts: -1 }; + options.limit = 1; + const [message] = this.find(query, options).fetch(); + return message && message.ts; + } + + findByRoomIdAndMessageIds(rid, messageIds, options) { + const query = { + rid, + _id: { + $in: messageIds, + }, + }; + + return this.find(query, options); + } + + findOneBySlackBotIdAndSlackTs(slackBotId, slackTs) { + const query = { + slackBotId, + slackTs, + }; + + return this.findOne(query); + } + + findOneBySlackTs(slackTs) { + const query = { slackTs }; + + return this.findOne(query); + } + + findByRoomIdAndType(roomId, type, options) { + const query = { + rid: roomId, + t: type, + }; + + if (options == null) { options = {}; } + + return this.find(query, options); + } + + findByRoomId(roomId, options) { + const query = { + rid: roomId, + }; + + return this.find(query, options); + } + + getLastVisibleMessageSentWithNoTypeByRoomId(rid, messageId) { + const query = { + rid, + _hidden: { $ne: true }, + t: { $exists: false }, + }; + + if (messageId) { + query._id = { $ne: messageId }; + } + + const options = { + sort: { + ts: -1, + }, + }; + + return this.findOne(query, options); + } + + cloneAndSaveAsHistoryById(_id, user) { + const record = this.findOneById(_id); + record._hidden = true; + record.parent = record._id; + record.editedAt = new Date(); + record.editedBy = { + _id: user._id, + username: user.username, + }; + delete record._id; + return this.insert(record); + } + + // UPDATE + setHiddenById(_id, hidden) { + if (hidden == null) { hidden = true; } + const query = { _id }; + + const update = { + $set: { + _hidden: hidden, + }, + }; + + return this.update(query, update); + } + + setAsDeletedByIdAndUser(_id, user) { + const query = { _id }; + + const update = { + $set: { + msg: '', + t: 'rm', + urls: [], + mentions: [], + attachments: [], + reactions: [], + editedAt: new Date(), + editedBy: { + _id: user._id, + username: user.username, + }, + }, + }; + + return this.update(query, update); + } + + setPinnedByIdAndUserId(_id, pinnedBy, pinned, pinnedAt) { + if (pinned == null) { pinned = true; } + if (pinnedAt == null) { pinnedAt = 0; } + const query = { _id }; + + const update = { + $set: { + pinned, + pinnedAt: pinnedAt || new Date(), + pinnedBy, + }, + }; + + return this.update(query, update); + } + + setSnippetedByIdAndUserId(message, snippetName, snippetedBy, snippeted, snippetedAt) { + if (snippeted == null) { snippeted = true; } + if (snippetedAt == null) { snippetedAt = 0; } + const query = { _id: message._id }; + + const msg = `\`\`\`${ message.msg }\`\`\``; + + const update = { + $set: { + msg, + snippeted, + snippetedAt: snippetedAt || new Date(), + snippetedBy, + snippetName, + }, + }; + + return this.update(query, update); + } + + setUrlsById(_id, urls) { + const query = { _id }; + + const update = { + $set: { + urls, + }, + }; + + return this.update(query, update); + } + + updateAllUsernamesByUserId(userId, username) { + const query = { 'u._id': userId }; + + const update = { + $set: { + 'u.username': username, + }, + }; + + return this.update(query, update, { multi: true }); + } + + updateUsernameOfEditByUserId(userId, username) { + const query = { 'editedBy._id': userId }; + + const update = { + $set: { + 'editedBy.username': username, + }, + }; + + return this.update(query, update, { multi: true }); + } + + updateUsernameAndMessageOfMentionByIdAndOldUsername(_id, oldUsername, newUsername, newMessage) { + const query = { + _id, + 'mentions.username': oldUsername, + }; + + const update = { + $set: { + 'mentions.$.username': newUsername, + msg: newMessage, + }, + }; + + return this.update(query, update); + } + + updateUserStarById(_id, userId, starred) { + let update; + const query = { _id }; + + if (starred) { + update = { + $addToSet: { + starred: { _id: userId }, + }, + }; + } else { + update = { + $pull: { + starred: { _id: userId }, + }, + }; + } + + return this.update(query, update); + } + + upgradeEtsToEditAt() { + const query = { ets: { $exists: 1 } }; + + const update = { + $rename: { + ets: 'editedAt', + }, + }; + + return this.update(query, update, { multi: true }); + } + + setMessageAttachments(_id, attachments) { + const query = { _id }; + + const update = { + $set: { + attachments, + }, + }; + + return this.update(query, update); + } + + setSlackBotIdAndSlackTs(_id, slackBotId, slackTs) { + const query = { _id }; + + const update = { + $set: { + slackBotId, + slackTs, + }, + }; + + return this.update(query, update); + } + + unlinkUserId(userId, newUserId, newUsername, newNameAlias) { + const query = { + 'u._id': userId, + }; + + const update = { + $set: { + alias: newNameAlias, + 'u._id': newUserId, + 'u.username': newUsername, + 'u.name': undefined, + }, + }; + + return this.update(query, update, { multi: true }); + } + + // INSERT + createWithTypeRoomIdMessageAndUser(type, roomId, message, user, extraData) { + const room = Rooms.findOneById(roomId, { fields: { sysMes: 1 } }); + if ((room != null ? room.sysMes : undefined) === false) { + return; + } + const record = { + t: type, + rid: roomId, + ts: new Date(), + msg: message, + u: { + _id: user._id, + username: user.username, + }, + groupable: false, + }; + + if (settings.get('Message_Read_Receipt_Enabled')) { + record.unread = true; + } + + _.extend(record, extraData); + + record._id = this.insertOrUpsert(record); + Rooms.incMsgCountById(roomId, 1); + return record; + } + + createNavigationHistoryWithRoomIdMessageAndUser(roomId, message, user, extraData) { + const type = 'livechat_navigation_history'; + const room = Rooms.findOneById(roomId, { fields: { sysMes: 1 } }); + if ((room != null ? room.sysMes : undefined) === false) { + return; + } + const record = { + t: type, + rid: roomId, + ts: new Date(), + msg: message, + u: { + _id: user._id, + username: user.username, + }, + groupable: false, + }; + + if (settings.get('Message_Read_Receipt_Enabled')) { + record.unread = true; + } + + _.extend(record, extraData); + + record._id = this.insertOrUpsert(record); + return record; + } + + createUserJoinWithRoomIdAndUser(roomId, user, extraData) { + const message = user.username; + return this.createWithTypeRoomIdMessageAndUser('uj', roomId, message, user, extraData); + } + + createUserJoinWithRoomIdAndUserDiscussion(roomId, user, extraData) { + const message = user.username; + return this.createWithTypeRoomIdMessageAndUser('ut', roomId, message, user, extraData); + } + + createUserLeaveWithRoomIdAndUser(roomId, user, extraData) { + const message = user.username; + return this.createWithTypeRoomIdMessageAndUser('ul', roomId, message, user, extraData); + } + + createUserRemovedWithRoomIdAndUser(roomId, user, extraData) { + const message = user.username; + return this.createWithTypeRoomIdMessageAndUser('ru', roomId, message, user, extraData); + } + + createUserAddedWithRoomIdAndUser(roomId, user, extraData) { + const message = user.username; + return this.createWithTypeRoomIdMessageAndUser('au', roomId, message, user, extraData); + } + + createCommandWithRoomIdAndUser(command, roomId, user, extraData) { + return this.createWithTypeRoomIdMessageAndUser('command', roomId, command, user, extraData); + } + + createUserMutedWithRoomIdAndUser(roomId, user, extraData) { + const message = user.username; + return this.createWithTypeRoomIdMessageAndUser('user-muted', roomId, message, user, extraData); + } + + createUserUnmutedWithRoomIdAndUser(roomId, user, extraData) { + const message = user.username; + return this.createWithTypeRoomIdMessageAndUser('user-unmuted', roomId, message, user, extraData); + } + + createNewModeratorWithRoomIdAndUser(roomId, user, extraData) { + const message = user.username; + return this.createWithTypeRoomIdMessageAndUser('new-moderator', roomId, message, user, extraData); + } + + createModeratorRemovedWithRoomIdAndUser(roomId, user, extraData) { + const message = user.username; + return this.createWithTypeRoomIdMessageAndUser('moderator-removed', roomId, message, user, extraData); + } + + createNewOwnerWithRoomIdAndUser(roomId, user, extraData) { + const message = user.username; + return this.createWithTypeRoomIdMessageAndUser('new-owner', roomId, message, user, extraData); + } + + createOwnerRemovedWithRoomIdAndUser(roomId, user, extraData) { + const message = user.username; + return this.createWithTypeRoomIdMessageAndUser('owner-removed', roomId, message, user, extraData); + } + + createNewLeaderWithRoomIdAndUser(roomId, user, extraData) { + const message = user.username; + return this.createWithTypeRoomIdMessageAndUser('new-leader', roomId, message, user, extraData); + } + + createLeaderRemovedWithRoomIdAndUser(roomId, user, extraData) { + const message = user.username; + return this.createWithTypeRoomIdMessageAndUser('leader-removed', roomId, message, user, extraData); + } + + createSubscriptionRoleAddedWithRoomIdAndUser(roomId, user, extraData) { + const message = user.username; + return this.createWithTypeRoomIdMessageAndUser('subscription-role-added', roomId, message, user, extraData); + } + + createSubscriptionRoleRemovedWithRoomIdAndUser(roomId, user, extraData) { + const message = user.username; + return this.createWithTypeRoomIdMessageAndUser('subscription-role-removed', roomId, message, user, extraData); + } + + createRejectedMessageByPeer(roomId, user, extraData) { + const message = user.username; + return this.createWithTypeRoomIdMessageAndUser('rejected-message-by-peer', roomId, message, user, extraData); + } + + createPeerDoesNotExist(roomId, user, extraData) { + const message = user.username; + return this.createWithTypeRoomIdMessageAndUser('peer-does-not-exist', roomId, message, user, extraData); + } + + // REMOVE + removeById(_id) { + const query = { _id }; + + return this.remove(query); + } + + removeByRoomId(roomId) { + const query = { rid: roomId }; + + return this.remove(query); + } + + removeByIdPinnedTimestampLimitAndUsers(rid, pinned, ignoreDiscussion = true, ts, limit, users = []) { + const query = { + rid, + ts, + }; + + if (pinned) { + query.pinned = { $ne: true }; + } + + if (ignoreDiscussion) { + query.drid = { $exists: 0 }; + } + + if (users.length) { + query['u.username'] = { $in: users }; + } + + if (!limit) { + return this.remove(query); + } + + const messagesToDelete = this.find(query, { + fields: { + _id: 1, + }, + limit, + }).map(({ _id }) => _id); + + return this.remove({ + _id: { + $in: messagesToDelete, + }, + }); + } + + removeByUserId(userId) { + const query = { 'u._id': userId }; + + return this.remove(query); + } + + async removeFilesByRoomId(roomId) { + this.find({ + rid: roomId, + 'file._id': { + $exists: true, + }, + }, { + fields: { + 'file._id': 1, + }, + }).fetch().forEach((document) => FileUpload.getStore('Uploads').deleteById(document.file._id)); + } + + getMessageByFileId(fileID) { + return this.findOne({ 'file._id': fileID }); + } + + setAsRead(rid, until) { + return this.update({ + rid, + unread: true, + ts: { $lt: until }, + }, { + $unset: { + unread: 1, + }, + }, { + multi: true, + }); + } + + setAsReadById(_id) { + return this.update({ + _id, + }, { + $unset: { + unread: 1, + }, + }); + } + + findUnreadMessagesByRoomAndDate(rid, after) { + const query = { + unread: true, + rid, + }; + + if (after) { + query.ts = { $gt: after }; + } + + return this.find(query, { + fields: { + _id: 1, + }, + }); + } + + /** + * Copy metadata from the discussion to the system message in the parent channel + * which links to the discussion. + * Since we don't pass this metadata into the model's function, it is not a subject + * to race conditions: If multiple updates occur, the current state will be updated + * only if the new state of the discussion room is really newer. + */ + refreshDiscussionMetadata({ rid }) { + if (!rid) { + return false; + } + const { lm: dlm, msgs: dcount } = Rooms.findOneById(rid, { + fields: { + msgs: 1, + lm: 1, + }, + }); + + const query = { + drid: rid, + }; + + return this.update(query, { + $set: { + dcount, + dlm, + }, + }, { multi: 1 }); + } + + // ////////////////////////////////////////////////////////////////// + // threads + + countThreads() { + return this.find({ tcount: { $exists: true } }).count(); + } + + removeThreadRefByThreadId(tmid) { + const query = { tmid }; + const update = { + $unset: { + tmid: 1, + }, + }; + return this.update(query, update, { multi: true }); + } + + updateRepliesByThreadId(tmid, replies, ts) { + const query = { + _id: tmid, + }; + + const update = { + $addToSet: { + replies: { + $each: replies, + }, + }, + $set: { + tlm: ts, + }, + $inc: { + tcount: 1, + }, + }; + + return this.update(query, update); + } + + getThreadFollowsByThreadId(tmid) { + const msg = this.findOneById(tmid, { fields: { replies: 1 } }); + return msg && msg.replies; + } + + getFirstReplyTsByThreadId(tmid) { + return this.findOne({ tmid }, { fields: { ts: 1 }, sort: { ts: 1 } }); + } + + unsetThreadByThreadId(tmid) { + const query = { + _id: tmid, + }; + + const update = { + $unset: { + tcount: 1, + tlm: 1, + replies: 1, + }, + }; + + return this.update(query, update); + } + + updateThreadLastMessageAndCountByThreadId(tmid, tlm, tcount) { + const query = { + _id: tmid, + }; + + const update = { + $set: { + tlm, + }, + $inc: { + tcount, + }, + }; + + return this.update(query, update); + } + + addThreadFollowerByThreadId(tmid, userId) { + const query = { + _id: tmid, + }; + + const update = { + $addToSet: { + replies: userId, + }, + }; + + return this.update(query, update); + } + + removeThreadFollowerByThreadId(tmid, userId) { + const query = { + _id: tmid, + }; + + const update = { + $pull: { + replies: userId, + }, + }; + + return this.update(query, update); + } + + findThreadsByRoomId(rid, skip, limit) { + return this.find({ rid, tcount: { $exists: true } }, { sort: { tlm: -1 }, skip, limit }); + } +} + +export default new Messages(); diff --git a/app/models/server/models/OAuthApps.js b/app/models/server/models/OAuthApps.js new file mode 100644 index 0000000000000..6aedffb63ae07 --- /dev/null +++ b/app/models/server/models/OAuthApps.js @@ -0,0 +1,9 @@ +import { Base } from './_Base'; + +export class OAuthApps extends Base { + constructor() { + super('oauth_apps'); + } +} + +export default new OAuthApps(); diff --git a/packages/rocketchat-oembed/server/models/OEmbedCache.js b/app/models/server/models/OEmbedCache.js similarity index 75% rename from packages/rocketchat-oembed/server/models/OEmbedCache.js rename to app/models/server/models/OEmbedCache.js index 7f83342b51b2b..db4383b9cd34c 100644 --- a/packages/rocketchat-oembed/server/models/OEmbedCache.js +++ b/app/models/server/models/OEmbedCache.js @@ -1,6 +1,6 @@ -import { RocketChat } from 'meteor/rocketchat:lib'; +import { Base } from './_Base'; -RocketChat.models.OEmbedCache = new class extends RocketChat.models._Base { +export class OEmbedCache extends Base { constructor() { super('oembed_cache'); this.tryEnsureIndex({ updatedAt: 1 }); @@ -19,7 +19,7 @@ RocketChat.models.OEmbedCache = new class extends RocketChat.models._Base { const record = { _id, data, - updatedAt: new Date, + updatedAt: new Date(), }; record._id = this.insert(record); return record; @@ -34,6 +34,6 @@ RocketChat.models.OEmbedCache = new class extends RocketChat.models._Base { }; return this.remove(query); } -}; - +} +export default new OEmbedCache(); diff --git a/app/models/server/models/Permissions.js b/app/models/server/models/Permissions.js new file mode 100644 index 0000000000000..defeabe5b5932 --- /dev/null +++ b/app/models/server/models/Permissions.js @@ -0,0 +1,30 @@ +import { Base } from './_Base'; + +export class Permissions extends Base { + // FIND + findByRole(role, options) { + const query = { + roles: role, + }; + + return this.find(query, options); + } + + findOneById(_id) { + return this.findOne(_id); + } + + createOrUpdate(name, roles) { + this.upsert({ _id: name }, { $set: { roles } }); + } + + addRole(permission, role) { + this.update({ _id: permission }, { $addToSet: { roles: role } }); + } + + removeRole(permission, role) { + this.update({ _id: permission }, { $pull: { roles: role } }); + } +} + +export default new Permissions('permissions'); diff --git a/app/models/server/models/ReadReceipts.js b/app/models/server/models/ReadReceipts.js new file mode 100644 index 0000000000000..717f4d91afc4c --- /dev/null +++ b/app/models/server/models/ReadReceipts.js @@ -0,0 +1,21 @@ +import { Base } from './_Base'; + +export class ReadReceipts extends Base { + constructor(...args) { + super(...args); + + this.tryEnsureIndex({ + roomId: 1, + userId: 1, + messageId: 1, + }, { + unique: 1, + }); + } + + findByMessageId(messageId) { + return this.find({ messageId }); + } +} + +export default new ReadReceipts('message_read_receipt'); diff --git a/packages/rocketchat-lib/server/models/Reports.js b/app/models/server/models/Reports.js similarity index 75% rename from packages/rocketchat-lib/server/models/Reports.js rename to app/models/server/models/Reports.js index 923d0dccee3eb..4d2ab019f19b7 100644 --- a/packages/rocketchat-lib/server/models/Reports.js +++ b/app/models/server/models/Reports.js @@ -1,9 +1,12 @@ import _ from 'underscore'; -RocketChat.models.Reports = new class extends RocketChat.models._Base { +import { Base } from './_Base'; + +export class Reports extends Base { constructor() { super('reports'); } + createWithMessageDescriptionAndUserId(message, description, userId, extraData) { const record = { message, @@ -15,4 +18,6 @@ RocketChat.models.Reports = new class extends RocketChat.models._Base { record._id = this.insert(record); return record; } -}; +} + +export default new Reports(); diff --git a/app/models/server/models/Roles.js b/app/models/server/models/Roles.js new file mode 100644 index 0000000000000..579d157208806 --- /dev/null +++ b/app/models/server/models/Roles.js @@ -0,0 +1,88 @@ +import * as Models from '..'; + +import { Base } from './_Base'; + +export class Roles extends Base { + constructor(...args) { + super(...args); + this.tryEnsureIndex({ name: 1 }); + this.tryEnsureIndex({ scope: 1 }); + } + + findUsersInRole(name, scope, options) { + const role = this.findOne(name); + const roleScope = (role && role.scope) || 'Users'; + const model = Models[roleScope]; + + return model && model.findUsersInRoles && model.findUsersInRoles(name, scope, options); + } + + isUserInRoles(userId, roles, scope) { + roles = [].concat(roles); + return roles.some((roleName) => { + const role = this.findOne(roleName); + const roleScope = (role && role.scope) || 'Users'; + const model = Models[roleScope]; + + return model && model.isUserInRole && model.isUserInRole(userId, roleName, scope); + }); + } + + createOrUpdate(name, scope = 'Users', description, protectedRole, mandatory2fa) { + const updateData = {}; + updateData.name = name; + updateData.scope = scope; + + if (description != null) { + updateData.description = description; + } + + if (protectedRole) { + updateData.protected = protectedRole; + } + + if (mandatory2fa != null) { + updateData.mandatory2fa = mandatory2fa; + } + + this.upsert({ _id: name }, { $set: updateData }); + } + + addUserRoles(userId, roles, scope) { + roles = [].concat(roles); + for (const roleName of roles) { + const role = this.findOne(roleName); + const roleScope = (role && role.scope) || 'Users'; + const model = Models[roleScope]; + + model && model.addRolesByUserId && model.addRolesByUserId(userId, roleName, scope); + } + return true; + } + + removeUserRoles(userId, roles, scope) { + roles = [].concat(roles); + for (const roleName of roles) { + const role = this.findOne(roleName); + const roleScope = (role && role.scope) || 'Users'; + const model = Models[roleScope]; + + model && model.removeRolesByUserId && model.removeRolesByUserId(userId, roleName, scope); + } + return true; + } + + findOneByIdOrName(_idOrName, options) { + const query = { + $or: [{ + _id: _idOrName, + }, { + name: _idOrName, + }], + }; + + return this.findOne(query, options); + } +} + +export default new Roles('roles'); diff --git a/app/models/server/models/Rooms.js b/app/models/server/models/Rooms.js new file mode 100644 index 0000000000000..2d7f387709323 --- /dev/null +++ b/app/models/server/models/Rooms.js @@ -0,0 +1,1422 @@ +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; +import s from 'underscore.string'; + +import { Base } from './_Base'; +import Messages from './Messages'; +import Subscriptions from './Subscriptions'; +import Settings from './Settings'; + +export class Rooms extends Base { + constructor(...args) { + super(...args); + + this.tryEnsureIndex({ name: 1 }, { unique: true, sparse: true }); + this.tryEnsureIndex({ default: 1 }); + this.tryEnsureIndex({ t: 1 }); + this.tryEnsureIndex({ 'u._id': 1 }); + this.tryEnsureIndex({ 'tokenpass.tokens.token': 1 }); + this.tryEnsureIndex({ open: 1 }, { sparse: true }); + this.tryEnsureIndex({ departmentId: 1 }, { sparse: true }); + this.tryEnsureIndex({ ts: 1 }); + + // discussions + this.tryEnsureIndex({ prid: 1 }, { sparse: true }); + } + + findOneByIdOrName(_idOrName, options) { + const query = { + $or: [{ + _id: _idOrName, + }, { + name: _idOrName, + }], + }; + + return this.findOne(query, options); + } + + updateSurveyFeedbackById(_id, surveyFeedback) { + const query = { + _id, + }; + + const update = { + $set: { + surveyFeedback, + }, + }; + + return this.update(query, update); + } + + updateLivechatDataByToken(token, key, value, overwrite = true) { + const query = { + 'v.token': token, + open: true, + }; + + if (!overwrite) { + const room = this.findOne(query, { fields: { livechatData: 1 } }); + if (room.livechatData && typeof room.livechatData[key] !== 'undefined') { + return true; + } + } + + const update = { + $set: { + [`livechatData.${ key }`]: value, + }, + }; + + return this.update(query, update); + } + + findLivechat(filter = {}, offset = 0, limit = 20) { + const query = _.extend(filter, { + t: 'l', + }); + + return this.find(query, { sort: { ts: - 1 }, offset, limit }); + } + + findLivechatById(_id, fields) { + const options = {}; + + if (fields) { + options.fields = fields; + } + + const query = { + t: 'l', + _id, + }; + + return this.find(query, options); + } + + findOneLivechatById(_id, fields) { + const options = {}; + + if (fields) { + options.fields = fields; + } + + const query = { + t: 'l', + _id, + }; + + return this.findOne(query, options); + } + + findLivechatByIdAndVisitorToken(_id, visitorToken, fields) { + const options = {}; + + if (fields) { + options.fields = fields; + } + + const query = { + t: 'l', + _id, + 'v.token': visitorToken, + }; + + return this.findOne(query, options); + } + + findLivechatByVisitorToken(visitorToken, fields) { + const options = {}; + + if (fields) { + options.fields = fields; + } + + const query = { + t: 'l', + 'v.token': visitorToken, + }; + + return this.findOne(query, options); + } + + updateLivechatRoomCount = function() { + const settingsRaw = Settings.model.rawCollection(); + const findAndModify = Meteor.wrapAsync(settingsRaw.findAndModify, settingsRaw); + + const query = { + _id: 'Livechat_Room_Count', + }; + + const update = { + $inc: { + value: 1, + }, + }; + + const livechatCount = findAndModify(query, null, update); + + return livechatCount.value.value; + } + + findOpenByVisitorToken(visitorToken, options) { + const query = { + open: true, + 'v.token': visitorToken, + }; + + return this.find(query, options); + } + + findOpenByVisitorTokenAndDepartmentId(visitorToken, departmentId, options) { + const query = { + open: true, + 'v.token': visitorToken, + departmentId, + }; + + return this.find(query, options); + } + + findByVisitorToken(visitorToken) { + const query = { + 'v.token': visitorToken, + }; + + return this.find(query); + } + + findByVisitorId(visitorId) { + const query = { + 'v._id': visitorId, + }; + + return this.find(query); + } + + findOneOpenByRoomIdAndVisitorToken(roomId, visitorToken, options) { + const query = { + _id: roomId, + open: true, + 'v.token': visitorToken, + }; + + return this.findOne(query, options); + } + + setResponseByRoomId(roomId, response) { + return this.update({ + _id: roomId, + }, { + $set: { + responseBy: { + _id: response.user._id, + username: response.user.username, + }, + }, + $unset: { + waitingResponse: 1, + }, + }); + } + + saveAnalyticsDataByRoomId(room, message, analyticsData) { + const update = { + $set: {}, + }; + + if (analyticsData) { + update.$set['metrics.response.avg'] = analyticsData.avgResponseTime; + + update.$inc = {}; + update.$inc['metrics.response.total'] = 1; + update.$inc['metrics.response.tt'] = analyticsData.responseTime; + update.$inc['metrics.reaction.tt'] = analyticsData.reactionTime; + } + + if (analyticsData && analyticsData.firstResponseTime) { + update.$set['metrics.response.fd'] = analyticsData.firstResponseDate; + update.$set['metrics.response.ft'] = analyticsData.firstResponseTime; + update.$set['metrics.reaction.fd'] = analyticsData.firstReactionDate; + update.$set['metrics.reaction.ft'] = analyticsData.firstReactionTime; + } + + // livechat analytics : update last message timestamps + const visitorLastQuery = room.metrics && room.metrics.v ? room.metrics.v.lq : room.ts; + const agentLastReply = room.metrics && room.metrics.servedBy ? room.metrics.servedBy.lr : room.ts; + + if (message.token) { // update visitor timestamp, only if its new inquiry and not continuing message + if (agentLastReply >= visitorLastQuery) { // if first query, not continuing query from visitor + update.$set['metrics.v.lq'] = message.ts; + } + } else if (visitorLastQuery > agentLastReply) { // update agent timestamp, if first response, not continuing + update.$set['metrics.servedBy.lr'] = message.ts; + } + + return this.update({ + _id: room._id, + }, update); + } + + getTotalConversationsBetweenDate(t, date) { + const query = { + t, + ts: { + $gte: new Date(date.gte), // ISO Date, ts >= date.gte + $lt: new Date(date.lt), // ISODate, ts < date.lt + }, + }; + + return this.find(query).count(); + } + + getAnalyticsMetricsBetweenDate(t, date) { + const query = { + t, + ts: { + $gte: new Date(date.gte), // ISO Date, ts >= date.gte + $lt: new Date(date.lt), // ISODate, ts < date.lt + }, + }; + + return this.find(query, { fields: { ts: 1, departmentId: 1, open: 1, servedBy: 1, metrics: 1, msgs: 1 } }); + } + + closeByRoomId(roomId, closeInfo) { + return this.update({ + _id: roomId, + }, { + $set: { + closer: closeInfo.closer, + closedBy: closeInfo.closedBy, + closedAt: closeInfo.closedAt, + 'metrics.chatDuration': closeInfo.chatDuration, + 'v.status': 'offline', + }, + $unset: { + open: 1, + }, + }); + } + + findOpenByAgent(userId) { + const query = { + open: true, + 'servedBy._id': userId, + }; + + return this.find(query); + } + + changeAgentByRoomId(roomId, newAgent) { + const query = { + _id: roomId, + }; + const update = { + $set: { + servedBy: { + _id: newAgent.agentId, + username: newAgent.username, + ts: new Date(), + }, + }, + }; + + if (newAgent.ts) { + update.$set.servedBy.ts = newAgent.ts; + } + + this.update(query, update); + } + + changeDepartmentIdByRoomId(roomId, departmentId) { + const query = { + _id: roomId, + }; + const update = { + $set: { + departmentId, + }, + }; + + this.update(query, update); + } + + saveCRMDataByRoomId(roomId, crmData) { + const query = { + _id: roomId, + }; + const update = { + $set: { + crmData, + }, + }; + + return this.update(query, update); + } + + updateVisitorStatus(token, status) { + const query = { + 'v.token': token, + open: true, + }; + + const update = { + $set: { + 'v.status': status, + }, + }; + + return this.update(query, update); + } + + removeAgentByRoomId(roomId) { + const query = { + _id: roomId, + }; + const update = { + $unset: { + servedBy: 1, + }, + }; + + this.update(query, update); + } + + removeByVisitorToken(token) { + const query = { + 'v.token': token, + }; + + this.remove(query); + } + + setJitsiTimeout(_id, time) { + const query = { + _id, + }; + + const update = { + $set: { + jitsiTimeout: time, + }, + }; + + return this.update(query, update); + } + + findByTokenpass(tokens) { + const query = { + 'tokenpass.tokens.token': { + $in: tokens, + }, + }; + + return this._db.find(query).fetch(); + } + + setTokensById(_id, tokens) { + const update = { + $set: { + 'tokenpass.tokens.token': tokens, + }, + }; + + return this.update({ _id }, update); + } + + findAllTokenChannels() { + const query = { + tokenpass: { $exists: true }, + }; + const options = { + fields: { + tokenpass: 1, + }, + }; + return this._db.find(query, options); + } + + setReactionsInLastMessage(roomId, lastMessage) { + return this.update({ _id: roomId }, { $set: { 'lastMessage.reactions': lastMessage.reactions } }); + } + + unsetReactionsInLastMessage(roomId) { + return this.update({ _id: roomId }, { $unset: { lastMessage: { reactions: 1 } } }); + } + + updateLastMessageStar(roomId, userId, starred) { + let update; + const query = { _id: roomId }; + + if (starred) { + update = { + $addToSet: { + 'lastMessage.starred': { _id: userId }, + }, + }; + } else { + update = { + $pull: { + 'lastMessage.starred': { _id: userId }, + }, + }; + } + + return this.update(query, update); + } + + setLastMessageSnippeted(roomId, message, snippetName, snippetedBy, snippeted, snippetedAt) { + const query = { _id: roomId }; + + const msg = `\`\`\`${ message.msg }\`\`\``; + + const update = { + $set: { + 'lastMessage.msg': msg, + 'lastMessage.snippeted': snippeted, + 'lastMessage.snippetedAt': snippetedAt || new Date(), + 'lastMessage.snippetedBy': snippetedBy, + 'lastMessage.snippetName': snippetName, + }, + }; + + return this.update(query, update); + } + + setLastMessagePinned(roomId, pinnedBy, pinned, pinnedAt) { + const query = { _id: roomId }; + + const update = { + $set: { + 'lastMessage.pinned': pinned, + 'lastMessage.pinnedAt': pinnedAt || new Date(), + 'lastMessage.pinnedBy': pinnedBy, + }, + }; + + return this.update(query, update); + } + + setLastMessageAsRead(roomId) { + return this.update({ + _id: roomId, + }, { + $unset: { + 'lastMessage.unread': 1, + }, + }); + } + + setSentiment(roomId, sentiment) { + return this.update({ _id: roomId }, { $set: { sentiment } }); + } + + setDescriptionById(_id, description) { + const query = { + _id, + }; + const update = { + $set: { + description, + }, + }; + return this.update(query, update); + } + + setStreamingOptionsById(_id, streamingOptions) { + const update = { + $set: { + streamingOptions, + }, + }; + return this.update({ _id }, update); + } + + setTokenpassById(_id, tokenpass) { + const update = { + $set: { + tokenpass, + }, + }; + + return this.update({ _id }, update); + } + + setReadOnlyById(_id, readOnly, hasPermission) { + if (!hasPermission) { + throw new Error('You must provide "hasPermission" function to be able to call this method'); + } + const query = { + _id, + }; + const update = { + $set: { + ro: readOnly, + }, + }; + + return this.update(query, update); + } + + setAllowReactingWhenReadOnlyById = function(_id, allowReacting) { + const query = { + _id, + }; + const update = { + $set: { + reactWhenReadOnly: allowReacting, + }, + }; + return this.update(query, update); + } + + setSystemMessagesById = function(_id, systemMessages) { + const query = { + _id, + }; + const update = { + $set: { + sysMes: systemMessages, + }, + }; + return this.update(query, update); + } + + setE2eKeyId(_id, e2eKeyId, options) { + const query = { + _id, + }; + + const update = { + $set: { + e2eKeyId, + }, + }; + + return this.update(query, update, options); + } + + findOneByImportId(_id, options) { + const query = { importIds: _id }; + + return this.findOne(query, options); + } + + findOneByName(name, options) { + const query = { name }; + + return this.findOne(query, options); + } + + findOneByNameAndNotId(name, rid) { + const query = { + _id: { $ne: rid }, + name, + }; + + return this.findOne(query); + } + + findOneByDisplayName(fname, options) { + const query = { fname }; + + return this.findOne(query, options); + } + + findOneByNameAndType(name, type, options) { + const query = { + name, + t: type, + }; + + return this.findOne(query, options); + } + + // FIND + + findById(roomId, options) { + return this.find({ _id: roomId }, options); + } + + findByIds(roomIds, options) { + return this.find({ _id: { $in: [].concat(roomIds) } }, options); + } + + findByType(type, options) { + const query = { t: type }; + + return this.find(query, options); + } + + findByTypeInIds(type, ids, options) { + const query = { + _id: { + $in: ids, + }, + t: type, + }; + + return this.find(query, options); + } + + findByTypes(types, options) { + const query = { + t: { + $in: types, + }, + }; + + return this.find(query, options); + } + + findByUserId(userId, options) { + const query = { 'u._id': userId }; + + return this.find(query, options); + } + + findBySubscriptionUserId(userId, options) { + const data = Subscriptions.findByUserId(userId, { fields: { rid: 1 } }).fetch() + .map((item) => item.rid); + + const query = { + _id: { + $in: data, + }, + }; + + return this.find(query, options); + } + + findBySubscriptionTypeAndUserId(type, userId, options) { + const data = Subscriptions.findByUserIdAndType(userId, type, { fields: { rid: 1 } }).fetch() + .map((item) => item.rid); + + const query = { + t: type, + _id: { + $in: data, + }, + }; + + return this.find(query, options); + } + + findBySubscriptionUserIdUpdatedAfter(userId, _updatedAt, options) { + const ids = Subscriptions.findByUserId(userId, { fields: { rid: 1 } }).fetch() + .map((item) => item.rid); + + const query = { + _id: { + $in: ids, + }, + _updatedAt: { + $gt: _updatedAt, + }, + }; + + return this.find(query, options); + } + + findByNameContaining(name, options) { + const nameRegex = new RegExp(s.trim(s.escapeRegExp(name)), 'i'); + + const query = { + $or: [ + { name: nameRegex }, + { + t: 'd', + usernames: nameRegex, + }, + ], + }; + + return this.find(query, options); + } + + findByNameContainingAndTypes(name, types, options) { + const nameRegex = new RegExp(s.trim(s.escapeRegExp(name)), 'i'); + + const query = { + t: { + $in: types, + }, + $or: [ + { name: nameRegex }, + { + t: 'd', + usernames: nameRegex, + }, + ], + }; + + return this.find(query, options); + } + + findByNameAndType(name, type, options) { + const query = { + t: type, + name, + }; + + // do not use cache + return this._db.find(query, options); + } + + findByNameAndTypeNotDefault(name, type, options) { + const query = { + t: type, + name, + default: { + $ne: true, + }, + }; + + // do not use cache + return this._db.find(query, options); + } + + findByNameAndTypesNotInIds(name, types, ids, options) { + const query = { + _id: { + $ne: ids, + }, + t: { + $in: types, + }, + name, + }; + + // do not use cache + return this._db.find(query, options); + } + + findChannelAndPrivateByNameStarting(name, options) { + const nameRegex = new RegExp(`^${ s.trim(s.escapeRegExp(name)) }`, 'i'); + + const query = { + t: { + $in: ['c', 'p'], + }, + name: nameRegex, + }; + + return this.find(query, options); + } + + findByDefaultAndTypes(defaultValue, types, options) { + const query = { + default: defaultValue, + t: { + $in: types, + }, + }; + + return this.find(query, options); + } + + findDirectRoomContainingUsername(username, options) { + const query = { + t: 'd', + usernames: username, + }; + + return this.find(query, options); + } + + findDirectRoomContainingAllUsernames(usernames, options) { + const query = { + t: 'd', + usernames: { $size: usernames.length, $all: usernames }, + }; + + return this.findOne(query, options); + } + + findByTypeAndName(type, name, options) { + const query = { + name, + t: type, + }; + + return this.findOne(query, options); + } + + findByTypeAndNameContaining(type, name, options) { + const nameRegex = new RegExp(s.trim(s.escapeRegExp(name)), 'i'); + + const query = { + name: nameRegex, + t: type, + }; + + return this.find(query, options); + } + + findByTypeInIdsAndNameContaining(type, ids, name, options) { + const nameRegex = new RegExp(s.trim(s.escapeRegExp(name)), 'i'); + + const query = { + _id: { + $in: ids, + }, + name: nameRegex, + t: type, + }; + + return this.find(query, options); + } + + findByTypeAndArchivationState(type, archivationstate, options) { + const query = { t: type }; + + if (archivationstate) { + query.archived = true; + } else { + query.archived = { $ne: true }; + } + + return this.find(query, options); + } + + // UPDATE + addImportIds(_id, importIds) { + importIds = [].concat(importIds); + const query = { _id }; + + const update = { + $addToSet: { + importIds: { + $each: importIds, + }, + }, + }; + + return this.update(query, update); + } + + archiveById(_id) { + const query = { _id }; + + const update = { + $set: { + archived: true, + }, + }; + + return this.update(query, update); + } + + unarchiveById(_id) { + const query = { _id }; + + const update = { + $set: { + archived: false, + }, + }; + + return this.update(query, update); + } + + setNameById(_id, name, fname) { + const query = { _id }; + + const update = { + $set: { + name, + fname, + }, + }; + + return this.update(query, update); + } + + setFnameById(_id, fname) { + const query = { _id }; + + const update = { + $set: { + fname, + }, + }; + + return this.update(query, update); + } + + incMsgCountById(_id, inc) { + if (inc == null) { inc = 1; } + const query = { _id }; + + const update = { + $inc: { + msgs: inc, + }, + }; + + return this.update(query, update); + } + + incMsgCountAndSetLastMessageById(_id, inc, lastMessageTimestamp, lastMessage) { + if (inc == null) { inc = 1; } + const query = { _id }; + + const update = { + $set: { + lm: lastMessageTimestamp, + }, + $inc: { + msgs: inc, + }, + }; + + if (lastMessage) { + update.$set.lastMessage = lastMessage; + } + + return this.update(query, update); + } + + incUsersCountById(_id, inc = 1) { + const query = { _id }; + + const update = { + $inc: { + usersCount: inc, + }, + }; + + return this.update(query, update); + } + + incUsersCountByIds(ids, inc = 1) { + const query = { + _id: { + $in: ids, + }, + }; + + const update = { + $inc: { + usersCount: inc, + }, + }; + + return this.update(query, update, { multi: true }); + } + + setLastMessageById(_id, lastMessage) { + const query = { _id }; + + const update = { + $set: { + lastMessage, + }, + }; + + return this.update(query, update); + } + + resetLastMessageById(_id, messageId) { + const query = { _id }; + const lastMessage = Messages.getLastVisibleMessageSentWithNoTypeByRoomId(_id, messageId); + + const update = lastMessage ? { + $set: { + lastMessage, + }, + } : { + $unset: { + lastMessage: 1, + }, + }; + + return this.update(query, update); + } + + replaceUsername(previousUsername, username) { + const query = { usernames: previousUsername }; + + const update = { + $set: { + 'usernames.$': username, + }, + }; + + return this.update(query, update, { multi: true }); + } + + replaceMutedUsername(previousUsername, username) { + const query = { muted: previousUsername }; + + const update = { + $set: { + 'muted.$': username, + }, + }; + + return this.update(query, update, { multi: true }); + } + + replaceUsernameOfUserByUserId(userId, username) { + const query = { 'u._id': userId }; + + const update = { + $set: { + 'u.username': username, + }, + }; + + return this.update(query, update, { multi: true }); + } + + setJoinCodeById(_id, joinCode) { + let update; + const query = { _id }; + + if ((joinCode != null ? joinCode.trim() : undefined) !== '') { + update = { + $set: { + joinCodeRequired: true, + joinCode, + }, + }; + } else { + update = { + $set: { + joinCodeRequired: false, + }, + $unset: { + joinCode: 1, + }, + }; + } + + return this.update(query, update); + } + + setUserById(_id, user) { + const query = { _id }; + + const update = { + $set: { + u: { + _id: user._id, + username: user.username, + }, + }, + }; + + return this.update(query, update); + } + + setTypeById(_id, type) { + const query = { _id }; + const update = { + $set: { + t: type, + }, + }; + if (type === 'p') { + update.$unset = { default: '' }; + } + + return this.update(query, update); + } + + setTopicById(_id, topic) { + const query = { _id }; + + const update = { + $set: { + topic, + }, + }; + + return this.update(query, update); + } + + setAnnouncementById(_id, announcement, announcementDetails) { + const query = { _id }; + + const update = { + $set: { + announcement, + announcementDetails, + }, + }; + + return this.update(query, update); + } + + setCustomFieldsById(_id, customFields) { + const query = { _id }; + + const update = { + $set: { + customFields, + }, + }; + + return this.update(query, update); + } + + muteUsernameByRoomId(_id, username) { + const query = { _id }; + + const update = { + $addToSet: { + muted: username, + }, + $pull: { + unmuted: username, + }, + }; + + return this.update(query, update); + } + + unmuteUsernameByRoomId(_id, username) { + const query = { _id }; + + const update = { + $pull: { + muted: username, + }, + $addToSet: { + unmuted: username, + }, + }; + + return this.update(query, update); + } + + saveDefaultById(_id, defaultValue) { + const query = { _id }; + + const update = { + $set: { + default: defaultValue === 'true', + }, + }; + + return this.update(query, update); + } + + saveRetentionEnabledById(_id, value) { + const query = { _id }; + + const update = {}; + + if (value == null) { + update.$unset = { 'retention.enabled': true }; + } else { + update.$set = { 'retention.enabled': !!value }; + } + + return this.update(query, update); + } + + saveRetentionMaxAgeById(_id, value) { + const query = { _id }; + + value = Number(value); + if (!value) { + value = 30; + } + + const update = { + $set: { + 'retention.maxAge': value, + }, + }; + + return this.update(query, update); + } + + saveRetentionExcludePinnedById(_id, value) { + const query = { _id }; + + const update = { + $set: { + 'retention.excludePinned': value === true, + }, + }; + + return this.update(query, update); + } + + saveRetentionFilesOnlyById(_id, value) { + const query = { _id }; + + const update = { + $set: { + 'retention.filesOnly': value === true, + }, + }; + + return this.update(query, update); + } + + saveRetentionOverrideGlobalById(_id, value) { + const query = { _id }; + + const update = { + $set: { + 'retention.overrideGlobal': value === true, + }, + }; + + return this.update(query, update); + } + + saveEncryptedById(_id, value) { + const query = { _id }; + + const update = { + $set: { + encrypted: value === true, + }, + }; + + return this.update(query, update); + } + + setTopicAndTagsById(_id, topic, tags) { + const setData = {}; + const unsetData = {}; + + if (topic != null) { + if (!_.isEmpty(s.trim(topic))) { + setData.topic = s.trim(topic); + } else { + unsetData.topic = 1; + } + } + + if (tags != null) { + if (!_.isEmpty(s.trim(tags))) { + setData.tags = s.trim(tags).split(',').map((tag) => s.trim(tag)); + } else { + unsetData.tags = 1; + } + } + + const update = {}; + + if (!_.isEmpty(setData)) { + update.$set = setData; + } + + if (!_.isEmpty(unsetData)) { + update.$unset = unsetData; + } + + if (_.isEmpty(update)) { + return; + } + + return this.update({ _id }, update); + } + + // INSERT + createWithTypeNameUserAndUsernames(type, name, fname, user, usernames, extraData) { + const room = { + name, + fname, + t: type, + usernames, + msgs: 0, + usersCount: 0, + u: { + _id: user._id, + username: user.username, + }, + }; + + _.extend(room, extraData); + + room._id = this.insert(room); + return room; + } + + createWithIdTypeAndName(_id, type, name, extraData) { + const room = { + _id, + ts: new Date(), + t: type, + name, + usernames: [], + msgs: 0, + usersCount: 0, + }; + + _.extend(room, extraData); + + this.insert(room); + return room; + } + + createWithFullRoomData(room) { + delete room._id; + + room._id = this.insert(room); + return room; + } + + + // REMOVE + removeById(_id) { + const query = { _id }; + + return this.remove(query); + } + + removeDirectRoomContainingUsername(username) { + const query = { + t: 'd', + usernames: username, + }; + + return this.remove(query); + } + + // ############################ + // Discussion + findDiscussionParentByNameStarting(name, options) { + const nameRegex = new RegExp(`^${ s.trim(s.escapeRegExp(name)) }`, 'i'); + + const query = { + t: { + $in: ['c'], + }, + name: nameRegex, + archived: { $ne: true }, + prid: { + $exists: false, + }, + }; + + return this.find(query, options); + } + + setLinkMessageById(_id, linkMessageId) { + const query = { _id }; + + const update = { + $set: { + linkMessageId, + }, + }; + + return this.update(query, update); + } + + countDiscussions() { + return this.find({ prid: { $exists: true } }).count(); + } +} + +export default new Rooms('room', true); diff --git a/app/models/server/models/Sessions.js b/app/models/server/models/Sessions.js new file mode 100644 index 0000000000000..10e3d1cc71bd9 --- /dev/null +++ b/app/models/server/models/Sessions.js @@ -0,0 +1,575 @@ +import { Base } from './_Base'; + +export const aggregates = { + dailySessionsOfYesterday(collection, { year, month, day }) { + return collection.aggregate([{ + $match: { + userId: { $exists: true }, + lastActivityAt: { $exists: true }, + device: { $exists: true }, + type: 'session', + $or: [{ + year: { $lt: year }, + }, { + year, + month: { $lt: month }, + }, { + year, + month, + day: { $lte: day }, + }], + }, + }, { + $sort: { + _id: 1, + }, + }, { + $project: { + userId: 1, + device: 1, + day: 1, + month: 1, + year: 1, + time: { $trunc: { $divide: [{ $subtract: ['$lastActivityAt', '$loginAt'] }, 1000] } }, + }, + }, { + $match: { + time: { $gt: 0 }, + }, + }, { + $group: { + _id: { + userId: '$userId', + device: '$device', + day: '$day', + month: '$month', + year: '$year', + }, + time: { $sum: '$time' }, + sessions: { $sum: 1 }, + }, + }, { + $group: { + _id: { + userId: '$_id.userId', + day: '$_id.day', + month: '$_id.month', + year: '$_id.year', + }, + time: { $sum: '$time' }, + sessions: { $sum: '$sessions' }, + devices: { + $push: { + sessions: '$sessions', + time: '$time', + device: '$_id.device', + }, + }, + }, + }, { + $project: { + _id: 0, + type: { $literal: 'user_daily' }, + _computedAt: { $literal: new Date() }, + day: '$_id.day', + month: '$_id.month', + year: '$_id.year', + userId: '$_id.userId', + time: 1, + sessions: 1, + devices: 1, + }, + }], { allowDiskUse: true }); + }, + + getUniqueUsersOfYesterday(collection, { year, month, day }) { + return collection.aggregate([{ + $match: { + year, + month, + day, + type: 'user_daily', + }, + }, { + $group: { + _id: { + day: '$day', + month: '$month', + year: '$year', + }, + count: { + $sum: 1, + }, + sessions: { + $sum: '$sessions', + }, + time: { + $sum: '$time', + }, + }, + }, { + $project: { + _id: 0, + count: 1, + sessions: 1, + time: 1, + }, + }]).toArray(); + }, + + getUniqueUsersOfLastMonth(collection, { year, month, day }) { + return collection.aggregate([{ + $match: { + type: 'user_daily', + ...aggregates.getMatchOfLastMonthToday({ year, month, day }), + }, + }, { + $group: { + _id: { + userId: '$userId', + }, + sessions: { + $sum: '$sessions', + }, + time: { + $sum: '$time', + }, + }, + }, { + $group: { + _id: 1, + count: { + $sum: 1, + }, + sessions: { + $sum: '$sessions', + }, + time: { + $sum: '$time', + }, + }, + }, { + $project: { + _id: 0, + count: 1, + sessions: 1, + time: 1, + }, + }], { allowDiskUse: true }).toArray(); + }, + + getMatchOfLastMonthToday({ year, month, day }) { + const pastMonthLastDay = (new Date(year, month - 1, 0)).getDate(); + const currMonthLastDay = (new Date(year, month, 0)).getDate(); + + const lastMonthToday = new Date(year, month - 1, day); + lastMonthToday.setMonth(lastMonthToday.getMonth() - 1, (currMonthLastDay === day ? pastMonthLastDay : Math.min(pastMonthLastDay, day)) + 1); + const lastMonthTodayObject = { + year: lastMonthToday.getFullYear(), + month: lastMonthToday.getMonth() + 1, + day: lastMonthToday.getDate(), + }; + + if (year === lastMonthTodayObject.year && month === lastMonthTodayObject.month) { + return { + year, + month, + day: { $gte: lastMonthTodayObject.day, $lte: day }, + }; + } + + if (year === lastMonthTodayObject.year) { + return { + year, + $and: [{ + $or: [{ + month: { $gt: lastMonthTodayObject.month }, + }, { + month: lastMonthTodayObject.month, + day: { $gte: lastMonthTodayObject.day }, + }], + }, { + $or: [{ + month: { $lt: month }, + }, { + month, + day: { $lte: day }, + }], + }], + }; + } + + return { + $and: [{ + $or: [{ + year: { $gt: lastMonthTodayObject.year }, + }, { + year: lastMonthTodayObject.year, + month: { $gt: lastMonthTodayObject.month }, + }, { + year: lastMonthTodayObject.year, + month: lastMonthTodayObject.month, + day: { $gte: lastMonthTodayObject.day }, + }], + }, { + $or: [{ + year: { $lt: year }, + }, { + year, + month: { $lt: month }, + }, { + year, + month, + day: { $lte: day }, + }], + }], + }; + }, + + getUniqueDevicesOfLastMonth(collection, { year, month, day }) { + return collection.aggregate([{ + $match: { + type: 'user_daily', + ...aggregates.getMatchOfLastMonthToday({ year, month, day }), + }, + }, { + $unwind: '$devices', + }, { + $group: { + _id: { + type: '$devices.device.type', + name: '$devices.device.name', + version: '$devices.device.version', + }, + count: { + $sum: '$devices.sessions', + }, + time: { + $sum: '$devices.time', + }, + }, + }, { + $project: { + _id: 0, + type: '$_id.type', + name: '$_id.name', + version: '$_id.version', + count: 1, + time: 1, + }, + }], { allowDiskUse: true }).toArray(); + }, + + getUniqueDevicesOfYesterday(collection, { year, month, day }) { + return collection.aggregate([{ + $match: { + year, + month, + day, + type: 'user_daily', + }, + }, { + $unwind: '$devices', + }, { + $group: { + _id: { + type: '$devices.device.type', + name: '$devices.device.name', + version: '$devices.device.version', + }, + count: { + $sum: '$devices.sessions', + }, + time: { + $sum: '$devices.time', + }, + }, + }, { + $project: { + _id: 0, + type: '$_id.type', + name: '$_id.name', + version: '$_id.version', + count: 1, + time: 1, + }, + }]).toArray(); + }, + + getUniqueOSOfLastMonth(collection, { year, month, day }) { + return collection.aggregate([{ + $match: { + type: 'user_daily', + 'devices.device.os.name': { + $exists: true, + }, + ...aggregates.getMatchOfLastMonthToday({ year, month, day }), + }, + }, { + $unwind: '$devices', + }, { + $group: { + _id: { + name: '$devices.device.os.name', + version: '$devices.device.os.version', + }, + count: { + $sum: '$devices.sessions', + }, + time: { + $sum: '$devices.time', + }, + }, + }, { + $project: { + _id: 0, + name: '$_id.name', + version: '$_id.version', + count: 1, + time: 1, + }, + }], { allowDiskUse: true }).toArray(); + }, + + getUniqueOSOfYesterday(collection, { year, month, day }) { + return collection.aggregate([{ + $match: { + year, + month, + day, + type: 'user_daily', + 'devices.device.os.name': { + $exists: true, + }, + }, + }, { + $unwind: '$devices', + }, { + $group: { + _id: { + name: '$devices.device.os.name', + version: '$devices.device.os.version', + }, + count: { + $sum: '$devices.sessions', + }, + time: { + $sum: '$devices.time', + }, + }, + }, { + $project: { + _id: 0, + name: '$_id.name', + version: '$_id.version', + count: 1, + time: 1, + }, + }]).toArray(); + }, +}; + +export class Sessions extends Base { + constructor(...args) { + super(...args); + + this.tryEnsureIndex({ instanceId: 1, sessionId: 1, year: 1, month: 1, day: 1 }); + this.tryEnsureIndex({ instanceId: 1, sessionId: 1, userId: 1 }); + this.tryEnsureIndex({ instanceId: 1, sessionId: 1 }); + this.tryEnsureIndex({ year: 1, month: 1, day: 1, type: 1 }); + this.tryEnsureIndex({ type: 1 }); + this.tryEnsureIndex({ _computedAt: 1 }, { expireAfterSeconds: 60 * 60 * 24 * 45 }); + } + + getUniqueUsersOfYesterday() { + const date = new Date(); + date.setDate(date.getDate() - 1); + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + return { + year, + month, + day, + data: Promise.await(aggregates.getUniqueUsersOfYesterday(this.model.rawCollection(), { year, month, day })), + }; + } + + getUniqueUsersOfLastMonth() { + const date = new Date(); + date.setMonth(date.getMonth() - 1); + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + return { + year, + month, + day, + data: Promise.await(aggregates.getUniqueUsersOfLastMonth(this.model.rawCollection(), { year, month, day })), + }; + } + + getUniqueDevicesOfYesterday() { + const date = new Date(); + date.setDate(date.getDate() - 1); + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + return { + year, + month, + day, + data: Promise.await(aggregates.getUniqueDevicesOfYesterday(this.model.rawCollection(), { year, month, day })), + }; + } + + getUniqueDevicesOfLastMonth() { + const date = new Date(); + date.setDate(date.getDate() - 1); + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + return { + year, + month, + day, + data: Promise.await(aggregates.getUniqueDevicesOfLastMonth(this.model.rawCollection(), { year, month, day })), + }; + } + + getUniqueOSOfYesterday() { + const date = new Date(); + date.setDate(date.getDate() - 1); + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + return { + year, + month, + day, + data: Promise.await(aggregates.getUniqueOSOfYesterday(this.model.rawCollection(), { year, month, day })), + }; + } + + getUniqueOSOfLastMonth() { + const date = new Date(); + date.setDate(date.getDate() - 1); + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + return { + year, + month, + day, + data: Promise.await(aggregates.getUniqueOSOfLastMonth(this.model.rawCollection(), { year, month, day })), + }; + } + + createOrUpdate(data = {}) { + const { year, month, day, sessionId, instanceId } = data; + + if (!year || !month || !day || !sessionId || !instanceId) { + return; + } + + const now = new Date(); + + return this.upsert({ instanceId, sessionId, year, month, day }, { + $set: data, + $setOnInsert: { + createdAt: now, + }, + }); + } + + closeByInstanceIdAndSessionId(instanceId, sessionId) { + const query = { + instanceId, + sessionId, + closedAt: { $exists: 0 }, + }; + + const closeTime = new Date(); + const update = { + $set: { + closedAt: closeTime, + lastActivityAt: closeTime, + }, + }; + + return this.update(query, update); + } + + updateActiveSessionsByDateAndInstanceIdAndIds({ year, month, day } = {}, instanceId, sessions, data = {}) { + const query = { + instanceId, + year, + month, + day, + sessionId: { $in: sessions }, + closedAt: { $exists: 0 }, + }; + + const update = { + $set: data, + }; + + return this.update(query, update, { multi: true }); + } + + logoutByInstanceIdAndSessionIdAndUserId(instanceId, sessionId, userId) { + const query = { + instanceId, + sessionId, + userId, + logoutAt: { $exists: 0 }, + }; + + const logoutAt = new Date(); + const update = { + $set: { + logoutAt, + }, + }; + + return this.update(query, update, { multi: true }); + } + + createBatch(sessions) { + if (!sessions || sessions.length === 0) { + return; + } + + const ops = []; + sessions.forEach((doc) => { + const { year, month, day, sessionId, instanceId } = doc; + delete doc._id; + + ops.push({ + updateOne: { + filter: { year, month, day, sessionId, instanceId }, + update: { + $set: doc, + }, + upsert: true, + }, + }); + }); + + return this.model.rawCollection().bulkWrite(ops, { ordered: false }); + } +} + +export default new Sessions('sessions'); diff --git a/app/models/server/models/Sessions.mocks.js b/app/models/server/models/Sessions.mocks.js new file mode 100644 index 0000000000000..731c40948b785 --- /dev/null +++ b/app/models/server/models/Sessions.mocks.js @@ -0,0 +1,7 @@ +import mock from 'mock-require'; + +mock('./_Base', { + Base: class Base { + tryEnsureIndex() {} + }, +}); diff --git a/app/models/server/models/Sessions.tests.js b/app/models/server/models/Sessions.tests.js new file mode 100644 index 0000000000000..5e9a49589bf47 --- /dev/null +++ b/app/models/server/models/Sessions.tests.js @@ -0,0 +1,824 @@ +/* eslint-env mocha */ + +import assert from 'assert'; + +import './Sessions.mocks.js'; + +const mongoUnit = require('mongo-unit'); +const { MongoClient } = require('mongodb'); + +const { aggregates } = require('./Sessions'); + +const sessions_dates = []; +const baseDate = new Date(2018, 6, 1); + +for (let index = 0; index < 365; index++) { + sessions_dates.push({ + _id: `${ baseDate.getFullYear() }-${ baseDate.getMonth() + 1 }-${ baseDate.getDate() }`, + year: baseDate.getFullYear(), + month: baseDate.getMonth() + 1, + day: baseDate.getDate(), + }); + baseDate.setDate(baseDate.getDate() + 1); +} + +const DATA = { + sessions: [{ + _id: 'fNFyFcjszvoN6Grip2', + day: 30, + instanceId: 'HvbqxukP8E65LAGMY', + month: 4, + sessionId: 'kiA4xX33AyzPgpBNs2', + year: 2019, + _updatedAt: new Date('2019-04-30T16:33:24.311Z'), + createdAt: new Date('2019-04-30T00:11:34.047Z'), + device: { + type: 'browser', + name: 'Firefox', + longVersion: '66.0.3', + os: { + name: 'Linux', + version: '12', + }, + version: '66.0.3', + }, + host: 'localhost:3000', + ip: '127.0.0.1', + loginAt: new Date('2019-04-30T00:11:34.047Z'), + type: 'session', + userId: 'xPZXw9xqM3kKshsse', + lastActivityAt: new Date('2019-04-30T00:16:20.349Z'), + closedAt: new Date('2019-04-30T00:16:20.349Z'), + }, { + _id: 'fNFyFcjszvoN6Grip', + day: 2, + instanceId: 'HvbqxukP8E65LAGMY', + month: 5, + sessionId: 'kiA4xX33AyzPgpBNs', + year: 2019, + _updatedAt: new Date('2019-05-06T16:33:24.311Z'), + createdAt: new Date('2019-05-03T00:11:34.047Z'), + device: { + type: 'browser', + name: 'Firefox', + longVersion: '66.0.3', + os: { + name: 'Linux', + version: '12', + }, + version: '66.0.3', + }, + host: 'localhost:3000', + ip: '127.0.0.1', + loginAt: new Date('2019-05-03T00:11:34.047Z'), + type: 'session', + userId: 'xPZXw9xqM3kKshsse', + lastActivityAt: new Date('2019-05-03T00:16:20.349Z'), + closedAt: new Date('2019-05-03T00:16:20.349Z'), + }, { + _id: 'oZMkfR3gFB6kuKDK2', + day: 2, + instanceId: 'HvbqxukP8E65LAGMY', + month: 5, + sessionId: 'i8uJFekr9np4x88kS', + year: 2019, + _updatedAt: new Date('2019-05-06T16:33:24.311Z'), + createdAt: new Date('2019-05-03T00:16:21.847Z'), + device: { + type: 'browser', + name: 'Chrome', + longVersion: '73.0.3683.103', + os: { + name: 'Mac OS', + version: '10.14.1', + }, + version: '73.0.3683', + }, + host: 'localhost:3000', + ip: '127.0.0.1', + loginAt: new Date('2019-05-03T00:16:21.846Z'), + type: 'session', + userId: 'xPZXw9xqM3kKshsse', + lastActivityAt: new Date('2019-05-03T00:17:21.081Z'), + closedAt: new Date('2019-05-03T00:17:21.081Z'), + }, { + _id: 'ABXKoXKTZpPpzLjKd', + day: 2, + instanceId: 'HvbqxukP8E65LAGMY', + month: 5, + sessionId: 'T8MB28cpx2ZjfEDXr', + year: 2019, + _updatedAt: new Date('2019-05-06T16:33:24.311Z'), + createdAt: new Date('2019-05-03T00:17:22.375Z'), + device: { + type: 'browser', + name: 'Chrome', + longVersion: '73.0.3683.103', + os: { + name: 'Mac OS', + version: '10.14.1', + }, + version: '73.0.3683', + }, + host: 'localhost:3000', + ip: '127.0.0.1', + loginAt: new Date('2019-05-03T00:17:22.375Z'), + type: 'session', + userId: 'xPZXw9xqM3kKshsse', + lastActivityAt: new Date('2019-05-03T01:48:31.695Z'), + closedAt: new Date('2019-05-03T01:48:31.695Z'), + }, { + _id: 's4ucvvcfBjnTEtYEb', + day: 2, + instanceId: 'HvbqxukP8E65LAGMY', + month: 5, + sessionId: '8mHbJJypgeRG27TYF', + year: 2019, + _updatedAt: new Date('2019-05-06T16:33:24.311Z'), + createdAt: new Date('2019-05-03T01:48:43.521Z'), + device: { + type: 'browser', + name: 'Chrome', + longVersion: '73.0.3683.103', + os: { + name: 'Mac OS', + version: '10.14.1', + }, + version: '73.0.3683', + }, + host: 'localhost:3000', + ip: '127.0.0.1', + loginAt: new Date('2019-05-03T01:48:43.521Z'), + type: 'session', + userId: 'xPZXw9xqM3kKshsse', + closedAt: new Date('2019-05-03T01:48:43.761Z'), + lastActivityAt: new Date('2019-05-03T01:48:43.761Z'), + }, { + _id: 'MDs9SzQKmwaDmXL8s', + day: 2, + instanceId: 'HvbqxukP8E65LAGMY', + month: 5, + sessionId: 'GmoBDPKy9RW2eXdCG', + year: 2019, + _updatedAt: new Date('2019-05-06T16:33:24.311Z'), + createdAt: new Date('2019-05-03T01:48:45.064Z'), + device: { + type: 'browser', + name: 'Chrome', + longVersion: '73.0.3683.103', + os: { + name: 'Mac OS', + version: '10.14.1', + }, + version: '73.0.3683', + }, + host: 'localhost:3000', + ip: '127.0.0.1', + loginAt: new Date('2019-05-03T01:48:45.064Z'), + type: 'session', + userId: 'xPZXw9xqM3kKshsse', + }, { + _id: 'CJwfxASo62FHDgqog', + day: 2, + instanceId: 'Nmwo2ttFeWZSrowNh', + month: 5, + sessionId: 'LMrrL4sbpNMLWYomA', + year: 2019, + _updatedAt: new Date('2019-05-06T16:33:24.311Z'), + createdAt: new Date('2019-05-03T01:50:31.098Z'), + device: { + type: 'browser', + name: 'Chrome', + longVersion: '73.0.3683.103', + os: { + name: 'Mac OS', + version: '10.14.1', + }, + version: '73.0.3683', + }, + host: 'localhost:3000', + ip: '127.0.0.1', + loginAt: new Date('2019-05-03T01:50:31.092Z'), + type: 'session', + userId: 'xPZXw9xqM3kKshsse', + closedAt: new Date('2019-05-03T01:50:31.355Z'), + lastActivityAt: new Date('2019-05-03T01:50:31.355Z'), + }, { + _id: 'iGAcPobWfTQtN6s4K', + day: 1, + instanceId: 'Nmwo2ttFeWZSrowNh', + month: 5, + sessionId: 'AsbjZRLNQMqfbyYFS', + year: 2019, + _updatedAt: new Date('2019-05-06T16:33:24.311Z'), + createdAt: new Date('2019-05-03T01:50:32.765Z'), + device: { + type: 'browser', + name: 'Chrome', + longVersion: '73.0.3683.103', + os: { + name: 'Mac OS', + version: '10.14.1', + }, + version: '73.0.3683', + }, + host: 'localhost:3000', + ip: '127.0.0.1', + loginAt: new Date('2019-05-03T01:50:32.765Z'), + type: 'session', + userId: 'xPZXw9xqM3kKshsse2', + lastActivityAt: new Date('2019-05-03T02:59:59.999Z'), + }], + sessions_dates, +}; // require('./fixtures/testData.json') + +describe('Sessions Aggregates', () => { + let db; + + if (!process.env.MONGO_URL) { + before(function() { + this.timeout(120000); + return mongoUnit.start({ version: '3.2.22' }) + .then((testMongoUrl) => { process.env.MONGO_URL = testMongoUrl; }); + }); + + after(() => { mongoUnit.stop(); }); + } + + before(function() { + return MongoClient.connect(process.env.MONGO_URL) + .then((client) => { db = client.db('test'); }); + }); + + before(() => db.dropDatabase().then(() => { + const sessions = db.collection('sessions'); + const sessions_dates = db.collection('sessions_dates'); + return Promise.all([ + sessions.insertMany(DATA.sessions), + sessions_dates.insertMany(DATA.sessions_dates), + ]); + })); + + after(() => { db.close(); }); + + it('should have sessions_dates data saved', () => { + const collection = db.collection('sessions_dates'); + return collection.find().toArray() + .then((docs) => assert.equal(docs.length, DATA.sessions_dates.length)); + }); + + it('should match sessions between 2018-12-11 and 2019-1-10', () => { + const collection = db.collection('sessions_dates'); + const $match = aggregates.getMatchOfLastMonthToday({ year: 2019, month: 1, day: 10 }); + + assert.deepEqual($match, { + $and: [{ + $or: [ + { year: { $gt: 2018 } }, + { year: 2018, month: { $gt: 12 } }, + { year: 2018, month: 12, day: { $gte: 11 } }, + ], + }, { + $or: [ + { year: { $lt: 2019 } }, + { year: 2019, month: { $lt: 1 } }, + { year: 2019, month: 1, day: { $lte: 10 } }, + ], + }], + }); + + return collection.aggregate([{ + $match, + }]).toArray() + .then((docs) => { + assert.equal(docs.length, 31); + assert.deepEqual(docs, [ + { _id: '2018-12-11', year: 2018, month: 12, day: 11 }, + { _id: '2018-12-12', year: 2018, month: 12, day: 12 }, + { _id: '2018-12-13', year: 2018, month: 12, day: 13 }, + { _id: '2018-12-14', year: 2018, month: 12, day: 14 }, + { _id: '2018-12-15', year: 2018, month: 12, day: 15 }, + { _id: '2018-12-16', year: 2018, month: 12, day: 16 }, + { _id: '2018-12-17', year: 2018, month: 12, day: 17 }, + { _id: '2018-12-18', year: 2018, month: 12, day: 18 }, + { _id: '2018-12-19', year: 2018, month: 12, day: 19 }, + { _id: '2018-12-20', year: 2018, month: 12, day: 20 }, + { _id: '2018-12-21', year: 2018, month: 12, day: 21 }, + { _id: '2018-12-22', year: 2018, month: 12, day: 22 }, + { _id: '2018-12-23', year: 2018, month: 12, day: 23 }, + { _id: '2018-12-24', year: 2018, month: 12, day: 24 }, + { _id: '2018-12-25', year: 2018, month: 12, day: 25 }, + { _id: '2018-12-26', year: 2018, month: 12, day: 26 }, + { _id: '2018-12-27', year: 2018, month: 12, day: 27 }, + { _id: '2018-12-28', year: 2018, month: 12, day: 28 }, + { _id: '2018-12-29', year: 2018, month: 12, day: 29 }, + { _id: '2018-12-30', year: 2018, month: 12, day: 30 }, + { _id: '2018-12-31', year: 2018, month: 12, day: 31 }, + { _id: '2019-1-1', year: 2019, month: 1, day: 1 }, + { _id: '2019-1-2', year: 2019, month: 1, day: 2 }, + { _id: '2019-1-3', year: 2019, month: 1, day: 3 }, + { _id: '2019-1-4', year: 2019, month: 1, day: 4 }, + { _id: '2019-1-5', year: 2019, month: 1, day: 5 }, + { _id: '2019-1-6', year: 2019, month: 1, day: 6 }, + { _id: '2019-1-7', year: 2019, month: 1, day: 7 }, + { _id: '2019-1-8', year: 2019, month: 1, day: 8 }, + { _id: '2019-1-9', year: 2019, month: 1, day: 9 }, + { _id: '2019-1-10', year: 2019, month: 1, day: 10 }, + ]); + }); + }); + + it('should match sessions between 2019-1-11 and 2019-2-10', () => { + const collection = db.collection('sessions_dates'); + const $match = aggregates.getMatchOfLastMonthToday({ year: 2019, month: 2, day: 10 }); + + assert.deepEqual($match, { + year: 2019, + $and: [{ + $or: [ + { month: { $gt: 1 } }, + { month: 1, day: { $gte: 11 } }, + ], + }, { + $or: [ + { month: { $lt: 2 } }, + { month: 2, day: { $lte: 10 } }, + ], + }], + }); + + return collection.aggregate([{ + $match, + }]).toArray() + .then((docs) => { + assert.equal(docs.length, 31); + assert.deepEqual(docs, [ + { _id: '2019-1-11', year: 2019, month: 1, day: 11 }, + { _id: '2019-1-12', year: 2019, month: 1, day: 12 }, + { _id: '2019-1-13', year: 2019, month: 1, day: 13 }, + { _id: '2019-1-14', year: 2019, month: 1, day: 14 }, + { _id: '2019-1-15', year: 2019, month: 1, day: 15 }, + { _id: '2019-1-16', year: 2019, month: 1, day: 16 }, + { _id: '2019-1-17', year: 2019, month: 1, day: 17 }, + { _id: '2019-1-18', year: 2019, month: 1, day: 18 }, + { _id: '2019-1-19', year: 2019, month: 1, day: 19 }, + { _id: '2019-1-20', year: 2019, month: 1, day: 20 }, + { _id: '2019-1-21', year: 2019, month: 1, day: 21 }, + { _id: '2019-1-22', year: 2019, month: 1, day: 22 }, + { _id: '2019-1-23', year: 2019, month: 1, day: 23 }, + { _id: '2019-1-24', year: 2019, month: 1, day: 24 }, + { _id: '2019-1-25', year: 2019, month: 1, day: 25 }, + { _id: '2019-1-26', year: 2019, month: 1, day: 26 }, + { _id: '2019-1-27', year: 2019, month: 1, day: 27 }, + { _id: '2019-1-28', year: 2019, month: 1, day: 28 }, + { _id: '2019-1-29', year: 2019, month: 1, day: 29 }, + { _id: '2019-1-30', year: 2019, month: 1, day: 30 }, + { _id: '2019-1-31', year: 2019, month: 1, day: 31 }, + { _id: '2019-2-1', year: 2019, month: 2, day: 1 }, + { _id: '2019-2-2', year: 2019, month: 2, day: 2 }, + { _id: '2019-2-3', year: 2019, month: 2, day: 3 }, + { _id: '2019-2-4', year: 2019, month: 2, day: 4 }, + { _id: '2019-2-5', year: 2019, month: 2, day: 5 }, + { _id: '2019-2-6', year: 2019, month: 2, day: 6 }, + { _id: '2019-2-7', year: 2019, month: 2, day: 7 }, + { _id: '2019-2-8', year: 2019, month: 2, day: 8 }, + { _id: '2019-2-9', year: 2019, month: 2, day: 9 }, + { _id: '2019-2-10', year: 2019, month: 2, day: 10 }, + ]); + }); + }); + + it('should match sessions between 2019-5-1 and 2019-5-31', () => { + const collection = db.collection('sessions_dates'); + const $match = aggregates.getMatchOfLastMonthToday({ year: 2019, month: 5, day: 31 }); + + assert.deepEqual($match, { + year: 2019, + month: 5, + day: { $gte: 1, $lte: 31 }, + }); + + return collection.aggregate([{ + $match, + }]).toArray() + .then((docs) => { + assert.equal(docs.length, 31); + assert.deepEqual(docs, [ + { _id: '2019-5-1', year: 2019, month: 5, day: 1 }, + { _id: '2019-5-2', year: 2019, month: 5, day: 2 }, + { _id: '2019-5-3', year: 2019, month: 5, day: 3 }, + { _id: '2019-5-4', year: 2019, month: 5, day: 4 }, + { _id: '2019-5-5', year: 2019, month: 5, day: 5 }, + { _id: '2019-5-6', year: 2019, month: 5, day: 6 }, + { _id: '2019-5-7', year: 2019, month: 5, day: 7 }, + { _id: '2019-5-8', year: 2019, month: 5, day: 8 }, + { _id: '2019-5-9', year: 2019, month: 5, day: 9 }, + { _id: '2019-5-10', year: 2019, month: 5, day: 10 }, + { _id: '2019-5-11', year: 2019, month: 5, day: 11 }, + { _id: '2019-5-12', year: 2019, month: 5, day: 12 }, + { _id: '2019-5-13', year: 2019, month: 5, day: 13 }, + { _id: '2019-5-14', year: 2019, month: 5, day: 14 }, + { _id: '2019-5-15', year: 2019, month: 5, day: 15 }, + { _id: '2019-5-16', year: 2019, month: 5, day: 16 }, + { _id: '2019-5-17', year: 2019, month: 5, day: 17 }, + { _id: '2019-5-18', year: 2019, month: 5, day: 18 }, + { _id: '2019-5-19', year: 2019, month: 5, day: 19 }, + { _id: '2019-5-20', year: 2019, month: 5, day: 20 }, + { _id: '2019-5-21', year: 2019, month: 5, day: 21 }, + { _id: '2019-5-22', year: 2019, month: 5, day: 22 }, + { _id: '2019-5-23', year: 2019, month: 5, day: 23 }, + { _id: '2019-5-24', year: 2019, month: 5, day: 24 }, + { _id: '2019-5-25', year: 2019, month: 5, day: 25 }, + { _id: '2019-5-26', year: 2019, month: 5, day: 26 }, + { _id: '2019-5-27', year: 2019, month: 5, day: 27 }, + { _id: '2019-5-28', year: 2019, month: 5, day: 28 }, + { _id: '2019-5-29', year: 2019, month: 5, day: 29 }, + { _id: '2019-5-30', year: 2019, month: 5, day: 30 }, + { _id: '2019-5-31', year: 2019, month: 5, day: 31 }, + ]); + }); + }); + + it('should match sessions between 2019-4-1 and 2019-4-30', () => { + const collection = db.collection('sessions_dates'); + const $match = aggregates.getMatchOfLastMonthToday({ year: 2019, month: 4, day: 30 }); + + assert.deepEqual($match, { + year: 2019, + month: 4, + day: { $gte: 1, $lte: 30 }, + }); + + return collection.aggregate([{ + $match, + }]).toArray() + .then((docs) => { + assert.equal(docs.length, 30); + assert.deepEqual(docs, [ + { _id: '2019-4-1', year: 2019, month: 4, day: 1 }, + { _id: '2019-4-2', year: 2019, month: 4, day: 2 }, + { _id: '2019-4-3', year: 2019, month: 4, day: 3 }, + { _id: '2019-4-4', year: 2019, month: 4, day: 4 }, + { _id: '2019-4-5', year: 2019, month: 4, day: 5 }, + { _id: '2019-4-6', year: 2019, month: 4, day: 6 }, + { _id: '2019-4-7', year: 2019, month: 4, day: 7 }, + { _id: '2019-4-8', year: 2019, month: 4, day: 8 }, + { _id: '2019-4-9', year: 2019, month: 4, day: 9 }, + { _id: '2019-4-10', year: 2019, month: 4, day: 10 }, + { _id: '2019-4-11', year: 2019, month: 4, day: 11 }, + { _id: '2019-4-12', year: 2019, month: 4, day: 12 }, + { _id: '2019-4-13', year: 2019, month: 4, day: 13 }, + { _id: '2019-4-14', year: 2019, month: 4, day: 14 }, + { _id: '2019-4-15', year: 2019, month: 4, day: 15 }, + { _id: '2019-4-16', year: 2019, month: 4, day: 16 }, + { _id: '2019-4-17', year: 2019, month: 4, day: 17 }, + { _id: '2019-4-18', year: 2019, month: 4, day: 18 }, + { _id: '2019-4-19', year: 2019, month: 4, day: 19 }, + { _id: '2019-4-20', year: 2019, month: 4, day: 20 }, + { _id: '2019-4-21', year: 2019, month: 4, day: 21 }, + { _id: '2019-4-22', year: 2019, month: 4, day: 22 }, + { _id: '2019-4-23', year: 2019, month: 4, day: 23 }, + { _id: '2019-4-24', year: 2019, month: 4, day: 24 }, + { _id: '2019-4-25', year: 2019, month: 4, day: 25 }, + { _id: '2019-4-26', year: 2019, month: 4, day: 26 }, + { _id: '2019-4-27', year: 2019, month: 4, day: 27 }, + { _id: '2019-4-28', year: 2019, month: 4, day: 28 }, + { _id: '2019-4-29', year: 2019, month: 4, day: 29 }, + { _id: '2019-4-30', year: 2019, month: 4, day: 30 }, + ]); + }); + }); + + it('should match sessions between 2019-2-1 and 2019-2-28', () => { + const collection = db.collection('sessions_dates'); + const $match = aggregates.getMatchOfLastMonthToday({ year: 2019, month: 2, day: 28 }); + + assert.deepEqual($match, { + year: 2019, + month: 2, + day: { $gte: 1, $lte: 28 }, + }); + + return collection.aggregate([{ + $match, + }]).toArray() + .then((docs) => { + assert.equal(docs.length, 28); + assert.deepEqual(docs, [ + { _id: '2019-2-1', year: 2019, month: 2, day: 1 }, + { _id: '2019-2-2', year: 2019, month: 2, day: 2 }, + { _id: '2019-2-3', year: 2019, month: 2, day: 3 }, + { _id: '2019-2-4', year: 2019, month: 2, day: 4 }, + { _id: '2019-2-5', year: 2019, month: 2, day: 5 }, + { _id: '2019-2-6', year: 2019, month: 2, day: 6 }, + { _id: '2019-2-7', year: 2019, month: 2, day: 7 }, + { _id: '2019-2-8', year: 2019, month: 2, day: 8 }, + { _id: '2019-2-9', year: 2019, month: 2, day: 9 }, + { _id: '2019-2-10', year: 2019, month: 2, day: 10 }, + { _id: '2019-2-11', year: 2019, month: 2, day: 11 }, + { _id: '2019-2-12', year: 2019, month: 2, day: 12 }, + { _id: '2019-2-13', year: 2019, month: 2, day: 13 }, + { _id: '2019-2-14', year: 2019, month: 2, day: 14 }, + { _id: '2019-2-15', year: 2019, month: 2, day: 15 }, + { _id: '2019-2-16', year: 2019, month: 2, day: 16 }, + { _id: '2019-2-17', year: 2019, month: 2, day: 17 }, + { _id: '2019-2-18', year: 2019, month: 2, day: 18 }, + { _id: '2019-2-19', year: 2019, month: 2, day: 19 }, + { _id: '2019-2-20', year: 2019, month: 2, day: 20 }, + { _id: '2019-2-21', year: 2019, month: 2, day: 21 }, + { _id: '2019-2-22', year: 2019, month: 2, day: 22 }, + { _id: '2019-2-23', year: 2019, month: 2, day: 23 }, + { _id: '2019-2-24', year: 2019, month: 2, day: 24 }, + { _id: '2019-2-25', year: 2019, month: 2, day: 25 }, + { _id: '2019-2-26', year: 2019, month: 2, day: 26 }, + { _id: '2019-2-27', year: 2019, month: 2, day: 27 }, + { _id: '2019-2-28', year: 2019, month: 2, day: 28 }, + ]); + }); + }); + + it('should match sessions between 2019-1-28 and 2019-2-27', () => { + const collection = db.collection('sessions_dates'); + const $match = aggregates.getMatchOfLastMonthToday({ year: 2019, month: 2, day: 27 }); + + assert.deepEqual($match, { + year: 2019, + $and: [{ + $or: [ + { month: { $gt: 1 } }, + { month: 1, day: { $gte: 28 } }, + ], + }, { + $or: [ + { month: { $lt: 2 } }, + { month: 2, day: { $lte: 27 } }, + ], + }], + }); + + return collection.aggregate([{ + $match, + }]).toArray() + .then((docs) => { + assert.equal(docs.length, 31); + assert.deepEqual(docs, [ + { _id: '2019-1-28', year: 2019, month: 1, day: 28 }, + { _id: '2019-1-29', year: 2019, month: 1, day: 29 }, + { _id: '2019-1-30', year: 2019, month: 1, day: 30 }, + { _id: '2019-1-31', year: 2019, month: 1, day: 31 }, + { _id: '2019-2-1', year: 2019, month: 2, day: 1 }, + { _id: '2019-2-2', year: 2019, month: 2, day: 2 }, + { _id: '2019-2-3', year: 2019, month: 2, day: 3 }, + { _id: '2019-2-4', year: 2019, month: 2, day: 4 }, + { _id: '2019-2-5', year: 2019, month: 2, day: 5 }, + { _id: '2019-2-6', year: 2019, month: 2, day: 6 }, + { _id: '2019-2-7', year: 2019, month: 2, day: 7 }, + { _id: '2019-2-8', year: 2019, month: 2, day: 8 }, + { _id: '2019-2-9', year: 2019, month: 2, day: 9 }, + { _id: '2019-2-10', year: 2019, month: 2, day: 10 }, + { _id: '2019-2-11', year: 2019, month: 2, day: 11 }, + { _id: '2019-2-12', year: 2019, month: 2, day: 12 }, + { _id: '2019-2-13', year: 2019, month: 2, day: 13 }, + { _id: '2019-2-14', year: 2019, month: 2, day: 14 }, + { _id: '2019-2-15', year: 2019, month: 2, day: 15 }, + { _id: '2019-2-16', year: 2019, month: 2, day: 16 }, + { _id: '2019-2-17', year: 2019, month: 2, day: 17 }, + { _id: '2019-2-18', year: 2019, month: 2, day: 18 }, + { _id: '2019-2-19', year: 2019, month: 2, day: 19 }, + { _id: '2019-2-20', year: 2019, month: 2, day: 20 }, + { _id: '2019-2-21', year: 2019, month: 2, day: 21 }, + { _id: '2019-2-22', year: 2019, month: 2, day: 22 }, + { _id: '2019-2-23', year: 2019, month: 2, day: 23 }, + { _id: '2019-2-24', year: 2019, month: 2, day: 24 }, + { _id: '2019-2-25', year: 2019, month: 2, day: 25 }, + { _id: '2019-2-26', year: 2019, month: 2, day: 26 }, + { _id: '2019-2-27', year: 2019, month: 2, day: 27 }, + ]); + }); + }); + + it('should have sessions data saved', () => { + const collection = db.collection('sessions'); + return collection.find().toArray() + .then((docs) => assert.equal(docs.length, DATA.sessions.length)); + }); + + it('should generate daily sessions', () => { + const collection = db.collection('sessions'); + return aggregates.dailySessionsOfYesterday(collection, { year: 2019, month: 5, day: 2 }).toArray() + .then((docs) => { + docs.forEach((doc) => { + doc._id = `${ doc.userId }-${ doc.year }-${ doc.month }-${ doc.day }`; + }); + + assert.equal(docs.length, 3); + assert.deepEqual(docs, [{ + _id: 'xPZXw9xqM3kKshsse-2019-5-2', + time: 5814, + sessions: 3, + devices: [{ + sessions: 1, + time: 286, + device: { + type: 'browser', + name: 'Firefox', + longVersion: '66.0.3', + os: { + name: 'Linux', + version: '12', + }, + version: '66.0.3', + }, + }, { + sessions: 2, + time: 5528, + device: { + type: 'browser', + name: 'Chrome', + longVersion: '73.0.3683.103', + os: { + name: 'Mac OS', + version: '10.14.1', + }, + version: '73.0.3683', + }, + }], + type: 'user_daily', + _computedAt: docs[0]._computedAt, + day: 2, + month: 5, + year: 2019, + userId: 'xPZXw9xqM3kKshsse', + }, { + _id: 'xPZXw9xqM3kKshsse-2019-4-30', + day: 30, + devices: [{ + device: { + longVersion: '66.0.3', + name: 'Firefox', + os: { + name: 'Linux', + version: '12', + }, + type: 'browser', + version: '66.0.3', + }, + sessions: 1, + time: 286, + }], + month: 4, + sessions: 1, + time: 286, + type: 'user_daily', + _computedAt: docs[1]._computedAt, + userId: 'xPZXw9xqM3kKshsse', + year: 2019, + }, { + _id: 'xPZXw9xqM3kKshsse2-2019-5-1', + time: 4167, + sessions: 1, + devices: [{ + sessions: 1, + time: 4167, + device: { + type: 'browser', + name: 'Chrome', + longVersion: '73.0.3683.103', + os: { + name: 'Mac OS', + version: '10.14.1', + }, + version: '73.0.3683', + }, + }], + type: 'user_daily', + _computedAt: docs[2]._computedAt, + day: 1, + month: 5, + year: 2019, + userId: 'xPZXw9xqM3kKshsse2', + }]); + + return collection.insertMany(docs); + }); + }); + + it('should have 2 unique users for month 5 of 2019', () => { + const collection = db.collection('sessions'); + return aggregates.getUniqueUsersOfLastMonth(collection, { year: 2019, month: 5, day: 31 }) + .then((docs) => { + assert.equal(docs.length, 1); + assert.deepEqual(docs, [{ + count: 2, + sessions: 4, + time: 9981, + }]); + }); + }); + + it('should have 1 unique user for 1st of month 5 of 2019', () => { + const collection = db.collection('sessions'); + return aggregates.getUniqueUsersOfYesterday(collection, { year: 2019, month: 5, day: 1 }) + .then((docs) => { + assert.equal(docs.length, 1); + assert.deepEqual(docs, [{ + count: 1, + sessions: 1, + time: 4167, + }]); + }); + }); + + it('should have 1 unique user for 2nd of month 5 of 2019', () => { + const collection = db.collection('sessions'); + return aggregates.getUniqueUsersOfYesterday(collection, { year: 2019, month: 5, day: 2 }) + .then((docs) => { + assert.equal(docs.length, 1); + assert.deepEqual(docs, [{ + count: 1, + sessions: 3, + time: 5814, + }]); + }); + }); + + it('should have 2 unique devices for month 5 of 2019', () => { + const collection = db.collection('sessions'); + return aggregates.getUniqueDevicesOfLastMonth(collection, { year: 2019, month: 5, day: 31 }) + .then((docs) => { + assert.equal(docs.length, 2); + assert.deepEqual(docs, [{ + count: 3, + time: 9695, + type: 'browser', + name: 'Chrome', + version: '73.0.3683', + }, { + count: 1, + time: 286, + type: 'browser', + name: 'Firefox', + version: '66.0.3', + }]); + }); + }); + + it('should have 2 unique devices for 2nd of month 5 of 2019', () => { + const collection = db.collection('sessions'); + return aggregates.getUniqueDevicesOfYesterday(collection, { year: 2019, month: 5, day: 2 }) + .then((docs) => { + assert.equal(docs.length, 2); + assert.deepEqual(docs, [{ + count: 2, + time: 5528, + type: 'browser', + name: 'Chrome', + version: '73.0.3683', + }, { + count: 1, + time: 286, + type: 'browser', + name: 'Firefox', + version: '66.0.3', + }]); + }); + }); + + it('should have 2 unique OS for month 5 of 2019', () => { + const collection = db.collection('sessions'); + return aggregates.getUniqueOSOfLastMonth(collection, { year: 2019, month: 5, day: 31 }) + .then((docs) => { + assert.equal(docs.length, 2); + assert.deepEqual(docs, [{ + count: 3, + time: 9695, + name: 'Mac OS', + version: '10.14.1', + }, { + count: 1, + time: 286, + name: 'Linux', + version: '12', + }]); + }); + }); + + it('should have 2 unique OS for 2nd of month 5 of 2019', () => { + const collection = db.collection('sessions'); + return aggregates.getUniqueOSOfYesterday(collection, { year: 2019, month: 5, day: 2 }) + .then((docs) => { + assert.equal(docs.length, 2); + assert.deepEqual(docs, [{ + count: 2, + time: 5528, + name: 'Mac OS', + version: '10.14.1', + }, { + count: 1, + time: 286, + name: 'Linux', + version: '12', + }]); + }); + }); +}); diff --git a/packages/rocketchat-lib/server/models/Settings.js b/app/models/server/models/Settings.js similarity index 86% rename from packages/rocketchat-lib/server/models/Settings.js rename to app/models/server/models/Settings.js index 3cd5af4fb8c98..fcf605c4b740f 100644 --- a/packages/rocketchat-lib/server/models/Settings.js +++ b/app/models/server/models/Settings.js @@ -1,4 +1,6 @@ -class ModelSettings extends RocketChat.models._Base { +import { Base } from './_Base'; + +export class Settings extends Base { constructor(...args) { super(...args); @@ -53,8 +55,7 @@ class ModelSettings extends RocketChat.models._Base { }; if (ids.length > 0) { - filter._id = - { $in: ids }; + filter._id = { $in: ids }; } return this.find(filter, { fields: { _id: 1, value: 1 } }); @@ -79,8 +80,16 @@ class ModelSettings extends RocketChat.models._Base { }); } - findNotHidden(options) { - return this.find({ hidden: { $ne: true } }, options); + findNotHidden({ updatedAfter, ...options } = {}) { + const query = { + hidden: { $ne: true }, + }; + + if (updatedAfter) { + query._updatedAt = { $gt: updatedAfter }; + } + + return this.find(query, options); } findNotHiddenUpdatedAfter(updatedAt) { @@ -162,7 +171,7 @@ class ModelSettings extends RocketChat.models._Base { const record = { _id, value, - _createdAt: new Date, + _createdAt: new Date(), }; return this.insert(record); @@ -179,4 +188,4 @@ class ModelSettings extends RocketChat.models._Base { } } -RocketChat.models.Settings = new ModelSettings('settings', true); +export default new Settings('settings', true); diff --git a/app/models/server/models/SmarshHistory.js b/app/models/server/models/SmarshHistory.js new file mode 100644 index 0000000000000..9b2b7abc50433 --- /dev/null +++ b/app/models/server/models/SmarshHistory.js @@ -0,0 +1,9 @@ +import { Base } from './_Base'; + +export class SmarshHistory extends Base { + constructor() { + super('smarsh_history'); + } +} + +export default new SmarshHistory(); diff --git a/app/models/server/models/Statistics.js b/app/models/server/models/Statistics.js new file mode 100644 index 0000000000000..ca95cd5642e6b --- /dev/null +++ b/app/models/server/models/Statistics.js @@ -0,0 +1,28 @@ +import { Base } from './_Base'; + +export class Statistics extends Base { + constructor() { + super('statistics'); + + this.tryEnsureIndex({ createdAt: 1 }); + } + + // FIND ONE + findOneById(_id, options) { + const query = { _id }; + return this.findOne(query, options); + } + + findLast() { + const options = { + sort: { + createdAt: -1, + }, + limit: 1, + }; + const records = this.find({}, options).fetch(); + return records && records[0]; + } +} + +export default new Statistics(); diff --git a/app/models/server/models/Subscriptions.js b/app/models/server/models/Subscriptions.js new file mode 100644 index 0000000000000..93813847b88a4 --- /dev/null +++ b/app/models/server/models/Subscriptions.js @@ -0,0 +1,1334 @@ +import { Meteor } from 'meteor/meteor'; +import { Match } from 'meteor/check'; +import _ from 'underscore'; + +import { Base } from './_Base'; +import Rooms from './Rooms'; +import Users from './Users'; +import { getDefaultSubscriptionPref } from '../../../utils/lib/getDefaultSubscriptionPref'; + +export class Subscriptions extends Base { + constructor(...args) { + super(...args); + + this.tryEnsureIndex({ rid: 1, 'u._id': 1 }, { unique: 1 }); + this.tryEnsureIndex({ rid: 1, 'u.username': 1 }); + this.tryEnsureIndex({ rid: 1, alert: 1, 'u._id': 1 }); + this.tryEnsureIndex({ rid: 1, roles: 1 }); + this.tryEnsureIndex({ 'u._id': 1, name: 1, t: 1 }); + this.tryEnsureIndex({ open: 1 }); + this.tryEnsureIndex({ alert: 1 }); + + this.tryEnsureIndex({ rid: 1, 'u._id': 1, open: 1 }); + + this.tryEnsureIndex({ ts: 1 }); + this.tryEnsureIndex({ ls: 1 }); + this.tryEnsureIndex({ audioNotifications: 1 }, { sparse: 1 }); + this.tryEnsureIndex({ desktopNotifications: 1 }, { sparse: 1 }); + this.tryEnsureIndex({ mobilePushNotifications: 1 }, { sparse: 1 }); + this.tryEnsureIndex({ emailNotifications: 1 }, { sparse: 1 }); + this.tryEnsureIndex({ autoTranslate: 1 }, { sparse: 1 }); + this.tryEnsureIndex({ autoTranslateLanguage: 1 }, { sparse: 1 }); + this.tryEnsureIndex({ 'userHighlights.0': 1 }, { sparse: 1 }); + this.tryEnsureIndex({ prid: 1 }); + } + + findByRoomIds(roomIds) { + const query = { + rid: { + $in: roomIds, + }, + }; + const options = { + fields: { + 'u._id': 1, + rid: 1, + }, + }; + + return this._db.find(query, options); + } + + removeByVisitorToken(token) { + const query = { + 'v.token': token, + }; + + this.remove(query); + } + + updateAutoTranslateById(_id, autoTranslate) { + const query = { + _id, + }; + + let update; + if (autoTranslate) { + update = { + $set: { + autoTranslate, + }, + }; + } else { + update = { + $unset: { + autoTranslate: 1, + }, + }; + } + + return this.update(query, update); + } + + updateAutoTranslateLanguageById(_id, autoTranslateLanguage) { + const query = { + _id, + }; + + const update = { + $set: { + autoTranslateLanguage, + }, + }; + + return this.update(query, update); + } + + getAutoTranslateLanguagesByRoomAndNotUser(rid, userId) { + const subscriptionsRaw = this.model.rawCollection(); + const distinct = Meteor.wrapAsync(subscriptionsRaw.distinct, subscriptionsRaw); + const query = { + rid, + 'u._id': { $ne: userId }, + autoTranslate: true, + }; + return distinct('autoTranslateLanguage', query); + } + + roleBaseQuery(userId, scope) { + if (scope == null) { + return; + } + + const query = { 'u._id': userId }; + if (!_.isUndefined(scope)) { + query.rid = scope; + } + return query; + } + + findByRidWithoutE2EKey(rid, options) { + const query = { + rid, + E2EKey: { + $exists: false, + }, + }; + + return this.find(query, options); + } + + updateAudioNotificationsById(_id, audioNotifications) { + const query = { + _id, + }; + + const update = {}; + + if (audioNotifications === 'default') { + update.$unset = { audioNotifications: 1 }; + } else { + update.$set = { audioNotifications }; + } + + return this.update(query, update); + } + + updateAudioNotificationValueById(_id, audioNotificationValue) { + const query = { + _id, + }; + + const update = { + $set: { + audioNotificationValue, + }, + }; + + return this.update(query, update); + } + + updateDesktopNotificationsById(_id, desktopNotifications) { + const query = { + _id, + }; + + const update = {}; + + if (desktopNotifications === null) { + update.$unset = { + desktopNotifications: 1, + desktopPrefOrigin: 1, + }; + } else { + update.$set = { + desktopNotifications: desktopNotifications.value, + desktopPrefOrigin: desktopNotifications.origin, + }; + } + + return this.update(query, update); + } + + updateDesktopNotificationDurationById(_id, value) { + const query = { + _id, + }; + + const update = { + $set: { + desktopNotificationDuration: parseInt(value), + }, + }; + + return this.update(query, update); + } + + updateMobilePushNotificationsById(_id, mobilePushNotifications) { + const query = { + _id, + }; + + const update = {}; + + if (mobilePushNotifications === null) { + update.$unset = { + mobilePushNotifications: 1, + mobilePrefOrigin: 1, + }; + } else { + update.$set = { + mobilePushNotifications: mobilePushNotifications.value, + mobilePrefOrigin: mobilePushNotifications.origin, + }; + } + + return this.update(query, update); + } + + updateEmailNotificationsById(_id, emailNotifications) { + const query = { + _id, + }; + + const update = {}; + + if (emailNotifications === null) { + update.$unset = { + emailNotifications: 1, + emailPrefOrigin: 1, + }; + } else { + update.$set = { + emailNotifications: emailNotifications.value, + emailPrefOrigin: emailNotifications.origin, + }; + } + + return this.update(query, update); + } + + updateUnreadAlertById(_id, unreadAlert) { + const query = { + _id, + }; + + const update = { + $set: { + unreadAlert, + }, + }; + + return this.update(query, update); + } + + updateDisableNotificationsById(_id, disableNotifications) { + const query = { + _id, + }; + + const update = { + $set: { + disableNotifications, + }, + }; + + return this.update(query, update); + } + + updateHideUnreadStatusById(_id, hideUnreadStatus) { + const query = { + _id, + }; + + const update = { + $set: { + hideUnreadStatus, + }, + }; + + return this.update(query, update); + } + + updateMuteGroupMentions(_id, muteGroupMentions) { + const query = { + _id, + }; + + const update = { + $set: { + muteGroupMentions, + }, + }; + + return this.update(query, update); + } + + findAlwaysNotifyAudioUsersByRoomId(roomId) { + const query = { + rid: roomId, + audioNotifications: 'all', + }; + + return this.find(query); + } + + findAlwaysNotifyDesktopUsersByRoomId(roomId) { + const query = { + rid: roomId, + desktopNotifications: 'all', + }; + + return this.find(query); + } + + findDontNotifyDesktopUsersByRoomId(roomId) { + const query = { + rid: roomId, + desktopNotifications: 'nothing', + }; + + return this.find(query); + } + + findAlwaysNotifyMobileUsersByRoomId(roomId) { + const query = { + rid: roomId, + mobilePushNotifications: 'all', + }; + + return this.find(query); + } + + findDontNotifyMobileUsersByRoomId(roomId) { + const query = { + rid: roomId, + mobilePushNotifications: 'nothing', + }; + + return this.find(query); + } + + findWithSendEmailByRoomId(roomId) { + const query = { + rid: roomId, + emailNotifications: { + $exists: true, + }, + }; + + return this.find(query, { fields: { emailNotifications: 1, u: 1 } }); + } + + findNotificationPreferencesByRoom(query/* { roomId: rid, desktopFilter: desktopNotifications, mobileFilter: mobilePushNotifications, emailFilter: emailNotifications }*/) { + return this._db.find(query, { + fields: { + + // fields needed for notifications + rid: 1, + t: 1, + u: 1, + name: 1, + fname: 1, + code: 1, + + // fields to define if should send a notification + ignored: 1, + audioNotifications: 1, + audioNotificationValue: 1, + desktopNotificationDuration: 1, + desktopNotifications: 1, + mobilePushNotifications: 1, + emailNotifications: 1, + disableNotifications: 1, + muteGroupMentions: 1, + userHighlights: 1, + }, + }); + } + + findAllMessagesNotificationPreferencesByRoom(roomId) { + const query = { + rid: roomId, + 'u._id': { $exists: true }, + $or: [ + { desktopNotifications: { $in: ['all', 'mentions'] } }, + { mobilePushNotifications: { $in: ['all', 'mentions'] } }, + { emailNotifications: { $in: ['all', 'mentions'] } }, + ], + }; + + return this._db.find(query, { + fields: { + 'u._id': 1, + audioNotifications: 1, + audioNotificationValue: 1, + desktopNotificationDuration: 1, + desktopNotifications: 1, + mobilePushNotifications: 1, + emailNotifications: 1, + disableNotifications: 1, + muteGroupMentions: 1, + }, + }); + } + + resetUserE2EKey(userId) { + this.update({ 'u._id': userId }, { + $unset: { + E2EKey: '', + }, + }, { + multi: true, + }); + } + + findByUserIdWithoutE2E(userId, options) { + const query = { + 'u._id': userId, + E2EKey: { + $exists: false, + }, + }; + + return this.find(query, options); + } + + updateGroupE2EKey(_id, key) { + const query = { _id }; + const update = { $set: { E2EKey: key } }; + this.update(query, update); + return this.findOne({ _id }); + } + + findUsersInRoles(roles, scope, options) { + roles = [].concat(roles); + + const query = { + roles: { $in: roles }, + }; + + if (scope) { + query.rid = scope; + } + + const subscriptions = this.find(query).fetch(); + + const users = _.compact(_.map(subscriptions, function(subscription) { + if (typeof subscription.u !== 'undefined' && typeof subscription.u._id !== 'undefined') { + return subscription.u._id; + } + })); + + return Users.find({ _id: { $in: users } }, options); + } + + // FIND ONE + findOneByRoomIdAndUserId(roomId, userId, options) { + const query = { + rid: roomId, + 'u._id': userId, + }; + + return this.findOne(query, options); + } + + findOneByRoomIdAndUsername(roomId, username, options) { + const query = { + rid: roomId, + 'u.username': username, + }; + + return this.findOne(query, options); + } + + findOneByRoomNameAndUserId(roomName, userId) { + const query = { + name: roomName, + 'u._id': userId, + }; + + return this.findOne(query); + } + + // FIND + findByUserId(userId, options) { + const query = { 'u._id': userId }; + + return this.find(query, options); + } + + findByUserIdAndType(userId, type, options) { + const query = { + 'u._id': userId, + t: type, + }; + + return this.find(query, options); + } + + findByUserIdAndTypes(userId, types, options) { + const query = { + 'u._id': userId, + t: { + $in: types, + }, + }; + + return this.find(query, options); + } + + findByUserIdUpdatedAfter(userId, updatedAt, options) { + const query = { + 'u._id': userId, + _updatedAt: { + $gt: updatedAt, + }, + }; + + return this.find(query, options); + } + + findByRoomIdAndRoles(roomId, roles, options) { + roles = [].concat(roles); + const query = { + rid: roomId, + roles: { $in: roles }, + }; + + return this.find(query, options); + } + + findByType(types, options) { + const query = { + t: { + $in: types, + }, + }; + + return this.find(query, options); + } + + findByTypeAndUserId(type, userId, options) { + const query = { + t: type, + 'u._id': userId, + }; + + return this.find(query, options); + } + + findByRoomId(roomId, options) { + const query = { rid: roomId }; + + return this.find(query, options); + } + + findByRoomIdAndNotUserId(roomId, userId, options) { + const query = { + rid: roomId, + 'u._id': { + $ne: userId, + }, + }; + + return this.find(query, options); + } + + findByRoomAndUsersWithUserHighlights(roomId, users, options) { + const query = { + rid: roomId, + 'u._id': { $in: users }, + 'userHighlights.0': { $exists: true }, + }; + + return this.find(query, options); + } + + findByRoomWithUserHighlights(roomId, options) { + const query = { + rid: roomId, + 'userHighlights.0': { $exists: true }, + }; + + return this.find(query, options); + } + + getLastSeen(options) { + if (options == null) { + options = {}; + } + const query = { ls: { $exists: 1 } }; + options.sort = { ls: -1 }; + options.limit = 1; + const [subscription] = this.find(query, options).fetch(); + return subscription && subscription.ls; + } + + findByRoomIdAndUserIds(roomId, userIds, options) { + const query = { + rid: roomId, + 'u._id': { + $in: userIds, + }, + }; + + return this.find(query, options); + } + + findByRoomIdAndUserIdsOrAllMessages(roomId, userIds) { + const query = { + rid: roomId, + $or: [ + { 'u._id': { $in: userIds } }, + { emailNotifications: 'all' }, + ], + }; + + return this.find(query); + } + + findByRoomIdWhenUserIdExists(rid, options) { + const query = { rid, 'u._id': { $exists: 1 } }; + + return this.find(query, options); + } + + findByRoomIdWhenUsernameExists(rid, options) { + const query = { rid, 'u.username': { $exists: 1 } }; + + return this.find(query, options); + } + + findUnreadByUserId(userId) { + const query = { + 'u._id': userId, + unread: { + $gt: 0, + }, + }; + + return this.find(query, { fields: { unread: 1 } }); + } + + getMinimumLastSeenByRoomId(rid) { + return this.db.findOne({ + rid, + }, { + sort: { + ls: 1, + }, + fields: { + ls: 1, + }, + }); + } + + // UPDATE + archiveByRoomId(roomId) { + const query = { rid: roomId }; + + const update = { + $set: { + alert: false, + open: false, + archived: true, + }, + }; + + return this.update(query, update, { multi: true }); + } + + unarchiveByRoomId(roomId) { + const query = { rid: roomId }; + + const update = { + $set: { + alert: false, + open: true, + archived: false, + }, + }; + + return this.update(query, update, { multi: true }); + } + + hideByRoomIdAndUserId(roomId, userId) { + const query = { + rid: roomId, + 'u._id': userId, + }; + + const update = { + $set: { + alert: false, + open: false, + }, + }; + + return this.update(query, update); + } + + openByRoomIdAndUserId(roomId, userId) { + const query = { + rid: roomId, + 'u._id': userId, + }; + + const update = { + $set: { + open: true, + }, + }; + + return this.update(query, update); + } + + setAsReadByRoomIdAndUserId(roomId, userId) { + const query = { + rid: roomId, + 'u._id': userId, + }; + + const update = { + $set: { + open: true, + alert: false, + unread: 0, + userMentions: 0, + groupMentions: 0, + ls: new Date(), + }, + }; + + return this.update(query, update); + } + + setAsUnreadByRoomIdAndUserId(roomId, userId, firstMessageUnreadTimestamp) { + const query = { + rid: roomId, + 'u._id': userId, + }; + + const update = { + $set: { + open: true, + alert: true, + ls: firstMessageUnreadTimestamp, + }, + }; + + return this.update(query, update); + } + + setCustomFieldsDirectMessagesByUserId(userId, fields) { + const query = { + 'u._id': userId, + t: 'd', + }; + const update = { $set: { customFields: fields } }; + const options = { multi: true }; + + return this.update(query, update, options); + } + + setFavoriteByRoomIdAndUserId(roomId, userId, favorite) { + if (favorite == null) { + favorite = true; + } + const query = { + rid: roomId, + 'u._id': userId, + }; + + const update = { + $set: { + f: favorite, + }, + }; + + return this.update(query, update); + } + + updateNameAndAlertByRoomId(roomId, name, fname) { + const query = { rid: roomId }; + + const update = { + $set: { + name, + fname, + alert: true, + }, + }; + + return this.update(query, update, { multi: true }); + } + + updateDisplayNameByRoomId(roomId, fname) { + const query = { rid: roomId }; + + const update = { + $set: { + fname, + name: fname, + }, + }; + + return this.update(query, update, { multi: true }); + } + + setUserUsernameByUserId(userId, username) { + const query = { 'u._id': userId }; + + const update = { + $set: { + 'u.username': username, + }, + }; + + return this.update(query, update, { multi: true }); + } + + setNameForDirectRoomsWithOldName(oldName, name) { + const query = { + name: oldName, + t: 'd', + }; + + const update = { + $set: { + name, + }, + }; + + return this.update(query, update, { multi: true }); + } + + incUnreadForRoomIdExcludingUserId(roomId, userId, inc) { + if (inc == null) { + inc = 1; + } + const query = { + rid: roomId, + 'u._id': { + $ne: userId, + }, + }; + + const update = { + $set: { + alert: true, + open: true, + }, + $inc: { + unread: inc, + }, + }; + + return this.update(query, update, { multi: true }); + } + + incGroupMentionsAndUnreadForRoomIdExcludingUserId(roomId, userId, incGroup = 1, incUnread = 1) { + const query = { + rid: roomId, + 'u._id': { + $ne: userId, + }, + }; + + const update = { + $set: { + alert: true, + open: true, + }, + $inc: { + unread: incUnread, + groupMentions: incGroup, + }, + }; + + return this.update(query, update, { multi: true }); + } + + incUserMentionsAndUnreadForRoomIdAndUserIds(roomId, userIds, incUser = 1, incUnread = 1) { + const query = { + rid: roomId, + 'u._id': { + $in: userIds, + }, + }; + + const update = { + $set: { + alert: true, + open: true, + }, + $inc: { + unread: incUnread, + userMentions: incUser, + }, + }; + + return this.update(query, update, { multi: true }); + } + + ignoreUser({ _id, ignoredUser: ignored, ignore = true }) { + const query = { + _id, + }; + const update = { + }; + if (ignore) { + update.$addToSet = { ignored }; + } else { + update.$pull = { ignored }; + } + + return this.update(query, update); + } + + setAlertForRoomIdExcludingUserId(roomId, userId) { + const query = { + rid: roomId, + 'u._id': { + $ne: userId, + }, + alert: { $ne: true }, + }; + + const update = { + $set: { + alert: true, + }, + }; + return this.update(query, update, { multi: true }); + } + + setOpenForRoomIdExcludingUserId(roomId, userId) { + const query = { + rid: roomId, + 'u._id': { + $ne: userId, + }, + open: { $ne: true }, + }; + + const update = { + $set: { + open: true, + }, + }; + return this.update(query, update, { multi: true }); + } + + setBlockedByRoomId(rid, blocked, blocker) { + const query = { + rid, + 'u._id': blocked, + }; + + const update = { + $set: { + blocked: true, + }, + }; + + const query2 = { + rid, + 'u._id': blocker, + }; + + const update2 = { + $set: { + blocker: true, + }, + }; + + return this.update(query, update) && this.update(query2, update2); + } + + unsetBlockedByRoomId(rid, blocked, blocker) { + const query = { + rid, + 'u._id': blocked, + }; + + const update = { + $unset: { + blocked: 1, + }, + }; + + const query2 = { + rid, + 'u._id': blocker, + }; + + const update2 = { + $unset: { + blocker: 1, + }, + }; + + return this.update(query, update) && this.update(query2, update2); + } + + updateCustomFieldsByRoomId(rid, cfields) { + const query = { rid }; + const customFields = cfields || {}; + const update = { + $set: { + customFields, + }, + }; + + return this.update(query, update, { multi: true }); + } + + updateTypeByRoomId(roomId, type) { + const query = { rid: roomId }; + + const update = { + $set: { + t: type, + }, + }; + + return this.update(query, update, { multi: true }); + } + + addRoleById(_id, role) { + const query = { _id }; + + const update = { + $addToSet: { + roles: role, + }, + }; + + return this.update(query, update); + } + + removeRoleById(_id, role) { + const query = { _id }; + + const update = { + $pull: { + roles: role, + }, + }; + + return this.update(query, update); + } + + setArchivedByUsername(username, archived) { + const query = { + t: 'd', + name: username, + }; + + const update = { + $set: { + archived, + }, + }; + + return this.update(query, update, { multi: true }); + } + + clearDesktopNotificationUserPreferences(userId) { + const query = { + 'u._id': userId, + desktopPrefOrigin: 'user', + }; + + const update = { + $unset: { + desktopNotifications: 1, + desktopPrefOrigin: 1, + }, + }; + + return this.update(query, update, { multi: true }); + } + + updateDesktopNotificationUserPreferences(userId, desktopNotifications) { + const query = { + 'u._id': userId, + desktopPrefOrigin: { + $ne: 'subscription', + }, + }; + + const update = { + $set: { + desktopNotifications, + desktopPrefOrigin: 'user', + }, + }; + + return this.update(query, update, { multi: true }); + } + + clearMobileNotificationUserPreferences(userId) { + const query = { + 'u._id': userId, + mobilePrefOrigin: 'user', + }; + + const update = { + $unset: { + mobilePushNotifications: 1, + mobilePrefOrigin: 1, + }, + }; + + return this.update(query, update, { multi: true }); + } + + updateMobileNotificationUserPreferences(userId, mobilePushNotifications) { + const query = { + 'u._id': userId, + mobilePrefOrigin: { + $ne: 'subscription', + }, + }; + + const update = { + $set: { + mobilePushNotifications, + mobilePrefOrigin: 'user', + }, + }; + + return this.update(query, update, { multi: true }); + } + + clearEmailNotificationUserPreferences(userId) { + const query = { + 'u._id': userId, + emailPrefOrigin: 'user', + }; + + const update = { + $unset: { + emailNotifications: 1, + emailPrefOrigin: 1, + }, + }; + + return this.update(query, update, { multi: true }); + } + + updateEmailNotificationUserPreferences(userId, emailNotifications) { + const query = { + 'u._id': userId, + emailPrefOrigin: { + $ne: 'subscription', + }, + }; + + const update = { + $set: { + emailNotifications, + emailPrefOrigin: 'user', + }, + }; + + return this.update(query, update, { multi: true }); + } + + updateUserHighlights(userId, userHighlights) { + const query = { + 'u._id': userId, + }; + + const update = { + $set: { + userHighlights, + }, + }; + + return this.update(query, update, { multi: true }); + } + + updateDirectFNameByName(name, fname) { + const query = { + t: 'd', + name, + }; + + let update; + if (fname) { + update = { + $set: { + fname, + }, + }; + } else { + update = { + $unset: { + fname: true, + }, + }; + } + + return this.update(query, update, { multi: true }); + } + + // INSERT + createWithRoomAndUser(room, user, extraData) { + const subscription = { + open: false, + alert: false, + unread: 0, + userMentions: 0, + groupMentions: 0, + ts: room.ts, + rid: room._id, + name: room.name, + fname: room.fname, + customFields: room.customFields, + t: room.t, + u: { + _id: user._id, + username: user.username, + name: user.name, + }, + ...getDefaultSubscriptionPref(user), + ...extraData, + }; + + if (room.prid) { + subscription.prid = room.prid; + } + + const result = this.insert(subscription); + + Rooms.incUsersCountById(room._id); + + return result; + } + + + // REMOVE + removeByUserId(userId) { + const query = { + 'u._id': userId, + }; + + const roomIds = this.findByUserId(userId).map((s) => s.rid); + + const result = this.remove(query); + + if (Match.test(result, Number) && result > 0) { + Rooms.incUsersCountByIds(roomIds, -1); + } + + return result; + } + + removeByRoomId(roomId) { + const query = { + rid: roomId, + }; + + const result = this.remove(query); + + if (Match.test(result, Number) && result > 0) { + Rooms.incUsersCountById(roomId, - result); + } + + return result; + } + + removeByRoomIdAndUserId(roomId, userId) { + const query = { + rid: roomId, + 'u._id': userId, + }; + + const result = this.remove(query); + + if (Match.test(result, Number) && result > 0) { + Rooms.incUsersCountById(roomId, - result); + } + + return result; + } + + // ////////////////////////////////////////////////////////////////// + // threads + + addUnreadThreadByRoomIdAndUserIds(rid, users, tmid) { + if (!users) { + return; + } + return this.update({ + 'u._id': { $in: users }, + rid, + }, { + $addToSet: { + tunread: tmid, + }, + }, { multi: true }); + } + + removeUnreadThreadByRoomIdAndUserId(rid, userId, tmid) { + return this.update({ + 'u._id': userId, + rid, + }, { + $pull: { + tunread: tmid, + }, + }); + } + + removeAllUnreadThreadsByRoomIdAndUserId(rid, userId) { + const query = { + rid, + 'u._id': userId, + }; + + const update = { + $unset: { + tunread: 1, + }, + }; + + return this.update(query, update); + } +} + +export default new Subscriptions('subscription', true); diff --git a/app/models/server/models/Uploads.js b/app/models/server/models/Uploads.js new file mode 100644 index 0000000000000..f9fa5fe4879d7 --- /dev/null +++ b/app/models/server/models/Uploads.js @@ -0,0 +1,113 @@ +import _ from 'underscore'; +import s from 'underscore.string'; +import { InstanceStatus } from 'meteor/konecty:multiple-instances-status'; + +import { Base } from './_Base'; + +export class Uploads extends Base { + constructor() { + super('uploads'); + + this.model.before.insert((userId, doc) => { + doc.instanceId = InstanceStatus.id(); + }); + + this.tryEnsureIndex({ rid: 1 }); + this.tryEnsureIndex({ uploadedAt: 1 }); + } + + findNotHiddenFilesOfRoom(roomId, searchText, limit) { + const fileQuery = { + rid: roomId, + complete: true, + uploading: false, + _hidden: { + $ne: true, + }, + }; + + if (searchText) { + fileQuery.name = { $regex: new RegExp(RegExp.escape(searchText), 'i') }; + } + + const fileOptions = { + limit, + sort: { + uploadedAt: -1, + }, + fields: { + _id: 1, + userId: 1, + rid: 1, + name: 1, + description: 1, + type: 1, + url: 1, + uploadedAt: 1, + }, + }; + + return this.find(fileQuery, fileOptions); + } + + insertFileInit(userId, store, file, extra) { + const fileData = { + userId, + store, + complete: false, + uploading: true, + progress: 0, + extension: s.strRightBack(file.name, '.'), + uploadedAt: new Date(), + }; + + _.extend(fileData, file, extra); + + if (this.model.direct && this.model.direct.insert != null) { + file = this.model.direct.insert(fileData); + } else { + file = this.insert(fileData); + } + + return file; + } + + updateFileComplete(fileId, userId, file) { + let result; + if (!fileId) { + return; + } + + const filter = { + _id: fileId, + userId, + }; + + const update = { + $set: { + complete: true, + uploading: false, + progress: 1, + }, + }; + + update.$set = _.extend(file, update.$set); + + if (this.model.direct && this.model.direct.update != null) { + result = this.model.direct.update(filter, update); + } else { + result = this.update(filter, update); + } + + return result; + } + + deleteFile(fileId) { + if (this.model.direct && this.model.direct.remove != null) { + return this.model.direct.remove({ _id: fileId }); + } + return this.remove({ _id: fileId }); + } +} + +export default new Uploads(); diff --git a/app/models/server/models/UserDataFiles.js b/app/models/server/models/UserDataFiles.js new file mode 100644 index 0000000000000..a877188ff03bc --- /dev/null +++ b/app/models/server/models/UserDataFiles.js @@ -0,0 +1,44 @@ +import _ from 'underscore'; + +import { Base } from './_Base'; + +export class UserDataFiles extends Base { + constructor() { + super('user_data_files'); + + this.tryEnsureIndex({ userId: 1 }); + } + + // FIND + findById(id) { + const query = { _id: id }; + return this.find(query); + } + + findLastFileByUser(userId, options = {}) { + const query = { + userId, + }; + + options.sort = { _updatedAt: -1 }; + return this.findOne(query, options); + } + + // INSERT + create(data) { + const userDataFile = { + createdAt: new Date(), + }; + + _.extend(userDataFile, data); + + return this.insert(userDataFile); + } + + // REMOVE + removeById(_id) { + return this.remove(_id); + } +} + +export default new UserDataFiles(); diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js new file mode 100644 index 0000000000000..0daea240a2ab7 --- /dev/null +++ b/app/models/server/models/Users.js @@ -0,0 +1,1101 @@ +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; +import _ from 'underscore'; +import s from 'underscore.string'; + +import { Base } from './_Base'; +import Subscriptions from './Subscriptions'; +import { settings } from '../../../settings/server/functions/settings'; + +export class Users extends Base { + constructor(...args) { + super(...args); + + this.tryEnsureIndex({ roles: 1 }, { sparse: 1 }); + this.tryEnsureIndex({ name: 1 }); + this.tryEnsureIndex({ lastLogin: 1 }); + this.tryEnsureIndex({ status: 1 }); + this.tryEnsureIndex({ active: 1 }, { sparse: 1 }); + this.tryEnsureIndex({ statusConnection: 1 }, { sparse: 1 }); + this.tryEnsureIndex({ type: 1 }); + this.tryEnsureIndex({ 'visitorEmails.address': 1 }); + this.tryEnsureIndex({ federation: 1 }, { sparse: true }); + } + + getLoginTokensByUserId(userId) { + const query = { + 'services.resume.loginTokens.type': { + $exists: true, + $eq: 'personalAccessToken', + }, + _id: userId, + }; + + return this.find(query, { fields: { 'services.resume.loginTokens': 1 } }); + } + + addPersonalAccessTokenToUser({ userId, loginTokenObject }) { + return this.update(userId, { + $push: { + 'services.resume.loginTokens': loginTokenObject, + }, + }); + } + + removePersonalAccessTokenOfUser({ userId, loginTokenObject }) { + return this.update(userId, { + $pull: { + 'services.resume.loginTokens': loginTokenObject, + }, + }); + } + + findPersonalAccessTokenByTokenNameAndUserId({ userId, tokenName }) { + const query = { + 'services.resume.loginTokens': { + $elemMatch: { name: tokenName, type: 'personalAccessToken' }, + }, + _id: userId, + }; + + return this.findOne(query); + } + + setOperator(_id, operator) { + const update = { + $set: { + operator, + }, + }; + + return this.update(_id, update); + } + + findOnlineAgents() { + const query = { + status: { + $exists: true, + $ne: 'offline', + }, + statusLivechat: 'available', + roles: 'livechat-agent', + }; + + return this.find(query); + } + + findOneOnlineAgentByUsername(username) { + const query = { + username, + status: { + $exists: true, + $ne: 'offline', + }, + statusLivechat: 'available', + roles: 'livechat-agent', + }; + + return this.findOne(query); + } + + findOneOnlineAgentById(_id) { + const query = { + _id, + status: { + $exists: true, + $ne: 'offline', + }, + statusLivechat: 'available', + roles: 'livechat-agent', + }; + + return this.findOne(query); + } + + findAgents() { + const query = { + roles: 'livechat-agent', + }; + + return this.find(query); + } + + findOnlineUserFromList(userList) { + const query = { + status: { + $exists: true, + $ne: 'offline', + }, + statusLivechat: 'available', + roles: 'livechat-agent', + username: { + $in: [].concat(userList), + }, + }; + + return this.find(query); + } + + getNextAgent() { + const query = { + status: { + $exists: true, + $ne: 'offline', + }, + statusLivechat: 'available', + roles: 'livechat-agent', + }; + + const collectionObj = this.model.rawCollection(); + const findAndModify = Meteor.wrapAsync(collectionObj.findAndModify, collectionObj); + + const sort = { + livechatCount: 1, + username: 1, + }; + + const update = { + $inc: { + livechatCount: 1, + }, + }; + + const user = findAndModify(query, sort, update); + if (user && user.value) { + return { + agentId: user.value._id, + username: user.value.username, + }; + } + return null; + } + + setLivechatStatus(userId, status) { + const query = { + _id: userId, + }; + + const update = { + $set: { + statusLivechat: status, + }, + }; + + return this.update(query, update); + } + + closeOffice() { + this.findAgents().forEach((agent) => this.setLivechatStatus(agent._id, 'not-available')); + } + + openOffice() { + this.findAgents().forEach((agent) => this.setLivechatStatus(agent._id, 'available')); + } + + getAgentInfo(agentId) { + const query = { + _id: agentId, + }; + + const options = { + fields: { + name: 1, + username: 1, + phone: 1, + customFields: 1, + status: 1, + }, + }; + + if (settings.get('Livechat_show_agent_email')) { + options.fields.emails = 1; + } + + return this.findOne(query, options); + } + + setTokenpassTcaBalances(_id, tcaBalances) { + const update = { + $set: { + 'services.tokenpass.tcaBalances': tcaBalances, + }, + }; + + return this.update(_id, update); + } + + getTokenBalancesByUserId(userId) { + const query = { + _id: userId, + }; + + const options = { + fields: { + 'services.tokenpass.tcaBalances': 1, + }, + }; + + return this.findOne(query, options); + } + + roleBaseQuery(userId) { + return { _id: userId }; + } + + setE2EPublicAndPivateKeysByUserId(userId, { public_key, private_key }) { + this.update({ _id: userId }, { + $set: { + 'e2e.public_key': public_key, + 'e2e.private_key': private_key, + }, + }); + } + + rocketMailUnsubscribe(_id, createdAt) { + const query = { + _id, + createdAt: new Date(parseInt(createdAt)), + }; + const update = { + $set: { + 'mailer.unsubscribed': true, + }, + }; + const affectedRows = this.update(query, update); + console.log('[Mailer:Unsubscribe]', _id, createdAt, new Date(parseInt(createdAt)), affectedRows); + return affectedRows; + } + + fetchKeysByUserId(userId) { + const user = this.findOne({ _id: userId }, { fields: { e2e: 1 } }); + + if (!user || !user.e2e || !user.e2e.public_key) { + return {}; + } + + return { + public_key: user.e2e.public_key, + private_key: user.e2e.private_key, + }; + } + + disable2FAAndSetTempSecretByUserId(userId, tempToken) { + return this.update({ + _id: userId, + }, { + $set: { + 'services.totp': { + enabled: false, + tempSecret: tempToken, + }, + }, + }); + } + + enable2FAAndSetSecretAndCodesByUserId(userId, secret, backupCodes) { + return this.update({ + _id: userId, + }, { + $set: { + 'services.totp.enabled': true, + 'services.totp.secret': secret, + 'services.totp.hashedBackup': backupCodes, + }, + $unset: { + 'services.totp.tempSecret': 1, + }, + }); + } + + disable2FAByUserId(userId) { + return this.update({ + _id: userId, + }, { + $set: { + 'services.totp': { + enabled: false, + }, + }, + }); + } + + update2FABackupCodesByUserId(userId, backupCodes) { + return this.update({ + _id: userId, + }, { + $set: { + 'services.totp.hashedBackup': backupCodes, + }, + }); + } + + findByIdsWithPublicE2EKey(ids, options) { + const query = { + _id: { + $in: ids, + }, + 'e2e.public_key': { + $exists: 1, + }, + }; + + return this.find(query, options); + } + + resetE2EKey(userId) { + this.update({ _id: userId }, { + $unset: { + e2e: '', + }, + }); + } + + findUsersInRoles(roles, scope, options) { + roles = [].concat(roles); + + const query = { + roles: { $in: roles }, + }; + + return this.find(query, options); + } + + findOneByImportId(_id, options) { + return this.findOne({ importIds: _id }, options); + } + + findOneByUsernameIgnoringCase(username, options) { + if (typeof username === 'string') { + username = new RegExp(`^${ s.escapeRegExp(username) }$`, 'i'); + } + + const query = { username }; + + return this.findOne(query, options); + } + + findOneByUsername(username, options) { + const query = { username }; + + return this.findOne(query, options); + } + + findOneByEmailAddress(emailAddress, options) { + const query = { 'emails.address': new RegExp(`^${ s.escapeRegExp(emailAddress) }$`, 'i') }; + + return this.findOne(query, options); + } + + findOneAdmin(admin, options) { + const query = { admin }; + + return this.findOne(query, options); + } + + findOneByIdAndLoginToken(_id, token, options) { + const query = { + _id, + 'services.resume.loginTokens.hashedToken': Accounts._hashLoginToken(token), + }; + + return this.findOne(query, options); + } + + findOneById(userId, options) { + const query = { _id: userId }; + + return this.findOne(query, options); + } + + // FIND + findById(userId) { + const query = { _id: userId }; + + return this.find(query); + } + + findByIds(users, options) { + const query = { _id: { $in: users } }; + return this.find(query, options); + } + + findUsersNotOffline(options) { + const query = { + username: { + $exists: 1, + }, + status: { + $in: ['online', 'away', 'busy'], + }, + }; + + return this.find(query, options); + } + + findNotIdUpdatedFrom(uid, from, options) { + const query = { + _id: { $ne: uid }, + username: { + $exists: 1, + }, + _updatedAt: { $gte: from }, + }; + + return this.find(query, options); + } + + findByRoomId(rid, options) { + const data = Subscriptions.findByRoomId(rid).fetch().map((item) => item.u._id); + const query = { + _id: { + $in: data, + }, + }; + + return this.find(query, options); + } + + findByUsername(username, options) { + const query = { username }; + + return this.find(query, options); + } + + findActiveByUsernameOrNameRegexWithExceptions(searchTerm, exceptions, options) { + if (exceptions == null) { exceptions = []; } + if (options == null) { options = {}; } + if (!_.isArray(exceptions)) { + exceptions = [exceptions]; + } + + const termRegex = new RegExp(s.escapeRegExp(searchTerm), 'i'); + const query = { + $or: [{ + username: termRegex, + }, { + name: termRegex, + }], + active: true, + type: { + $in: ['user', 'bot'], + }, + $and: [{ + username: { + $exists: true, + }, + }, { + username: { + $nin: exceptions, + }, + }], + }; + + return this.find(query, options); + } + + findByActiveUsersExcept(searchTerm, exceptions, options, forcedSearchFields, extraQuery = []) { + if (exceptions == null) { exceptions = []; } + if (options == null) { options = {}; } + if (!_.isArray(exceptions)) { + exceptions = [exceptions]; + } + + const termRegex = new RegExp(s.escapeRegExp(searchTerm), 'i'); + + const searchFields = forcedSearchFields || settings.get('Accounts_SearchFields').trim().split(','); + + const orStmt = _.reduce(searchFields, function(acc, el) { + acc.push({ [el.trim()]: termRegex }); + return acc; + }, []); + const query = { + $and: [ + { + active: true, + $or: orStmt, + }, + { + username: { $exists: true, $nin: exceptions }, + }, + ...extraQuery, + ], + }; + + // do not use cache + return this._db.find(query, options); + } + + findByActiveLocalUsersExcept(searchTerm, exceptions, options, forcedSearchFields, localPeer) { + const extraQuery = [ + { + $or: [ + { federation: { $exists: false } }, + { 'federation.peer': localPeer }, + ], + }, + ]; + return this.findByActiveUsersExcept(searchTerm, exceptions, options, forcedSearchFields, extraQuery); + } + + findByActiveExternalUsersExcept(searchTerm, exceptions, options, forcedSearchFields, localPeer) { + const extraQuery = [ + { federation: { $exists: true } }, + { 'federation.peer': { $ne: localPeer } }, + ]; + return this.findByActiveUsersExcept(searchTerm, exceptions, options, forcedSearchFields, extraQuery); + } + + findUsersByNameOrUsername(nameOrUsername, options) { + const query = { + username: { + $exists: 1, + }, + + $or: [ + { name: nameOrUsername }, + { username: nameOrUsername }, + ], + + type: { + $in: ['user'], + }, + }; + + return this.find(query, options); + } + + findByUsernameNameOrEmailAddress(usernameNameOrEmailAddress, options) { + const query = { + $or: [ + { name: usernameNameOrEmailAddress }, + { username: usernameNameOrEmailAddress }, + { 'emails.address': usernameNameOrEmailAddress }, + ], + type: { + $in: ['user', 'bot'], + }, + }; + + return this.find(query, options); + } + + findLDAPUsers(options) { + const query = { ldap: true }; + + return this.find(query, options); + } + + findCrowdUsers(options) { + const query = { crowd: true }; + + return this.find(query, options); + } + + getLastLogin(options) { + if (options == null) { options = {}; } + const query = { lastLogin: { $exists: 1 } }; + options.sort = { lastLogin: -1 }; + options.limit = 1; + const [user] = this.find(query, options).fetch(); + return user && user.lastLogin; + } + + findUsersByUsernames(usernames, options) { + const query = { + username: { + $in: usernames, + }, + }; + + return this.find(query, options); + } + + findUsersByIds(ids, options) { + const query = { + _id: { + $in: ids, + }, + }; + return this.find(query, options); + } + + findUsersWithUsernameByIds(ids, options) { + const query = { + _id: { + $in: ids, + }, + username: { + $exists: 1, + }, + }; + + return this.find(query, options); + } + + findUsersWithUsernameByIdsNotOffline(ids, options) { + const query = { + _id: { + $in: ids, + }, + username: { + $exists: 1, + }, + status: { + $in: ['online', 'away', 'busy'], + }, + }; + + return this.find(query, options); + } + + getOldest(fields = { _id: 1 }) { + const query = { + _id: { + $ne: 'rocket.cat', + }, + }; + + const options = { + fields, + sort: { + createdAt: 1, + }, + }; + + return this.findOne(query, options); + } + + // UPDATE + addImportIds(_id, importIds) { + importIds = [].concat(importIds); + + const query = { _id }; + + const update = { + $addToSet: { + importIds: { + $each: importIds, + }, + }, + }; + + return this.update(query, update); + } + + updateLastLoginById(_id) { + const update = { + $set: { + lastLogin: new Date(), + }, + }; + + return this.update(_id, update); + } + + setServiceId(_id, serviceName, serviceId) { + const update = { $set: {} }; + + const serviceIdKey = `services.${ serviceName }.id`; + update.$set[serviceIdKey] = serviceId; + + return this.update(_id, update); + } + + setUsername(_id, username) { + const update = { $set: { username } }; + + return this.update(_id, update); + } + + setEmail(_id, email) { + const update = { + $set: { + emails: [{ + address: email, + verified: false, + }, + ], + }, + }; + + return this.update(_id, update); + } + + setEmailVerified(_id, email) { + const query = { + _id, + emails: { + $elemMatch: { + address: email, + verified: false, + }, + }, + }; + + const update = { + $set: { + 'emails.$.verified': true, + }, + }; + + return this.update(query, update); + } + + setName(_id, name) { + const update = { + $set: { + name, + }, + }; + + return this.update(_id, update); + } + + unsetName(_id) { + const update = { + $unset: { + name, + }, + }; + + return this.update(_id, update); + } + + setCustomFields(_id, fields) { + const values = {}; + Object.keys(fields).forEach((key) => { + values[`customFields.${ key }`] = fields[key]; + }); + + const update = { $set: values }; + + return this.update(_id, update); + } + + setAvatarOrigin(_id, origin) { + const update = { + $set: { + avatarOrigin: origin, + }, + }; + + return this.update(_id, update); + } + + unsetAvatarOrigin(_id) { + const update = { + $unset: { + avatarOrigin: 1, + }, + }; + + return this.update(_id, update); + } + + setUserActive(_id, active) { + if (active == null) { active = true; } + const update = { + $set: { + active, + }, + }; + + return this.update(_id, update); + } + + setAllUsersActive(active) { + const update = { + $set: { + active, + }, + }; + + return this.update({}, update, { multi: true }); + } + + unsetLoginTokens(_id) { + const update = { + $set: { + 'services.resume.loginTokens': [], + }, + }; + + return this.update(_id, update); + } + + unsetRequirePasswordChange(_id) { + const update = { + $unset: { + requirePasswordChange: true, + requirePasswordChangeReason: true, + }, + }; + + return this.update(_id, update); + } + + resetPasswordAndSetRequirePasswordChange(_id, requirePasswordChange, requirePasswordChangeReason) { + const update = { + $unset: { + 'services.password': 1, + }, + $set: { + requirePasswordChange, + requirePasswordChangeReason, + }, + }; + + return this.update(_id, update); + } + + setLanguage(_id, language) { + const update = { + $set: { + language, + }, + }; + + return this.update(_id, update); + } + + setProfile(_id, profile) { + const update = { + $set: { + 'settings.profile': profile, + }, + }; + + return this.update(_id, update); + } + + clearSettings(_id) { + const update = { + $set: { + settings: {}, + }, + }; + + return this.update(_id, update); + } + + setPreferences(_id, preferences) { + const settingsObject = Object.assign( + {}, + ...Object.keys(preferences).map((key) => ({ [`settings.preferences.${ key }`]: preferences[key] })) + ); + + const update = { + $set: settingsObject, + }; + if (parseInt(preferences.clockMode) === 0) { + delete update.$set['settings.preferences.clockMode']; + update.$unset = { 'settings.preferences.clockMode': 1 }; + } + + return this.update(_id, update); + } + + setUtcOffset(_id, utcOffset) { + const query = { + _id, + utcOffset: { + $ne: utcOffset, + }, + }; + + const update = { + $set: { + utcOffset, + }, + }; + + return this.update(query, update); + } + + saveUserById(_id, data) { + const setData = {}; + const unsetData = {}; + + if (data.name != null) { + if (!_.isEmpty(s.trim(data.name))) { + setData.name = s.trim(data.name); + } else { + unsetData.name = 1; + } + } + + if (data.email != null) { + if (!_.isEmpty(s.trim(data.email))) { + setData.emails = [{ address: s.trim(data.email) }]; + } else { + unsetData.emails = 1; + } + } + + if (data.phone != null) { + if (!_.isEmpty(s.trim(data.phone))) { + setData.phone = [{ phoneNumber: s.trim(data.phone) }]; + } else { + unsetData.phone = 1; + } + } + + const update = {}; + + if (!_.isEmpty(setData)) { + update.$set = setData; + } + + if (!_.isEmpty(unsetData)) { + update.$unset = unsetData; + } + + if (_.isEmpty(update)) { + return true; + } + + return this.update({ _id }, update); + } + + setReason(_id, reason) { + const update = { + $set: { + reason, + }, + }; + + return this.update(_id, update); + } + + unsetReason(_id) { + const update = { + $unset: { + reason: true, + }, + }; + + return this.update(_id, update); + } + + bannerExistsById(_id, bannerId) { + const query = { + _id, + [`banners.${ bannerId }`]: { + $exists: true, + }, + }; + + return this.find(query).count() !== 0; + } + + addBannerById(_id, banner) { + const query = { + _id, + [`banners.${ banner.id }.read`]: { + $ne: true, + }, + }; + + const update = { + $set: { + [`banners.${ banner.id }`]: banner, + }, + }; + + return this.update(query, update); + } + + setBannerReadById(_id, bannerId) { + const update = { + $set: { + [`banners.${ bannerId }.read`]: true, + }, + }; + + return this.update({ _id }, update); + } + + removeBannerById(_id, banner) { + const update = { + $unset: { + [`banners.${ banner.id }`]: true, + }, + }; + + return this.update({ _id }, update); + } + + removeResumeService(_id) { + const update = { + $unset: { + 'services.resume': '', + }, + }; + + return this.update({ _id }, update); + } + + updateDefaultStatus(_id, statusDefault) { + return this.update({ + _id, + statusDefault: { $ne: statusDefault }, + }, { + $set: { + statusDefault, + }, + }); + } + + // INSERT + create(data) { + const user = { + createdAt: new Date(), + avatarOrigin: 'none', + }; + + _.extend(user, data); + + return this.insert(user); + } + + + // REMOVE + removeById(_id) { + return this.remove(_id); + } + + /* +Find users to send a message by email if: +- he is not online +- has a verified email +- has not disabled email notifications +- `active` is equal to true (false means they were deactivated and can't login) +*/ + getUsersToSendOfflineEmail(usersIds) { + const query = { + _id: { + $in: usersIds, + }, + active: true, + status: 'offline', + statusConnection: { + $ne: 'online', + }, + 'emails.verified': true, + }; + + const options = { + fields: { + name: 1, + username: 1, + emails: 1, + 'settings.preferences.emailNotificationMode': 1, + language: 1, + }, + }; + + return this.find(query, options); + } +} + +export default new Users(Meteor.users, true); diff --git a/app/models/server/models/WebdavAccounts.js b/app/models/server/models/WebdavAccounts.js new file mode 100644 index 0000000000000..b3d8f4875bf66 --- /dev/null +++ b/app/models/server/models/WebdavAccounts.js @@ -0,0 +1,27 @@ +/** + * Webdav Accounts model + */ +import { Base } from './_Base'; + +export class WebdavAccounts extends Base { + constructor() { + super('webdav_accounts'); + + this.tryEnsureIndex({ user_id: 1, server_url: 1, username: 1, name: 1 }, { unique: 1 }); + } + + findWithUserId(user_id, options) { + const query = { user_id }; + return this.find(query, options); + } + + removeByUserAndId(_id, user_id) { + return this.remove({ _id, user_id }); + } + + removeById(_id) { + return this.remove({ _id }); + } +} + +export default new WebdavAccounts(); diff --git a/app/models/server/models/_Base.js b/app/models/server/models/_Base.js new file mode 100644 index 0000000000000..87abf5aed6848 --- /dev/null +++ b/app/models/server/models/_Base.js @@ -0,0 +1,330 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import objectPath from 'object-path'; +import _ from 'underscore'; + +import { BaseDb } from './_BaseDb'; + +export class Base { + constructor(nameOrModel) { + this._db = new BaseDb(nameOrModel, this); + this.model = this._db.model; + this.collectionName = this._db.collectionName; + this.name = this._db.name; + + this.on = this._db.on.bind(this._db); + this.emit = this._db.emit.bind(this._db); + + this.db = this; + } + + get origin() { + return '_db'; + } + + roleBaseQuery() { + + } + + findRolesByUserId(userId) { + const query = this.roleBaseQuery(userId); + return this.find(query, { fields: { roles: 1 } }); + } + + isUserInRole(userId, roleName, scope) { + const query = this.roleBaseQuery(userId, scope); + + if (query == null) { + return false; + } + + query.roles = roleName; + return !_.isUndefined(this.findOne(query, { fields: { roles: 1 } })); + } + + addRolesByUserId(userId, roles, scope) { + roles = [].concat(roles); + const query = this.roleBaseQuery(userId, scope); + const update = { + $addToSet: { + roles: { $each: roles }, + }, + }; + return this.update(query, update); + } + + removeRolesByUserId(userId, roles, scope) { + roles = [].concat(roles); + const query = this.roleBaseQuery(userId, scope); + const update = { + $pullAll: { + roles, + }, + }; + return this.update(query, update); + } + + findUsersInRoles() { + throw new Meteor.Error('overwrite-function', 'You must overwrite this function in the extended classes'); + } + + arrayToCursor(data) { + return { + fetch() { + return data; + }, + count() { + return data.length; + }, + forEach(fn) { + return data.forEach(fn); + }, + }; + } + + setUpdatedAt(...args/* record, checkQuery, query*/) { + return this._db.setUpdatedAt(...args); + } + + find(...args) { + try { + return this[this.origin].find(...args); + } catch (e) { + console.error('Exception on find', e, ...args); + } + } + + findOne(...args) { + try { + return this[this.origin].findOne(...args); + } catch (e) { + console.error('Exception on find', e, ...args); + } + } + + findOneById(...args) { + try { + return this[this.origin].findOneById(...args); + } catch (e) { + console.error('Exception on find', e, ...args); + } + } + + findOneByIds(ids, options, ...args) { + check(ids, [String]); + + try { + return this[this.origin].findOneByIds(ids, options); + } catch (e) { + console.error('Exception on find', e, [ids, options, ...args]); + } + } + + insert(...args/* record*/) { + return this._db.insert(...args); + } + + update(...args/* query, update, options*/) { + return this._db.update(...args); + } + + upsert(...args/* query, update*/) { + return this._db.upsert(...args); + } + + remove(...args/* query*/) { + return this._db.remove(...args); + } + + insertOrUpsert(...args) { + return this._db.insertOrUpsert(...args); + } + + allow(...args) { + return this._db.allow(...args); + } + + deny(...args) { + return this._db.deny(...args); + } + + ensureIndex(...args) { + return this._db.ensureIndex(...args); + } + + dropIndex(...args) { + return this._db.dropIndex(...args); + } + + tryEnsureIndex(...args) { + return this._db.tryEnsureIndex(...args); + } + + tryDropIndex(...args) { + return this._db.tryDropIndex(...args); + } + + trashFind(...args/* query, options*/) { + return this._db.trashFind(...args); + } + + trashFindOneById(...args/* _id, options*/) { + return this._db.trashFindOneById(...args); + } + + trashFindDeletedAfter(...args/* deletedAt, query, options*/) { + return this._db.trashFindDeletedAfter(...args); + } + + trashFindDeleted(...args) { + return this._db.trashFindDeleted(...args); + } + + processQueryOptionsOnResult(result, options = {}) { + if (result === undefined || result === null) { + return undefined; + } + + if (Array.isArray(result)) { + if (options.sort) { + result = result.sort((a, b) => { + let r = 0; + for (const field in options.sort) { + if (options.sort.hasOwnProperty(field)) { + const direction = options.sort[field]; + let valueA; + let valueB; + if (field.indexOf('.') > -1) { + valueA = objectPath.get(a, field); + valueB = objectPath.get(b, field); + } else { + valueA = a[field]; + valueB = b[field]; + } + if (valueA > valueB) { + r = direction; + break; + } + if (valueA < valueB) { + r = -direction; + break; + } + } + } + return r; + }); + } + + if (typeof options.skip === 'number') { + result.splice(0, options.skip); + } + + if (typeof options.limit === 'number' && options.limit !== 0) { + result.splice(options.limit); + } + } + + if (!options.fields) { + options.fields = {}; + } + + const fieldsToRemove = []; + const fieldsToGet = []; + + for (const field in options.fields) { + if (options.fields.hasOwnProperty(field)) { + if (options.fields[field] === 0) { + fieldsToRemove.push(field); + } else if (options.fields[field] === 1) { + fieldsToGet.push(field); + } + } + } + + if (fieldsToRemove.length > 0 && fieldsToGet.length > 0) { + console.warn('Can\'t mix remove and get fields'); + fieldsToRemove.splice(0, fieldsToRemove.length); + } + + if (fieldsToGet.length > 0 && fieldsToGet.indexOf('_id') === -1) { + fieldsToGet.push('_id'); + } + + const pickFields = (obj, fields) => { + const picked = {}; + fields.forEach((field) => { + if (field.indexOf('.') !== -1) { + objectPath.set(picked, field, objectPath.get(obj, field)); + } else { + picked[field] = obj[field]; + } + }); + return picked; + }; + + if (fieldsToRemove.length > 0 || fieldsToGet.length > 0) { + if (Array.isArray(result)) { + result = result.map((record) => { + if (fieldsToRemove.length > 0) { + return _.omit(record, ...fieldsToRemove); + } + + if (fieldsToGet.length > 0) { + return pickFields(record, fieldsToGet); + } + + return null; + }); + } else { + if (fieldsToRemove.length > 0) { + return _.omit(result, ...fieldsToRemove); + } + + if (fieldsToGet.length > 0) { + return pickFields(result, fieldsToGet); + } + } + } + + return result; + } + + // dinamicTrashFindAfter(method, deletedAt, ...args) { + // const scope = { + // find: (query={}) => { + // return this.trashFindDeletedAfter(deletedAt, query, { fields: {_id: 1, _deletedAt: 1} }); + // } + // }; + + // scope.model = { + // find: scope.find + // }; + + // return this[method].apply(scope, args); + // } + + // dinamicFindAfter(method, updatedAt, ...args) { + // const scope = { + // find: (query={}, options) => { + // query._updatedAt = { + // $gt: updatedAt + // }; + + // return this.find(query, options); + // } + // }; + + // scope.model = { + // find: scope.find + // }; + + // return this[method].apply(scope, args); + // } + + // dinamicFindChangesAfter(method, updatedAt, ...args) { + // return { + // update: this.dinamicFindAfter(method, updatedAt, ...args).fetch(), + // remove: this.dinamicTrashFindAfter(method, updatedAt, ...args).fetch() + // }; + // } +} diff --git a/app/models/server/models/_BaseDb.js b/app/models/server/models/_BaseDb.js new file mode 100644 index 0000000000000..cd774981ffb67 --- /dev/null +++ b/app/models/server/models/_BaseDb.js @@ -0,0 +1,305 @@ +import { EventEmitter } from 'events'; + +import { Match } from 'meteor/check'; +import { Mongo, MongoInternals } from 'meteor/mongo'; +import _ from 'underscore'; + +const baseName = 'rocketchat_'; + +const trash = new Mongo.Collection(`${ baseName }_trash`); +try { + trash._ensureIndex({ collection: 1 }); + trash._ensureIndex({ _deletedAt: 1 }, { expireAfterSeconds: 60 * 60 * 24 * 30 }); +} catch (e) { + console.log(e); +} + +const isOplogEnabled = MongoInternals.defaultRemoteCollectionDriver().mongo._oplogHandle && !!MongoInternals.defaultRemoteCollectionDriver().mongo._oplogHandle.onOplogEntry; + +export class BaseDb extends EventEmitter { + constructor(model, baseModel) { + super(); + + if (Match.test(model, String)) { + this.name = model; + this.collectionName = this.baseName + this.name; + this.model = new Mongo.Collection(this.collectionName); + } else { + this.name = model._name; + this.collectionName = this.name; + this.model = model; + } + + this.baseModel = baseModel; + + this.wrapModel(); + + let alreadyListeningToOplog = false; + // When someone start listening for changes we start oplog if available + this.on('newListener', (event/* , listener*/) => { + if (event === 'change' && alreadyListeningToOplog === false) { + alreadyListeningToOplog = true; + if (isOplogEnabled) { + const query = { + collection: this.collectionName, + }; + + MongoInternals.defaultRemoteCollectionDriver().mongo._oplogHandle.onOplogEntry(query, this.processOplogRecord.bind(this)); + // Meteor will handle if we have a value https://github.com/meteor/meteor/blob/5dcd0b2eb9c8bf881ffbee98bc4cb7631772c4da/packages/mongo/oplog_tailing.js#L5 + if (process.env.METEOR_OPLOG_TOO_FAR_BEHIND == null) { + MongoInternals.defaultRemoteCollectionDriver().mongo._oplogHandle._defineTooFarBehind(Number.MAX_SAFE_INTEGER); + } + } + } + }); + + this.tryEnsureIndex({ _updatedAt: 1 }); + } + + get baseName() { + return baseName; + } + + setUpdatedAt(record = {}) { + // TODO: Check if this can be deleted, Rodrigo does not rememebr WHY he added it. So he removed it to fix issue #5541 + // setUpdatedAt(record = {}, checkQuery = false, query) { + // if (checkQuery === true) { + // if (!query || Object.keys(query).length === 0) { + // throw new Meteor.Error('Models._Base: Empty query'); + // } + // } + + if (/(^|,)\$/.test(Object.keys(record).join(','))) { + record.$set = record.$set || {}; + record.$set._updatedAt = new Date(); + } else { + record._updatedAt = new Date(); + } + + return record; + } + + wrapModel() { + this.originals = { + insert: this.model.insert.bind(this.model), + update: this.model.update.bind(this.model), + remove: this.model.remove.bind(this.model), + }; + const self = this; + + this.model.insert = function(...args) { + return self.insert(...args); + }; + + this.model.update = function(...args) { + return self.update(...args); + }; + + this.model.remove = function(...args) { + return self.remove(...args); + }; + } + + _doNotMixInclusionAndExclusionFields(options) { + if (options && options.fields) { + const keys = Object.keys(options.fields); + const removeKeys = keys.filter((key) => options.fields[key] === 0); + if (keys.length > removeKeys.length) { + removeKeys.forEach((key) => delete options.fields[key]); + } + } + } + + find(...args) { + this._doNotMixInclusionAndExclusionFields(args[1]); + return this.model.find(...args); + } + + findOne(...args) { + this._doNotMixInclusionAndExclusionFields(args[1]); + return this.model.findOne(...args); + } + + findOneById(_id, options) { + return this.findOne({ _id }, options); + } + + findOneByIds(ids, options) { + return this.findOne({ _id: { $in: ids } }, options); + } + + updateHasPositionalOperator(update) { + return Object.keys(update).some((key) => key.includes('.$') || (Match.test(update[key], Object) && this.updateHasPositionalOperator(update[key]))); + } + + processOplogRecord(action) { + if (action.op.op === 'i') { + this.emit('change', { + action: 'insert', + clientAction: 'inserted', + id: action.op.o._id, + data: action.op.o, + oplog: true, + }); + return; + } + + if (action.op.op === 'u') { + if (!action.op.o.$set && !action.op.o.$unset) { + this.emit('change', { + action: 'update', + clientAction: 'updated', + id: action.id, + data: action.op.o, + oplog: true, + }); + return; + } + + const diff = {}; + if (action.op.o.$set) { + for (const key in action.op.o.$set) { + if (action.op.o.$set.hasOwnProperty(key)) { + diff[key] = action.op.o.$set[key]; + } + } + } + + if (action.op.o.$unset) { + for (const key in action.op.o.$unset) { + if (action.op.o.$unset.hasOwnProperty(key)) { + diff[key] = undefined; + } + } + } + + this.emit('change', { + action: 'update', + clientAction: 'updated', + id: action.id, + diff, + oplog: true, + }); + return; + } + + if (action.op.op === 'd') { + this.emit('change', { + action: 'remove', + clientAction: 'removed', + id: action.id, + oplog: true, + }); + } + } + + insert(record, ...args) { + this.setUpdatedAt(record); + + const result = this.originals.insert(record, ...args); + + record._id = result; + + return result; + } + + update(query, update, options = {}) { + this.setUpdatedAt(update, true, query); + + return this.originals.update(query, update, options); + } + + upsert(query, update, options = {}) { + options.upsert = true; + options._returnObject = true; + return this.update(query, update, options); + } + + remove(query) { + const records = this.model.find(query).fetch(); + + const ids = []; + for (const record of records) { + ids.push(record._id); + + record._deletedAt = new Date(); + record.__collection__ = this.name; + + trash.upsert({ _id: record._id }, _.omit(record, '_id')); + } + + query = { _id: { $in: ids } }; + + return this.originals.remove(query); + } + + insertOrUpsert(...args) { + if (args[0] && args[0]._id) { + const { _id } = args[0]; + delete args[0]._id; + args.unshift({ + _id, + }); + + this.upsert(...args); + return _id; + } + return this.insert(...args); + } + + allow(...args) { + return this.model.allow(...args); + } + + deny(...args) { + return this.model.deny(...args); + } + + ensureIndex(...args) { + return this.model._ensureIndex(...args); + } + + dropIndex(...args) { + return this.model._dropIndex(...args); + } + + tryEnsureIndex(...args) { + try { + return this.ensureIndex(...args); + } catch (e) { + console.error('Error creating index:', this.name, '->', ...args, e); + } + } + + tryDropIndex(...args) { + try { + return this.dropIndex(...args); + } catch (e) { + console.error('Error dropping index:', this.name, '->', ...args, e); + } + } + + trashFind(query, options) { + query.__collection__ = this.name; + + return trash.find(query, options); + } + + trashFindOneById(_id, options) { + const query = { + _id, + __collection__: this.name, + }; + + return trash.findOne(query, options); + } + + trashFindDeletedAfter(deletedAt, query = {}, options) { + query.__collection__ = this.name; + query._deletedAt = { + $gt: deletedAt, + }; + + return trash.find(query, options); + } +} diff --git a/app/models/server/models/apps-logs-model.js b/app/models/server/models/apps-logs-model.js new file mode 100644 index 0000000000000..a2888e3ba062e --- /dev/null +++ b/app/models/server/models/apps-logs-model.js @@ -0,0 +1,7 @@ +import { Base } from './_Base'; + +export class AppsLogsModel extends Base { + constructor() { + super('apps_logs'); + } +} diff --git a/app/models/server/models/apps-model.js b/app/models/server/models/apps-model.js new file mode 100644 index 0000000000000..086db3ccb7e6c --- /dev/null +++ b/app/models/server/models/apps-model.js @@ -0,0 +1,7 @@ +import { Base } from './_Base'; + +export class AppsModel extends Base { + constructor() { + super('apps'); + } +} diff --git a/app/models/server/models/apps-persistence-model.js b/app/models/server/models/apps-persistence-model.js new file mode 100644 index 0000000000000..bad009b7faeb0 --- /dev/null +++ b/app/models/server/models/apps-persistence-model.js @@ -0,0 +1,7 @@ +import { Base } from './_Base'; + +export class AppsPersistenceModel extends Base { + constructor() { + super('apps_persistence'); + } +} diff --git a/app/notifications/client/index.js b/app/notifications/client/index.js new file mode 100644 index 0000000000000..edafa8c4a7fd8 --- /dev/null +++ b/app/notifications/client/index.js @@ -0,0 +1,5 @@ +import Notifications from './lib/Notifications'; + +export { + Notifications, +}; diff --git a/app/notifications/client/lib/Notifications.js b/app/notifications/client/lib/Notifications.js new file mode 100644 index 0000000000000..feb3bfd468063 --- /dev/null +++ b/app/notifications/client/lib/Notifications.js @@ -0,0 +1,99 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; + +class Notifications { + constructor(...args) { + this.logged = Meteor.userId() !== null; + this.loginCb = []; + Tracker.autorun(() => { + if (Meteor.userId() !== null && this.logged === false) { + this.loginCb.forEach((cb) => cb()); + } + this.logged = Meteor.userId() !== null; + }); + this.debug = false; + this.streamAll = new Meteor.Streamer('notify-all'); + this.streamLogged = new Meteor.Streamer('notify-logged'); + this.streamRoom = new Meteor.Streamer('notify-room'); + this.streamRoomUsers = new Meteor.Streamer('notify-room-users'); + this.streamUser = new Meteor.Streamer('notify-user'); + if (this.debug === true) { + this.onAll(function() { + return console.log('RocketChat.Notifications: onAll', args); + }); + this.onUser(function() { + return console.log('RocketChat.Notifications: onAll', args); + }); + } + } + + onLogin(cb) { + this.loginCb.push(cb); + if (this.logged) { + return cb(); + } + } + + notifyRoom(room, eventName, ...args) { + if (this.debug === true) { + console.log('RocketChat.Notifications: notifyRoom', [room, eventName, ...args]); + } + args.unshift(`${ room }/${ eventName }`); + return this.streamRoom.emit.apply(this.streamRoom, args); + } + + notifyUser(userId, eventName, ...args) { + if (this.debug === true) { + console.log('RocketChat.Notifications: notifyUser', [userId, eventName, ...args]); + } + args.unshift(`${ userId }/${ eventName }`); + return this.streamUser.emit.apply(this.streamUser, args); + } + + notifyUsersOfRoom(room, eventName, ...args) { + if (this.debug === true) { + console.log('RocketChat.Notifications: notifyUsersOfRoom', [room, eventName, ...args]); + } + args.unshift(`${ room }/${ eventName }`); + return this.streamRoomUsers.emit.apply(this.streamRoomUsers, args); + } + + onAll(eventName, callback) { + return this.streamAll.on(eventName, callback); + } + + onLogged(eventName, callback) { + return this.onLogin(() => this.streamLogged.on(eventName, callback)); + } + + onRoom(room, eventName, callback) { + if (this.debug === true) { + this.streamRoom.on(room, function() { + return console.log(`RocketChat.Notifications: onRoom ${ room }`, [room, eventName, callback]); + }); + } + return this.streamRoom.on(`${ room }/${ eventName }`, callback); + } + + onUser(eventName, callback) { + return this.streamUser.on(`${ Meteor.userId() }/${ eventName }`, callback); + } + + unAll(callback) { + return this.streamAll.removeListener('notify', callback); + } + + unLogged(callback) { + return this.streamLogged.removeListener('notify', callback); + } + + unRoom(room, eventName, callback) { + return this.streamRoom.removeListener(`${ room }/${ eventName }`, callback); + } + + unUser(eventName, callback) { + return this.streamUser.removeListener(`${ Meteor.userId() }/${ eventName }`, callback); + } +} + +export default new Notifications(); diff --git a/app/notifications/index.js b/app/notifications/index.js new file mode 100644 index 0000000000000..a67eca871efbb --- /dev/null +++ b/app/notifications/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/notifications/server/index.js b/app/notifications/server/index.js new file mode 100644 index 0000000000000..edafa8c4a7fd8 --- /dev/null +++ b/app/notifications/server/index.js @@ -0,0 +1,5 @@ +import Notifications from './lib/Notifications'; + +export { + Notifications, +}; diff --git a/app/notifications/server/lib/Notifications.js b/app/notifications/server/lib/Notifications.js new file mode 100644 index 0000000000000..1682b1de2e76c --- /dev/null +++ b/app/notifications/server/lib/Notifications.js @@ -0,0 +1,209 @@ +import { Meteor } from 'meteor/meteor'; +import { DDPCommon } from 'meteor/ddp-common'; + +import { WEB_RTC_EVENTS } from '../../../webrtc'; +import { Subscriptions, Rooms } from '../../../models'; +import { settings } from '../../../settings'; + +const changedPayload = function(collection, id, fields) { + return DDPCommon.stringifyDDP({ + msg: 'changed', + collection, + id, + fields, + }); +}; +const send = function(self, msg) { + if (!self.socket) { + return; + } + self.socket.send(msg); +}; +class RoomStreamer extends Meteor.Streamer { + _publish(publication, eventName, options) { + super._publish(publication, eventName, options); + const uid = Meteor.userId(); + if (/rooms-changed/.test(eventName)) { + const roomEvent = (...args) => send(publication._session, changedPayload(this.subscriptionName, 'id', { + eventName: `${ uid }/rooms-changed`, + args, + })); + const rooms = Subscriptions.find({ 'u._id': uid }, { fields: { rid: 1 } }).fetch(); + rooms.forEach(({ rid }) => { + this.on(rid, roomEvent); + }); + + const userEvent = (clientAction, { rid }) => { + switch (clientAction) { + case 'inserted': + rooms.push({ rid }); + this.on(rid, roomEvent); + break; + + case 'removed': + this.removeListener(rid, roomEvent); + break; + } + }; + this.on(uid, userEvent); + + publication.onStop(() => { + this.removeListener(uid, userEvent); + rooms.forEach(({ rid }) => this.removeListener(rid, roomEvent)); + }); + } + } +} + +class Notifications { + constructor() { + const self = this; + this.debug = false; + this.notifyUser = this.notifyUser.bind(this); + this.streamAll = new Meteor.Streamer('notify-all'); + this.streamLogged = new Meteor.Streamer('notify-logged'); + this.streamRoom = new Meteor.Streamer('notify-room'); + this.streamRoomUsers = new Meteor.Streamer('notify-room-users'); + this.streamUser = new RoomStreamer('notify-user'); + this.streamAll.allowWrite('none'); + this.streamLogged.allowWrite('none'); + this.streamRoom.allowWrite('none'); + this.streamRoomUsers.allowWrite(function(eventName, ...args) { + const [roomId, e] = eventName.split('/'); + // const user = Meteor.users.findOne(this.userId, { + // fields: { + // username: 1 + // } + // }); + if (Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId) != null) { + const subscriptions = Subscriptions.findByRoomIdAndNotUserId(roomId, this.userId).fetch(); + subscriptions.forEach((subscription) => self.notifyUser(subscription.u._id, e, ...args)); + } + return false; + }); + this.streamUser.allowWrite('logged'); + this.streamAll.allowRead('all'); + this.streamLogged.allowRead('logged'); + this.streamRoom.allowRead(function(eventName, extraData) { + const [roomId] = eventName.split('/'); + const room = Rooms.findOneById(roomId); + if (!room) { + console.warn(`Invalid streamRoom eventName: "${ eventName }"`); + return false; + } + if (room.t === 'l' && extraData && extraData.token && room.v.token === extraData.token) { + return true; + } + if (this.userId == null) { + return false; + } + const subscription = Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId, { fields: { _id: 1 } }); + return subscription != null; + }); + this.streamRoomUsers.allowRead('none'); + this.streamUser.allowRead(function(eventName) { + const [userId] = eventName.split('/'); + return (this.userId != null) && this.userId === userId; + }); + } + + notifyAll(eventName, ...args) { + if (this.debug === true) { + console.log('notifyAll', [eventName, ...args]); + } + args.unshift(eventName); + return this.streamAll.emit.apply(this.streamAll, args); + } + + notifyLogged(eventName, ...args) { + if (this.debug === true) { + console.log('notifyLogged', [eventName, ...args]); + } + args.unshift(eventName); + return this.streamLogged.emit.apply(this.streamLogged, args); + } + + notifyRoom(room, eventName, ...args) { + if (this.debug === true) { + console.log('notifyRoom', [room, eventName, ...args]); + } + args.unshift(`${ room }/${ eventName }`); + return this.streamRoom.emit.apply(this.streamRoom, args); + } + + notifyUser(userId, eventName, ...args) { + if (this.debug === true) { + console.log('notifyUser', [userId, eventName, ...args]); + } + args.unshift(`${ userId }/${ eventName }`); + return this.streamUser.emit.apply(this.streamUser, args); + } + + notifyAllInThisInstance(eventName, ...args) { + if (this.debug === true) { + console.log('notifyAll', [eventName, ...args]); + } + args.unshift(eventName); + return this.streamAll.emitWithoutBroadcast.apply(this.streamAll, args); + } + + notifyLoggedInThisInstance(eventName, ...args) { + if (this.debug === true) { + console.log('notifyLogged', [eventName, ...args]); + } + args.unshift(eventName); + return this.streamLogged.emitWithoutBroadcast.apply(this.streamLogged, args); + } + + notifyRoomInThisInstance(room, eventName, ...args) { + if (this.debug === true) { + console.log('notifyRoomAndBroadcast', [room, eventName, ...args]); + } + args.unshift(`${ room }/${ eventName }`); + return this.streamRoom.emitWithoutBroadcast.apply(this.streamRoom, args); + } + + notifyUserInThisInstance(userId, eventName, ...args) { + if (this.debug === true) { + console.log('notifyUserAndBroadcast', [userId, eventName, ...args]); + } + args.unshift(`${ userId }/${ eventName }`); + return this.streamUser.emitWithoutBroadcast.apply(this.streamUser, args); + } +} + +const notifications = new Notifications(); + +notifications.streamRoom.allowWrite(function(eventName, username, typing, extraData) { + const [roomId, e] = eventName.split('/'); + + if (isNaN(e) ? e === WEB_RTC_EVENTS.WEB_RTC : parseFloat(e) === WEB_RTC_EVENTS.WEB_RTC) { + return true; + } + + if (e === 'typing') { + const key = settings.get('UI_Use_Real_Name') ? 'name' : 'username'; + // typing from livechat widget + if (extraData && extraData.token) { + const room = Rooms.findOneById(roomId); + if (room && room.t === 'l' && room.v.token === extraData.token) { + return true; + } + } + + const user = Meteor.users.findOne(this.userId, { + fields: { + [key]: 1, + }, + }); + + if (!user) { + return false; + } + + return user[key] === username; + } + return false; +}); + +export default notifications; diff --git a/packages/rocketchat-nrr/README.md b/app/nrr/README.md similarity index 100% rename from packages/rocketchat-nrr/README.md rename to app/nrr/README.md diff --git a/app/nrr/client/index.js b/app/nrr/client/index.js new file mode 100644 index 0000000000000..613d0630705e4 --- /dev/null +++ b/app/nrr/client/index.js @@ -0,0 +1 @@ +import './nrr'; diff --git a/packages/rocketchat-nrr/nrr.js b/app/nrr/client/nrr.js similarity index 79% rename from packages/rocketchat-nrr/nrr.js rename to app/nrr/client/nrr.js index 8376a64258697..80feb407d92bf 100644 --- a/packages/rocketchat-nrr/nrr.js +++ b/app/nrr/client/nrr.js @@ -7,27 +7,23 @@ import { HTML } from 'meteor/htmljs'; import { Spacebars } from 'meteor/spacebars'; import { Tracker } from 'meteor/tracker'; -Blaze.toHTMLWithDataNonReactive = function(content, data) { - const makeCursorReactive = function(obj) { - if (obj instanceof Meteor.Collection.Cursor) { - return obj._depend({ - added: true, - removed: true, - changed: true, - }); - } - }; +const makeCursorReactive = function(obj) { + if (obj instanceof Meteor.Collection.Cursor) { + return obj._depend({ + added: true, + removed: true, + changed: true, + }); + } +}; +Blaze.toHTMLWithDataNonReactive = function(content, data) { makeCursorReactive(data); if (data instanceof Spacebars.kw && Object.keys(data.hash).length > 0) { - Object.keys(data.hash).forEach((key) => { - makeCursorReactive(data.hash[key]); - }); - - data = data.hash; + Object.entries(data.hash).forEach(([, value]) => makeCursorReactive(value)); + return Tracker.nonreactive(() => Blaze.toHTMLWithData(content, data.hash)); } - return Tracker.nonreactive(() => Blaze.toHTMLWithData(content, data)); }; diff --git a/app/nrr/index.js b/app/nrr/index.js new file mode 100644 index 0000000000000..40a7340d38877 --- /dev/null +++ b/app/nrr/index.js @@ -0,0 +1 @@ +export * from './client/index'; diff --git a/packages/rocketchat-oauth2-server-config/.gitignore b/app/oauth2-server-config/.gitignore similarity index 100% rename from packages/rocketchat-oauth2-server-config/.gitignore rename to app/oauth2-server-config/.gitignore diff --git a/packages/rocketchat-oauth2-server-config/client/admin/collection.js b/app/oauth2-server-config/client/admin/collection.js similarity index 100% rename from packages/rocketchat-oauth2-server-config/client/admin/collection.js rename to app/oauth2-server-config/client/admin/collection.js diff --git a/app/oauth2-server-config/client/admin/route.js b/app/oauth2-server-config/client/admin/route.js new file mode 100644 index 0000000000000..df568f2bca603 --- /dev/null +++ b/app/oauth2-server-config/client/admin/route.js @@ -0,0 +1,26 @@ +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { BlazeLayout } from 'meteor/kadira:blaze-layout'; + +import { t } from '../../../utils'; + +FlowRouter.route('/admin/oauth-apps', { + name: 'admin-oauth-apps', + action() { + return BlazeLayout.render('main', { + center: 'oauthApps', + pageTitle: t('OAuth_Applications'), + }); + }, +}); + +FlowRouter.route('/admin/oauth-app/:id?', { + name: 'admin-oauth-app', + action(params) { + return BlazeLayout.render('main', { + center: 'pageSettingsContainer', + pageTitle: t('OAuth_Application'), + pageTemplate: 'oauthApp', + params, + }); + }, +}); diff --git a/app/oauth2-server-config/client/admin/startup.js b/app/oauth2-server-config/client/admin/startup.js new file mode 100644 index 0000000000000..cf3e4fb086634 --- /dev/null +++ b/app/oauth2-server-config/client/admin/startup.js @@ -0,0 +1,11 @@ +import { AdminBox } from '../../../ui-utils'; +import { hasAllPermission } from '../../../authorization'; + +AdminBox.addOption({ + href: 'admin-oauth-apps', + i18nLabel: 'OAuth Apps', + icon: 'discover', + permissionGranted() { + return hasAllPermission('manage-oauth-apps'); + }, +}); diff --git a/packages/rocketchat-oauth2-server-config/client/admin/views/oauthApp.html b/app/oauth2-server-config/client/admin/views/oauthApp.html similarity index 100% rename from packages/rocketchat-oauth2-server-config/client/admin/views/oauthApp.html rename to app/oauth2-server-config/client/admin/views/oauthApp.html diff --git a/packages/rocketchat-oauth2-server-config/client/admin/views/oauthApp.js b/app/oauth2-server-config/client/admin/views/oauthApp.js similarity index 87% rename from packages/rocketchat-oauth2-server-config/client/admin/views/oauthApp.js rename to app/oauth2-server-config/client/admin/views/oauthApp.js index ea3932455313b..1ef9f76d3287a 100644 --- a/packages/rocketchat-oauth2-server-config/client/admin/views/oauthApp.js +++ b/app/oauth2-server-config/client/admin/views/oauthApp.js @@ -3,11 +3,14 @@ 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 { RocketChat, handleError } from 'meteor/rocketchat:lib'; -import { t, modal } from 'meteor/rocketchat:ui'; -import { ChatOAuthApps } from '../collection'; +import { Tracker } from 'meteor/tracker'; import toastr from 'toastr'; +import { hasAllPermission } from '../../../../authorization'; +import { modal, SideNav } from '../../../../ui-utils/client'; +import { t, handleError } from '../../../../utils'; +import { ChatOAuthApps } from '../collection'; + Template.oauthApp.onCreated(function() { this.subscribe('oauthApps'); this.record = new ReactiveVar({ @@ -17,7 +20,7 @@ Template.oauthApp.onCreated(function() { Template.oauthApp.helpers({ hasPermission() { - return RocketChat.authz.hasAllPermission('manage-oauth-apps'); + return hasAllPermission('manage-oauth-apps'); }, data() { const instance = Template.instance(); @@ -99,3 +102,10 @@ Template.oauthApp.events({ }); }, }); + +Template.oauthApp.onRendered(() => { + Tracker.afterFlush(() => { + SideNav.setFlex('adminFlex'); + SideNav.openFlex(); + }); +}); diff --git a/app/oauth2-server-config/client/admin/views/oauthApps.html b/app/oauth2-server-config/client/admin/views/oauthApps.html new file mode 100644 index 0000000000000..99a142127802f --- /dev/null +++ b/app/oauth2-server-config/client/admin/views/oauthApps.html @@ -0,0 +1,38 @@ + diff --git a/app/oauth2-server-config/client/admin/views/oauthApps.js b/app/oauth2-server-config/client/admin/views/oauthApps.js new file mode 100644 index 0000000000000..75ac3a749b308 --- /dev/null +++ b/app/oauth2-server-config/client/admin/views/oauthApps.js @@ -0,0 +1,30 @@ +import { Template } from 'meteor/templating'; +import { Tracker } from 'meteor/tracker'; +import moment from 'moment'; + +import { hasAllPermission } from '../../../../authorization'; +import { ChatOAuthApps } from '../collection'; +import { SideNav } from '../../../../ui-utils/client'; + +Template.oauthApps.onCreated(function() { + this.subscribe('oauthApps'); +}); + +Template.oauthApps.helpers({ + hasPermission() { + return hasAllPermission('manage-oauth-apps'); + }, + applications() { + return ChatOAuthApps.find(); + }, + dateFormated(date) { + return moment(date).format('L LT'); + }, +}); + +Template.oauthApps.onRendered(() => { + Tracker.afterFlush(() => { + SideNav.setFlex('adminFlex'); + SideNav.openFlex(); + }); +}); diff --git a/app/oauth2-server-config/client/index.js b/app/oauth2-server-config/client/index.js new file mode 100644 index 0000000000000..27d8d4052ce88 --- /dev/null +++ b/app/oauth2-server-config/client/index.js @@ -0,0 +1,8 @@ +import './oauth/oauth2-client.html'; +import './oauth/oauth2-client'; +import './admin/startup'; +import './admin/route'; +import './admin/views/oauthApp.html'; +import './admin/views/oauthApp'; +import './admin/views/oauthApps.html'; +import './admin/views/oauthApps'; diff --git a/packages/rocketchat-oauth2-server-config/client/oauth/oauth2-client.html b/app/oauth2-server-config/client/oauth/oauth2-client.html similarity index 100% rename from packages/rocketchat-oauth2-server-config/client/oauth/oauth2-client.html rename to app/oauth2-server-config/client/oauth/oauth2-client.html diff --git a/packages/rocketchat-oauth2-server-config/client/oauth/oauth2-client.js b/app/oauth2-server-config/client/oauth/oauth2-client.js similarity index 89% rename from packages/rocketchat-oauth2-server-config/client/oauth/oauth2-client.js rename to app/oauth2-server-config/client/oauth/oauth2-client.js index e6b02c2adffb4..c763233e57e76 100644 --- a/packages/rocketchat-oauth2-server-config/client/oauth/oauth2-client.js +++ b/app/oauth2-server-config/client/oauth/oauth2-client.js @@ -1,7 +1,9 @@ import { Meteor } from 'meteor/meteor'; -import { FlowRouter } from 'meteor/kadira:flow-router' ; +import { FlowRouter } from 'meteor/kadira:flow-router'; import { BlazeLayout } from 'meteor/kadira:blaze-layout'; import { Template } from 'meteor/templating'; +import { Accounts } from 'meteor/accounts-base'; + import { ChatOAuthApps } from '../admin/collection'; FlowRouter.route('/oauth/authorize', { @@ -34,7 +36,7 @@ Template.authorize.onCreated(function() { Template.authorize.helpers({ getToken() { - return localStorage.getItem('Meteor.loginToken'); + return localStorage.getItem(Accounts.LOGIN_TOKEN_KEY); }, getClient() { return ChatOAuthApps.findOne(); diff --git a/packages/rocketchat-oauth2-server-config/client/oauth/stylesheets/oauth2.css b/app/oauth2-server-config/client/oauth/stylesheets/oauth2.css similarity index 100% rename from packages/rocketchat-oauth2-server-config/client/oauth/stylesheets/oauth2.css rename to app/oauth2-server-config/client/oauth/stylesheets/oauth2.css diff --git a/app/oauth2-server-config/server/admin/methods/addOAuthApp.js b/app/oauth2-server-config/server/admin/methods/addOAuthApp.js new file mode 100644 index 0000000000000..a5ef900f34bd9 --- /dev/null +++ b/app/oauth2-server-config/server/admin/methods/addOAuthApp.js @@ -0,0 +1,29 @@ +import { Meteor } from 'meteor/meteor'; +import { Random } from 'meteor/random'; +import _ from 'underscore'; + +import { hasPermission } from '../../../../authorization'; +import { Users, OAuthApps } from '../../../../models'; + +Meteor.methods({ + addOAuthApp(application) { + if (!hasPermission(this.userId, 'manage-oauth-apps')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'addOAuthApp' }); + } + if (!_.isString(application.name) || application.name.trim() === '') { + throw new Meteor.Error('error-invalid-name', 'Invalid name', { method: 'addOAuthApp' }); + } + if (!_.isString(application.redirectUri) || application.redirectUri.trim() === '') { + throw new Meteor.Error('error-invalid-redirectUri', 'Invalid redirectUri', { method: 'addOAuthApp' }); + } + if (!_.isBoolean(application.active)) { + throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', { method: 'addOAuthApp' }); + } + application.clientId = Random.id(); + application.clientSecret = Random.secret(); + application._createdAt = new Date(); + application._createdBy = Users.findOne(this.userId, { fields: { username: 1 } }); + application._id = OAuthApps.insert(application); + return application; + }, +}); diff --git a/app/oauth2-server-config/server/admin/methods/deleteOAuthApp.js b/app/oauth2-server-config/server/admin/methods/deleteOAuthApp.js new file mode 100644 index 0000000000000..6c0b1e665de64 --- /dev/null +++ b/app/oauth2-server-config/server/admin/methods/deleteOAuthApp.js @@ -0,0 +1,18 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../../authorization'; +import { OAuthApps } from '../../../../models'; + +Meteor.methods({ + deleteOAuthApp(applicationId) { + if (!hasPermission(this.userId, 'manage-oauth-apps')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'deleteOAuthApp' }); + } + const application = OAuthApps.findOne(applicationId); + if (application == null) { + throw new Meteor.Error('error-application-not-found', 'Application not found', { method: 'deleteOAuthApp' }); + } + OAuthApps.remove({ _id: applicationId }); + return true; + }, +}); diff --git a/app/oauth2-server-config/server/admin/methods/updateOAuthApp.js b/app/oauth2-server-config/server/admin/methods/updateOAuthApp.js new file mode 100644 index 0000000000000..6043a34a23f0a --- /dev/null +++ b/app/oauth2-server-config/server/admin/methods/updateOAuthApp.js @@ -0,0 +1,40 @@ +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { hasPermission } from '../../../../authorization'; +import { OAuthApps, Users } from '../../../../models'; + +Meteor.methods({ + updateOAuthApp(applicationId, application) { + if (!hasPermission(this.userId, 'manage-oauth-apps')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'updateOAuthApp' }); + } + if (!_.isString(application.name) || application.name.trim() === '') { + throw new Meteor.Error('error-invalid-name', 'Invalid name', { method: 'updateOAuthApp' }); + } + if (!_.isString(application.redirectUri) || application.redirectUri.trim() === '') { + throw new Meteor.Error('error-invalid-redirectUri', 'Invalid redirectUri', { method: 'updateOAuthApp' }); + } + if (!_.isBoolean(application.active)) { + throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', { method: 'updateOAuthApp' }); + } + const currentApplication = OAuthApps.findOne(applicationId); + if (currentApplication == null) { + throw new Meteor.Error('error-application-not-found', 'Application not found', { method: 'updateOAuthApp' }); + } + OAuthApps.update(applicationId, { + $set: { + name: application.name, + active: application.active, + redirectUri: application.redirectUri, + _updatedAt: new Date(), + _updatedBy: Users.findOne(this.userId, { + fields: { + username: 1, + }, + }), + }, + }); + return OAuthApps.findOne(applicationId); + }, +}); diff --git a/app/oauth2-server-config/server/admin/publications/oauthApps.js b/app/oauth2-server-config/server/admin/publications/oauthApps.js new file mode 100644 index 0000000000000..e6f105c7e9ebd --- /dev/null +++ b/app/oauth2-server-config/server/admin/publications/oauthApps.js @@ -0,0 +1,14 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../../authorization'; +import { OAuthApps } from '../../../../models'; + +Meteor.publish('oauthApps', function() { + if (!this.userId) { + return this.ready(); + } + if (!hasPermission(this.userId, 'manage-oauth-apps')) { + this.error(Meteor.Error('error-not-allowed', 'Not allowed', { publish: 'oauthApps' })); + } + return OAuthApps.find(); +}); diff --git a/app/oauth2-server-config/server/index.js b/app/oauth2-server-config/server/index.js new file mode 100644 index 0000000000000..4fe04df84ed2b --- /dev/null +++ b/app/oauth2-server-config/server/index.js @@ -0,0 +1,6 @@ +import './oauth/oauth2-server'; +import './oauth/default-services'; +import './admin/publications/oauthApps'; +import './admin/methods/addOAuthApp'; +import './admin/methods/updateOAuthApp'; +import './admin/methods/deleteOAuthApp'; diff --git a/app/oauth2-server-config/server/oauth/default-services.js b/app/oauth2-server-config/server/oauth/default-services.js new file mode 100644 index 0000000000000..d39489c9ec850 --- /dev/null +++ b/app/oauth2-server-config/server/oauth/default-services.js @@ -0,0 +1,17 @@ +import { OAuthApps } from '../../../models'; + +if (!OAuthApps.findOne('zapier')) { + OAuthApps.insert({ + _id: 'zapier', + name: 'Zapier', + active: true, + clientId: 'zapier', + clientSecret: 'RTK6TlndaCIolhQhZ7_KHIGOKj41RnlaOq_o-7JKwLr', + redirectUri: 'https://zapier.com/dashboard/auth/oauth/return/RocketChatDevAPI/', + _createdAt: new Date(), + _createdBy: { + _id: 'system', + username: 'system', + }, + }); +} diff --git a/packages/rocketchat-oauth2-server-config/server/oauth/oauth2-server.js b/app/oauth2-server-config/server/oauth/oauth2-server.js similarity index 85% rename from packages/rocketchat-oauth2-server-config/server/oauth/oauth2-server.js rename to app/oauth2-server-config/server/oauth/oauth2-server.js index ec1da72899c65..7f0480c1c8356 100644 --- a/packages/rocketchat-oauth2-server-config/server/oauth/oauth2-server.js +++ b/app/oauth2-server-config/server/oauth/oauth2-server.js @@ -1,16 +1,21 @@ import { Meteor } from 'meteor/meteor'; import { WebApp } from 'meteor/webapp'; -import { RocketChat } from 'meteor/rocketchat:lib'; import { OAuth2Server } from 'meteor/rocketchat:oauth2-server'; +import { OAuthApps, Users } from '../../../models'; +import { API } from '../../../api'; + const oauth2server = new OAuth2Server({ accessTokensCollectionName: 'rocketchat_oauth_access_tokens', refreshTokensCollectionName: 'rocketchat_oauth_refresh_tokens', authCodesCollectionName: 'rocketchat_oauth_auth_codes', - clientsCollection: RocketChat.models.OAuthApps.model, + clientsCollection: OAuthApps.model, debug: true, }); +oauth2server.app.disable('x-powered-by'); +oauth2server.routes.disable('x-powered-by'); + WebApp.connectHandlers.use(oauth2server.app); oauth2server.routes.get('/oauth/userinfo', function(req, res) { @@ -24,7 +29,7 @@ oauth2server.routes.get('/oauth/userinfo', function(req, res) { if (token == null) { return res.sendStatus(401).send('Invalid Token'); } - const user = RocketChat.models.Users.findOneById(token.userId); + const user = Users.findOneById(token.userId); if (user == null) { return res.sendStatus(401).send('Invalid Token'); } @@ -45,7 +50,7 @@ Meteor.publish('oauthClient', function(clientId) { if (!this.userId) { return this.ready(); } - return RocketChat.models.OAuthApps.find({ + return OAuthApps.find({ clientId, active: true, }, { @@ -55,7 +60,7 @@ Meteor.publish('oauthClient', function(clientId) { }); }); -RocketChat.API.v1.addAuthMethod(function() { +API.v1.addAuthMethod(function() { let headerToken = this.request.headers.authorization; const getToken = this.request.query.access_token; if (headerToken != null) { @@ -78,7 +83,7 @@ RocketChat.API.v1.addAuthMethod(function() { if ((accessToken.expires != null) && accessToken.expires !== 0 && accessToken.expires < new Date()) { return; } - const user = RocketChat.models.Users.findOne(accessToken.userId); + const user = Users.findOne(accessToken.userId); if (user == null) { return; } diff --git a/packages/rocketchat-oembed/client/baseWidget.html b/app/oembed/client/baseWidget.html similarity index 100% rename from packages/rocketchat-oembed/client/baseWidget.html rename to app/oembed/client/baseWidget.html diff --git a/packages/rocketchat-oembed/client/baseWidget.js b/app/oembed/client/baseWidget.js similarity index 88% rename from packages/rocketchat-oembed/client/baseWidget.js rename to app/oembed/client/baseWidget.js index 66263dcf59925..004169306bd60 100644 --- a/packages/rocketchat-oembed/client/baseWidget.js +++ b/app/oembed/client/baseWidget.js @@ -22,9 +22,6 @@ Template.oembedBaseWidget.helpers({ if (this.meta && this.meta.oembedHtml) { return 'oembedFrameWidget'; } - if (this.meta && this.meta.sandstorm && this.meta.sandstorm.grain) { - return 'oembedSandstormGrain'; - } return 'oembedUrlWidget'; }, }); diff --git a/app/oembed/client/index.js b/app/oembed/client/index.js new file mode 100644 index 0000000000000..c5b326243e890 --- /dev/null +++ b/app/oembed/client/index.js @@ -0,0 +1,14 @@ +import './baseWidget.html'; +import './baseWidget'; +import './oembedImageWidget.html'; +import './oembedImageWidget'; +import './oembedAudioWidget.html'; +import './oembedAudioWidget'; +import './oembedVideoWidget.html'; +import './oembedVideoWidget'; +import './oembedYoutubeWidget.html'; +import './oembedYoutubeWidget'; +import './oembedUrlWidget.html'; +import './oembedUrlWidget'; +import './oembedFrameWidget.html'; +import './oembedFrameWidget'; diff --git a/packages/rocketchat-oembed/client/oembedAudioWidget.html b/app/oembed/client/oembedAudioWidget.html similarity index 88% rename from packages/rocketchat-oembed/client/oembedAudioWidget.html rename to app/oembed/client/oembedAudioWidget.html index 9d4117485189e..62c1924b4491b 100644 --- a/packages/rocketchat-oembed/client/oembedAudioWidget.html +++ b/app/oembed/client/oembedAudioWidget.html @@ -5,7 +5,7 @@ {{else}} -
+