Просмотр исходного кода

Merge branch 'feat/77515-display-search-result-with-snippet' into feat/77515-77833-arrange-component-temporaly

* feat/77515-display-search-result-with-snippet: (47 commits)
  revert
  wip show snippet
  wip fix presentation of snippets
  send search result
  make the first SlackAppIntegration primary
  replace add-app-to-this-channel image
  fix: New user is created on SAML login even if attribute-based login control failure (#4422)
  fix: Redirected to apiv3 endpoint when guest mode is enabled (#4443)
  imprv: Show modal when enabling Textlint (#4373)
  imprv: Slackbot reaction to user (#4442)
  update dependabot setting
  fix(slackbot): Stop auto-join to channels with middlewarer (#4424)
  Bump version
  Release v4.4.7
  imprv: Slackbot search (#4420)
  update release-slackbot-proxy.yml
  Bump version
  bump version for slackbot-proxy
  add npm script for bump-versions
  fix(slackbot): Sync permission when data stored is not enough (#4417)
  ...

# Conflicts:
#	packages/app/src/components/SearchPage/SearchResult.jsx
Mao 4 лет назад
Родитель
Сommit
05bb0a3555
100 измененных файлов с 1401 добавлено и 824 удалено
  1. 9 9
      .devcontainer/Dockerfile
  2. 1 0
      .gitattributes
  3. 6 0
      .github/dependabot.yml
  4. 2 5
      .github/workflows/ci-slackbot-proxy.yml
  5. 8 0
      .github/workflows/ci.yml
  6. 2 0
      .github/workflows/release-rc.yml
  7. 58 0
      .github/workflows/release-slackbot-proxy.yml
  8. 6 2
      .github/workflows/release.yml
  9. 38 1
      CHANGELOG.md
  10. 11 0
      THIRD-PARTY-NOTICES.md
  11. 7 1
      bin/github-actions/bump-versions/flow/bump-versions.js
  12. 5 1
      bin/github-actions/bump-versions/step/printHelp.js
  13. 1 1
      lerna.json
  14. 9 6
      package.json
  15. 2 0
      packages/app/.env.development
  16. 1 0
      packages/app/.env.production
  17. 2 1
      packages/app/.eslintrc.js
  18. 1 1
      packages/app/.gitignore
  19. 1 12
      packages/app/bin/cdn/cdn-resources-downloader.ts
  20. 0 1
      packages/app/config/cdn.js
  21. 2 2
      packages/app/config/webpack.prod.js
  22. 6 11
      packages/app/docker/Dockerfile
  23. 6 0
      packages/app/docker/Dockerfile.dockerignore
  24. 2 2
      packages/app/docker/README.md
  25. 1 1
      packages/app/docker/docker-entrypoint.sh
  26. 6 0
      packages/app/docker/nocdn/.env.production.local
  27. 0 5
      packages/app/docker/nocdn/env.prod.js
  28. 21 5
      packages/app/jest.config.js
  29. 8 5
      packages/app/migrate-mongo-config.js
  30. 16 13
      packages/app/package.json
  31. 3 0
      packages/app/public/static/dict/base.dat.gz
  32. 3 0
      packages/app/public/static/dict/cc.dat.gz
  33. 3 0
      packages/app/public/static/dict/check.dat.gz
  34. 3 0
      packages/app/public/static/dict/tid.dat.gz
  35. 3 0
      packages/app/public/static/dict/tid_map.dat.gz
  36. 3 0
      packages/app/public/static/dict/tid_pos.dat.gz
  37. 3 0
      packages/app/public/static/dict/unk.dat.gz
  38. 3 0
      packages/app/public/static/dict/unk_char.dat.gz
  39. 3 0
      packages/app/public/static/dict/unk_compat.dat.gz
  40. 3 0
      packages/app/public/static/dict/unk_invoke.dat.gz
  41. 3 0
      packages/app/public/static/dict/unk_map.dat.gz
  42. 3 0
      packages/app/public/static/dict/unk_pos.dat.gz
  43. 0 86
      packages/app/resource/cdn-manifests.js
  44. 5 0
      packages/app/resource/locales/en_US/translation.json
  45. 5 0
      packages/app/resource/locales/ja_JP/translation.json
  46. 5 0
      packages/app/resource/locales/zh_CN/translation.json
  47. 9 2
      packages/app/src/client/services/EditorContainer.js
  48. 1 1
      packages/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx
  49. 4 4
      packages/app/src/components/Me/EditorSettings.tsx
  50. 2 5
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  51. 75 0
      packages/app/src/components/PageEditor/DownloadDictModal.tsx
  52. 41 8
      packages/app/src/components/PageEditor/OptionsSelector.jsx
  53. 354 0
      packages/app/src/components/SearchPage/SearchResult.jsx
  54. 1 1
      packages/app/src/components/Sidebar/RecentChanges.jsx
  55. 2 3
      packages/app/src/migrations/20180926134048-make-email-unique.js
  56. 3 3
      packages/app/src/migrations/20180927102719-init-serverurl.js
  57. 3 4
      packages/app/src/migrations/20181019114028-abolish-page-group-relation.js
  58. 2 2
      packages/app/src/migrations/20190618055300-abolish-crowi-classic-auth.js
  59. 3 4
      packages/app/src/migrations/20190618104011-add-config-app-installed.js
  60. 2 2
      packages/app/src/migrations/20190619055421-adjust-page-grant.js
  61. 2 2
      packages/app/src/migrations/20190624110950-fill-last-update-user.js
  62. 2 2
      packages/app/src/migrations/20190629193445-make-root-page-public.js
  63. 2 2
      packages/app/src/migrations/20191102223900-drop-configs-indices.js
  64. 2 2
      packages/app/src/migrations/20191102223901-drop-pages-indices.js
  65. 3 3
      packages/app/src/migrations/20191126173016-adjust-pages-path.js
  66. 2 2
      packages/app/src/migrations/20191127023815-drop-wrong-index-of-page-tag-relation.js
  67. 2 3
      packages/app/src/migrations/20200402160380-remove-deleteduser-from-relationgroup.js
  68. 2 2
      packages/app/src/migrations/20200420160390-remove-crowi-layout.js
  69. 3 3
      packages/app/src/migrations/20200512005851-remove-behavior-type.js
  70. 2 2
      packages/app/src/migrations/20200514001356-update-theme-color-for-dark.js
  71. 2 3
      packages/app/src/migrations/20200620203632-normalize-locale-id.js
  72. 3 3
      packages/app/src/migrations/20200827045151-remove-layout-setting.js
  73. 3 3
      packages/app/src/migrations/20200828024025-copy-aws-setting.js
  74. 3 3
      packages/app/src/migrations/20200901034313-update-mail-transmission.js
  75. 2 2
      packages/app/src/migrations/20200901034314-update-mail-transmission-fix.js
  76. 3 3
      packages/app/src/migrations/20200903080025-remove-timeline-type.js.js
  77. 3 3
      packages/app/src/migrations/20200915035234-rename-s3-config.js
  78. 2 3
      packages/app/src/migrations/20210420160380-convert-double-to-date.js
  79. 35 44
      packages/app/src/migrations/20210830074539-update-configs-for-slackbot.js
  80. 3 4
      packages/app/src/migrations/20210906194521-slack-app-integration-set-default-value.js
  81. 57 43
      packages/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js
  82. 1 1
      packages/app/src/server/console.js
  83. 28 5
      packages/app/src/server/crowi/index.js
  84. 1 1
      packages/app/src/server/models/config.ts
  85. 1 1
      packages/app/src/server/models/editor-settings.ts
  86. 1 1
      packages/app/src/server/models/password-reset-order.ts
  87. 1 1
      packages/app/src/server/models/update-post.ts
  88. 37 0
      packages/app/src/server/models/vo/slack-command-handler-error.ts
  89. 0 22
      packages/app/src/server/models/vo/slackbot-error.js
  90. 5 3
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  91. 101 59
      packages/app/src/server/routes/apiv3/slack-integration.js
  92. 5 5
      packages/app/src/server/routes/login-passport.js
  93. 2 1
      packages/app/src/server/routes/search.js
  94. 1 1
      packages/app/src/server/service/search-delegator/elasticsearch.js
  95. 19 29
      packages/app/src/server/service/slack-command-handler/create-page-service.js
  96. 27 9
      packages/app/src/server/service/slack-command-handler/create.js
  97. 69 0
      packages/app/src/server/service/slack-command-handler/error-handler.ts
  98. 3 5
      packages/app/src/server/service/slack-command-handler/help.js
  99. 0 66
      packages/app/src/server/service/slack-command-handler/respond-if-slackbot-error.js
  100. 170 267
      packages/app/src/server/service/slack-command-handler/search.js

+ 9 - 9
.devcontainer/Dockerfile

@@ -29,15 +29,15 @@ RUN chown -R $USER_UID:$USER_GID /home/$USERNAME /workspace;
 # * any needed dependencies after executing "apt-get update". *
 # * See https://docs.docker.com/engine/reference/builder/#run *
 # *************************************************************
-# ENV DEBIAN_FRONTEND=noninteractive
-# RUN apt-get update \
-#    && apt-get -y install --no-install-recommends <your-package-list-here> \
-#    #
-#    # Clean up
-#    && apt-get autoremove -y \
-#    && apt-get clean -y \
-#    && rm -rf /var/lib/apt/lists/*
-# ENV DEBIAN_FRONTEND=dialog
+ENV DEBIAN_FRONTEND=noninteractive
+RUN apt-get update \
+   && apt-get -y install --no-install-recommends git-lfs \
+
+   # Clean up
+   && apt-get autoremove -y \
+   && apt-get clean -y \
+   && rm -rf /var/lib/apt/lists/*
+ENV DEBIAN_FRONTEND=dialog
 
 # Uncomment to default to non-root user
 # USER $USER_UID

+ 1 - 0
.gitattributes

@@ -0,0 +1 @@
+*.gz filter=lfs diff=lfs merge=lfs -text

+ 6 - 0
.github/dependabot.yml

@@ -15,3 +15,9 @@ updates:
     commit-message:
       prefix: ci
       include: scope
+    ignore:
+      - dependency-name: escape-string-regexp
+      - dependency-name: string-width
+      - dependency-name: "@handsontable/react"
+      - dependency-name: handsontable
+

+ 2 - 5
.github/workflows/ci-slackbot-proxy.yml

@@ -94,6 +94,7 @@ jobs:
         cp config/ci/.env.local.for-ci .env.development.local
         yarn dev:ci
       env:
+        SERVER_URI: http://localhost:8080
         TYPEORM_CONNECTION: mysql
         TYPEORM_HOST: localhost
         TYPEORM_PORT: ${{ job.services.mysql.ports[3306] }}
@@ -167,17 +168,13 @@ jobs:
         cp config/ci/.env.local.for-ci .env.production.local
         yarn start:prod:ci
       env:
+        SERVER_URI: http://localhost:8080
         TYPEORM_CONNECTION: mysql
         TYPEORM_HOST: localhost
         TYPEORM_PORT: ${{ job.services.mysql.ports[3306] }}
         TYPEORM_DATABASE: growi-slackbot-proxy
         TYPEORM_USERNAME: root
         TYPEORM_PASSWORD:
-    - name: Upload report as artifact
-      uses: actions/upload-artifact@v2
-      with:
-        name: Report
-        path: report
 
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master

+ 8 - 0
.github/workflows/ci.yml

@@ -208,6 +208,8 @@ jobs:
     - name: Build
       run: |
         yarn lerna run build
+      env:
+        ANALYZE_BUNDLE_SIZE: ${{ matrix.node-version == '14.x' }}
     - name: lerna bootstrap --production
       run: |
         npx lerna bootstrap -- --production
@@ -235,6 +237,12 @@ jobs:
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb36.ports['27017'] }}/growi-${{ steps.getdbname.outputs.suffix }}
 
+    - name: Upload report as artifact
+      uses: actions/upload-artifact@v2
+      with:
+        name: Bundle Analyzing Report
+        path: packages/app/report/bundle-analyzer.html
+
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
       if: failure()

+ 2 - 0
.github/workflows/release-rc.yml

@@ -13,6 +13,8 @@ jobs:
 
     steps:
     - uses: actions/checkout@v2
+      with:
+        lfs: true
 
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@1.1.0

+ 58 - 0
.github/workflows/release-slackbot-proxy.yml

@@ -19,6 +19,8 @@ jobs:
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@1.1.0
       id: package-json
+      with:
+        workingDir: packages/slackbot-proxy
 
     - name: Docker meta
       id: meta
@@ -78,6 +80,13 @@ jobs:
         rm -rf /tmp/.buildx-cache
         mv /tmp/.buildx-cache-new /tmp/.buildx-cache
 
+    - name: Add tag
+      uses: anothrNick/github-tag-action@1.36.0
+      env:
+        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        CUSTOM_TAG: v${{ steps.package-json.outputs.packageVersion }}
+        VERBOSE : true
+
     - name: Update Docker Hub Description
       uses: peter-evans/dockerhub-description@v2
       with:
@@ -85,3 +94,52 @@ jobs:
         password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
         repository: weseek/growi-slackbot-proxy
         readme-filepath: ./packages/slackbot-proxy/docker/README.md
+
+
+  create-pr-for-next-rc:
+    needs: build-and-push-image
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v2
+      with:
+        ref: ${{ github.event.pull_request.base.ref }}
+
+    - uses: actions/setup-node@v2
+      with:
+        node-version: '14'
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: Install dependencies
+      run: |
+        npx lerna bootstrap
+
+    - name: Bump versions for next RC
+      run: |
+        yarn bump-versions:slackbot-proxy
+
+    - name: Retrieve information from package.json
+      uses: myrotvorets/info-from-package-json-action@1.1.0
+      id: package-json
+      with:
+        workingDir: packages/slackbot-proxy
+
+    - name: Commit
+      uses: github-actions-x/commit@v2.8
+      with:
+        github-token: ${{ secrets.GITHUB_TOKEN }}
+        push-branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
+        commit-message: 'Bump version'
+        name: GitHub Action
+
+    - name: Create PR
+      uses: repo-sync/pull-request@v2
+      with:
+        source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
+        destination_branch: master
+        pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
+        pr_label: exclude from changelog
+        pr_body: "An automated PR generated by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
+        github_token: ${{ secrets.GITHUB_TOKEN }}

+ 6 - 2
.github/workflows/release.yml

@@ -34,7 +34,7 @@ jobs:
 
     - name: Bump versions
       run: |
-        node ./bin/github-actions/bump-versions -i patch
+        yarn bump-versions:patch
         sh ./packages/app/bin/github-actions/update-readme.sh
 
     - name: Retrieve information from package.json
@@ -95,7 +95,8 @@ jobs:
 
     - name: Bump versions for next RC
       run: |
-        node ./bin/github-actions/bump-versions -i prerelease
+        yarn bump-versions:rc
+        yarn bump-versions:slackbot-proxy
 
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@1.1.0
@@ -133,6 +134,7 @@ jobs:
     - uses: actions/checkout@v2
       with:
         ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
+        lfs: true
 
     - name: Setup suffix
       id: suffix
@@ -183,6 +185,8 @@ jobs:
         file: ./packages/app/docker/Dockerfile
         platforms: linux/amd64
         push: true
+        build-args: |
+          flavor=${{ matrix.flavor }}
         cache-from: type=local,src=/tmp/.buildx-cache
         cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
         tags: ${{ steps.meta.outputs.tags }}

+ 38 - 1
CHANGELOG.md

@@ -1,9 +1,46 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.4.5...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.4.7...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v4.4.7](https://github.com/weseek/growi/compare/v4.4.6...v4.4.7) - 2021-09-29
+
+### 🚀 Improvement
+
+- imprv: Slackbot search (#4420) @yuki-takei
+- imprv: Omit textlint-rule-en-capitalization (#4403) @yuki-takei
+- imprv: Apply terminus for graceful shutdown (#4398) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: A problem that GROWI server doesn't retrieve connection status from Official bot proxy (#4416) @yuki-takei
+- fix: Dictionary path of kuromoji invalid when uploaded to server (#4381) @stevenfukase
+- fix: Copy correct dotenv file for NO_CDN docker image (#4397) @yuki-takei
+- fix: Stop using ts-node in production (#4411) @yuki-takei
+- fix: SAML setting says 'setup is not yet complete' even if setup properly (#4390) @nakashimaki
+- fix: SidebarSmall button does not keep selection on reload (#4389) @nakashimaki
+- fix: Migrations for updating data for slackbot (#4406) @yuki-takei
+- fix: Migrations do not run in production (#4395) @yuki-takei
+- fix: Migration file for mongodb 3.6 compatibility (#4413) @hakumizuki
+- fix(slackbot): Sync permission when data stored is not enough (#4417) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Install Git LFS when provisioning of devcontainer (#4405) @stevenfukase
+- chore: Add .dockerignore (#4396) @yuki-takei
+
+## [v4.4.6](https://github.com/weseek/growi/compare/v4.4.5...v4.4.6) - 2021-09-24
+
+### 🚀 Improvement
+
+- imprv: Slackbot response flow (#4296) @yuki-takei
+- imprv(slackbot-proxy): Show version on the top page (#4342) @yuto-oweseek
+
+### 🧰 Maintenance
+
+- support(slackbot-proxy): Bump slackbot proxy version independentry (#4385) @yuki-takei
+
 ## [v4.4.5](https://github.com/weseek/growi/compare/v4.4.4...v4.4.5) - 2021-09-23
 
 ### 🐛 Bug Fixes

+ 11 - 0
THIRD-PARTY-NOTICES.md

@@ -17,6 +17,7 @@ https://github.com/weseek/growi.
 3. Microsoft/vscode (https://github.com/Microsoft/vscode)
 4. stephenhutchings/typicons.font (https://github.com/stephenhutchings/typicons.font)
 5. EmojiOne Version 3 (https://github.com/joypixels/emojione/tree/v3.1.1)
+6. Kuromoji.js (https://github.com/takuyaa/kuromoji.js)
 
 
 License Notice for Apache License, Version 2.0 Derivative Works
@@ -108,3 +109,13 @@ https://creativecommons.org/licenses/by/4.0/
 ```
 author: "EmojiOne <ryan@emojione.com> (http://emojione.com)"
 ```
+
+
+License Notice for Kuromoji.js
+------------------------
+
+https://github.com/takuyaa/kuromoji.js/blob/master/LICENSE-2.0.txt
+
+```
+author: "Takuya Asano <takuya.a@gmail.com>"
+```

+ 7 - 1
bin/github-actions/bump-versions/flow/bump-versions.js

@@ -13,6 +13,7 @@ async function bumpVersions({
   help = false,
   dir = '.',
   dryRun = false,
+  updateDependencies = true,
   increment = 'patch',
   preid = 'RC',
 }) {
@@ -26,8 +27,12 @@ async function bumpVersions({
 
   const config = await loadConfig(dir, 'bump-versions.config');
 
-  // get current version
   const { monorepo } = config;
+  if (!updateDependencies) {
+    monorepo.updateDependencies = false;
+  }
+
+  // get current version
   const currentVersion = monorepo && monorepo.mainVersionFile
     ? getCurrentVersion(dir, monorepo.mainVersionFile)
     : getCurrentVersion(dir);
@@ -55,6 +60,7 @@ const arg = {
   '--dir': String,
   '--help': Boolean,
   '--dry-run': Boolean,
+  '--update-dependencies': Boolean,
   '--increment': String,
   '--preid': String,
 

+ 5 - 1
bin/github-actions/bump-versions/step/printHelp.js

@@ -9,8 +9,9 @@ export default () => runStep({}, () => {
   const dir = `--dir ${underline('PATH')}`;
   const increment = `--increment ${underline('LEVEL')}`;
   const preId = `--preid ${underline('IDENTIFIER')}`;
+  const updateDependencies = `--update-dependencies ${underline('true/false')}`;
   const dryRun = '--dry-run';
-  const all = [help, dir, increment, preId, dryRun]
+  const all = [help, dir, increment, preId, updateDependencies, dryRun]
     .map(x => `[${x}]`)
     .join(' ');
 
@@ -46,6 +47,9 @@ export default () => runStep({}, () => {
       )} for semver.inc() with 'prerelease' type (default: 'RC').`,
     ),
     '',
+    indent(`${updateDependencies}`),
+    indent('  Update dependencies or not (default: true).'),
+    '',
     indent(`-D, ${dryRun}`),
     indent('  Displays the steps without actually doing them.'),
     '',

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "4.4.6-RC.0",
+  "version": "4.4.8-RC.0",
   "packages": [
     "packages/*"
   ]

+ 9 - 6
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.4.6-RC.0",
+  "version": "4.4.8-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -35,6 +35,9 @@
     "app:server": "yarn lerna run server --scope @growi/app",
     "slackbot-proxy:build": "yarn lerna run build --scope @growi/slackbot-proxy --scope @growi/slack",
     "slackbot-proxy:server": "yarn lerna run start:prod --scope @growi/slackbot-proxy",
+    "bump-versions:patch": "node ./bin/github-actions/bump-versions -i patch",
+    "bump-versions:rc": "node ./bin/github-actions/bump-versions -i prerelease",
+    "bump-versions:slackbot-proxy": "node ./bin/github-actions/bump-versions -i prerelease -d packages/slackbot-proxy --preid slackbot-proxy --update-dependencies false",
     "//// scripts for backward compatibility": "",
     "build:prod": "echo !!! CAUTION !!! ==> The script 'build:prod' is deprecated. Use 'yarn app:build' instead. && yarn app:build",
     "server:prod": "echo !!! CAUTION !!! ==> The script 'server:prod' is deprecated. Use 'yarn app:build' instead. && yarn app:server"
@@ -43,10 +46,7 @@
     "cross-env": "^7.0.0",
     "dotenv-flow": "^3.2.0",
     "npm-run-all": "^4.1.5",
-    "ts-node": "^9.1.1",
-    "tsconfig-paths": "^3.9.0",
-    "tslib": "^2.3.1",
-    "typescript": "^4.2.3"
+    "tslib": "^2.3.1"
   },
   "devDependencies": {
     "@types/jest": "^26.0.22",
@@ -67,7 +67,10 @@
     "lerna": "^4.0.0",
     "rewire": "^5.0.0",
     "shipjs": "^0.23.3",
-    "ts-jest": "^27.0.4"
+    "ts-jest": "^27.0.4",
+    "ts-node": "^9.1.1",
+    "tsconfig-paths": "^3.9.0",
+    "typescript": "^4.2.3"
   },
   "engines": {
     "node": "^12 || ^14",

+ 2 - 0
packages/app/.env.development

@@ -2,6 +2,8 @@
 ## Handled by Next.js with dotenv or dotenv-flow
 ## https://nextjs.org/docs/basic-features/environment-variables
 ##
+MIGRATIONS_DIR=src/migrations/
+
 FILE_UPLOAD=mongodb
 # MONGO_GRIDFS_TOTAL_LIMIT=10485760
 MATHJAX=1

+ 1 - 0
packages/app/.env.production

@@ -3,3 +3,4 @@
 ## https://nextjs.org/docs/basic-features/environment-variables
 ##
 FORMAT_NODE_LOG=false
+MIGRATIONS_DIR=dist/migrations/

+ 2 - 1
packages/app/.eslintrc.js

@@ -25,11 +25,12 @@ module.exports = {
       name: 'axios',
       message: 'Please use src/utils/axios instead.',
     }],
+    '@typescript-eslint/no-var-requires': 'off',
+
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei
     '@typescript-eslint/explicit-module-boundary-types': ['warn'],
     '@typescript-eslint/no-use-before-define': ['warn'],
     '@typescript-eslint/no-this-alias': ['warn'],
-    '@typescript-eslint/no-var-requires': ['warn'],
     'jest/no-done-callback': ['warn'],
   },
 };

+ 1 - 1
packages/app/.gitignore

@@ -5,8 +5,8 @@
 # dist
 /dist/
 /transpiled/
+/report/
 /public/static/js
-/public/static/dict
 /public/static/styles
 /public/uploads
 /tmp/

+ 1 - 12
packages/app/bin/cdn/cdn-resources-downloader.ts

@@ -4,9 +4,7 @@ import urljoin from 'url-join';
 import { Transform } from 'stream';
 import replaceStream from 'replacestream';
 
-import {
-  cdnLocalScriptRoot, cdnLocalDictRoot, cdnLocalStyleRoot, cdnLocalStyleWebRoot,
-} from '^/config/cdn';
+import { cdnLocalScriptRoot, cdnLocalStyleRoot, cdnLocalStyleWebRoot } from '^/config/cdn';
 import * as cdnManifests from '^/resource/cdn-manifests';
 
 import { CdnResource, CdnManifest } from '~/interfaces/cdn';
@@ -22,14 +20,6 @@ export default class CdnResourcesDownloader {
       return { manifest, outDir: cdnLocalScriptRoot };
     });
 
-    const cdnDictResources: CdnResource[] = cdnManifests.dict.map((manifest: CdnManifest) => {
-      return { manifest, outDir: cdnLocalDictRoot };
-    });
-
-    const dictExtensionOptions = {
-      ext: 'gz',
-    };
-
     const cdnStyleResources: CdnResource[] = cdnManifests.style.map((manifest) => {
       return { manifest, outDir: cdnLocalStyleRoot };
     });
@@ -42,7 +32,6 @@ export default class CdnResourcesDownloader {
 
     return Promise.all([
       this.downloadScripts(cdnScriptResources),
-      this.downloadScripts(cdnDictResources, dictExtensionOptions),
       this.downloadStyles(cdnStyleResources, dlStylesOptions),
     ]);
   }

+ 0 - 1
packages/app/config/cdn.js

@@ -4,6 +4,5 @@ import { projectRoot } from '~/utils/project-dir-utils';
 
 export const cdnLocalScriptRoot = path.join(projectRoot, 'public/static/js/cdn');
 export const cdnLocalScriptWebRoot = '/static/js/cdn';
-export const cdnLocalDictRoot = path.join(projectRoot, 'public/static/dict/cdn');
 export const cdnLocalStyleRoot = path.join(projectRoot, 'public/static/styles/cdn');
 export const cdnLocalStyleWebRoot = '/static/styles/cdn';

+ 2 - 2
packages/app/config/webpack.prod.js

@@ -15,7 +15,7 @@ const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
 /**
   * Webpack Constants
   */
-const { ANALYZE } = process.env;
+const { ANALYZE_BUNDLE_SIZE } = process.env;
 
 module.exports = require('./webpack.common')({
   mode: 'production',
@@ -60,7 +60,7 @@ module.exports = require('./webpack.common')({
     }),
 
     new BundleAnalyzerPlugin({
-      analyzerMode: ANALYZE ? 'static' : 'disabled',
+      analyzerMode: ANALYZE_BUNDLE_SIZE ? 'static' : 'disabled',
       reportFilename: path.resolve(__dirname, '../report/bundle-analyzer.html'),
       openAnalyzer: false,
     }),

+ 6 - 11
packages/app/docker/Dockerfile

@@ -72,15 +72,14 @@ RUN rm node_modules.tar
 ##
 FROM prebuilder-default AS prebuilder-nocdn
 
-# replace env.prod.js for NO_CDN
-COPY docker/nocdn/env.prod.js ${appDir}/config/
+# add dotenv file for NO_CDN
+COPY packages/app/docker/nocdn/.env.production.local ${appDir}/packages/app/
 
 
 
 ##
 ## builder
 ##
-# FROM prebuilder-${flavor}
 FROM prebuilder-${flavor} AS builder
 
 ENV appDir /opt/growi
@@ -113,7 +112,8 @@ RUN tar cf packages.tar \
   packages/app/public \
   packages/app/resource \
   packages/app/tmp \
-  packages/app/.env.production \
+  packages/app/migrate-mongo-config.js \
+  packages/app/.env.production* \
   packages/app/tsconfig.base.json \
   packages/app/tsconfig.json \
   packages/*/package.json \
@@ -140,11 +140,6 @@ RUN set -eux; \
 # verify that the binary works
 	gosu nobody true
 
-# Add Tini
-ENV TINI_VERSION v0.19.0
-ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
-RUN chmod +x /tini
-
 COPY --from=deps-resolver-prod --chown=node:node \
   ${appDir}/node_modules.tar ${appDir}/
 COPY --from=builder --chown=node:node \
@@ -168,5 +163,5 @@ WORKDIR ${appDir}/packages/app
 VOLUME /data
 EXPOSE 3000
 
-ENTRYPOINT ["/tini", "-e", "143", "--", "/docker-entrypoint.sh"]
-CMD ["node", "-r", "dotenv-flow/config", "--expose_gc", "dist/server/app.js"]
+ENTRYPOINT ["/docker-entrypoint.sh"]
+CMD ["yarn migrate && node -r dotenv-flow/config --expose_gc dist/server/app.js"]

+ 6 - 0
packages/app/docker/Dockerfile.dockerignore

@@ -0,0 +1,6 @@
+node_modules
+*/node_modules
+*/coverage
+*/dist
+*/Dockerfile
+*/*.dockerignore

+ 2 - 2
packages/app/docker/README.md

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`4.4.5`, `4.4`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.5/docker/Dockerfile)
-* [`4.4.5-nocdn`, `4.4-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.5/docker/Dockerfile)
+* [`4.4.7`, `4.4`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.7/docker/Dockerfile)
+* [`4.4.7-nocdn`, `4.4-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.7/docker/Dockerfile)
 * [`4.3.3`, `4.3` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.3/docker/Dockerfile)
 * [`4.3.3-nocdn`, `4.3-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.3/docker/Dockerfile)
 

+ 1 - 1
packages/app/docker/docker-entrypoint.sh

@@ -11,4 +11,4 @@ fi
 chown -R node:node /data/uploads
 chown -h node:node ./public/uploads
 
-gosu node $@
+exec gosu node /bin/bash -c "$@"

+ 6 - 0
packages/app/docker/nocdn/.env.production.local

@@ -0,0 +1,6 @@
+
+##
+## Handled by Next.js with dotenv or dotenv-flow
+## https://nextjs.org/docs/basic-features/environment-variables
+##
+NO_CDN=true

+ 0 - 5
packages/app/docker/nocdn/env.prod.js

@@ -1,5 +0,0 @@
-module.exports = {
-  NODE_ENV: 'production',
-  NO_CDN: true,
-  // FORMAT_NODE_LOG: false,
-};

+ 21 - 5
packages/app/jest.config.js

@@ -14,10 +14,22 @@ module.exports = {
 
   preset: 'ts-jest/presets/js-with-ts',
 
-  globalSetup: '<rootDir>/src/test/global-setup.js',
-  globalTeardown: '<rootDir>/src/test/global-teardown.js',
-
   projects: [
+    {
+      displayName: 'unit',
+
+      preset: 'ts-jest/presets/js-with-ts',
+
+      rootDir: '.',
+      roots: ['<rootDir>/src'],
+      testMatch: ['<rootDir>/src/test/unit/**/*.test.ts', '<rootDir>/src/test/unit/**/*.test.js'],
+
+      testEnvironment: 'node',
+
+      // Automatically clear mock calls and instances between every test
+      clearMocks: true,
+      moduleNameMapper: MODULE_NAME_MAPPING,
+    },
     {
       displayName: 'server',
 
@@ -25,9 +37,13 @@ module.exports = {
 
       rootDir: '.',
       roots: ['<rootDir>/src'],
+      testMatch: ['<rootDir>/src/test/integration/**/*.test.ts', '<rootDir>/src/test/integration/**/*.test.js'],
+
       testEnvironment: 'node',
-      setupFilesAfterEnv: ['<rootDir>/src/test/setup.js'],
-      testMatch: ['<rootDir>/src/test/**/*.test.ts', '<rootDir>/src/test/**/*.test.js'],
+      globalSetup: '<rootDir>/src/test/integration/global-setup.js',
+      globalTeardown: '<rootDir>/src/test/integration/global-teardown.js',
+      setupFilesAfterEnv: ['<rootDir>/src/test/integration/setup.js'],
+
       // Automatically clear mock calls and instances between every test
       clearMocks: true,
       moduleNameMapper: MODULE_NAME_MAPPING,

+ 8 - 5
packages/app/config/migrate.js → packages/app/migrate-mongo-config.js

@@ -5,11 +5,15 @@
  * @author Yuki Takei <yuki@weseek.co.jp>
  */
 
-import mongoose from 'mongoose';
+const { URL } = require('url');
 
-import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+// get migrationsDir from env var
+const migrationsDir = process.env.MIGRATIONS_DIR;
+if (migrationsDir == null) {
+  throw new Error('An env var MIGRATIONS_DIR must be set.');
+}
 
-const { URL } = require('url');
+const { initMongooseGlobalSettings, getMongoUri, mongoOptions } = require('@growi/core');
 
 initMongooseGlobalSettings();
 
@@ -25,8 +29,7 @@ const mongodb = {
 };
 
 module.exports = {
-  mongoUri,
   mongodb,
-  migrationsDir: 'src/migrations/',
+  migrationsDir,
   changelogCollectionName: 'migrations',
 };

+ 16 - 13
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "4.4.6-RC.0",
+  "version": "4.4.8-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -14,13 +14,20 @@
     "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config --expose_gc dist/server/app.js",
     "server:ci": "yarn server --ci",
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
+    "migrate": "node -r dotenv-flow/config node_modules/.bin/migrate-mongo up",
     "//// for development": "",
     "dev": "run-p dev:client dev:server",
     "dev:client": "yarn cross-env NODE_ENV=development webpack --config config/webpack.dev.js --progress --watch",
     "dev:client:nowatch": "yarn cross-env NODE_ENV=development webpack --config config/webpack.dev.js",
     "dev:server": "yarn cross-env NODE_ENV=development ts-node-dev --inspect --expose-gc -r tsconfig-paths/register -r dotenv-flow/config --transpile-only src/server/app.ts",
     "predev:client": "yarn cross-env NODE_ENV=development run-p resources:*",
-    "predev:server": "yarn cross-env NODE_ENV=development yarn migrate",
+    "predev:server": "yarn cross-env NODE_ENV=development yarn dev:migrate:up",
+    "dev:migrate-mongo": "yarn cross-env NODE_ENV=development yarn ts-node node_modules/.bin/migrate-mongo",
+    "dev:migrate": "yarn dev:migrate:up",
+    "dev:migrate:create": "yarn dev:migrate-mongo create",
+    "dev:migrate:status": "yarn dev:migrate-mongo status",
+    "dev:migrate:up": "yarn dev:migrate-mongo up",
+    "dev:migrate:down": "yarn dev:migrate-mongo down",
     "//// for CI": "",
     "dev:ci": "yarn dev:client:nowatch && yarn dev:server --ci",
     "predev:ci": "run-p resources:*",
@@ -39,11 +46,6 @@
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "resources:plugin": "yarn ts-node bin/generate-plugin-definitions-source.ts",
     "resources:dl-resources": "yarn ts-node bin/download-cdn-resources.ts",
-    "migrate": "yarn migrate:up",
-    "migrate:create": "yarn ts-node node_modules/.bin/migrate-mongo create -f config/migrate.js",
-    "migrate:status": "yarn ts-node node_modules/.bin/migrate-mongo status -f config/migrate.js",
-    "migrate:up": "yarn ts-node node_modules/.bin/migrate-mongo up -f config/migrate.js",
-    "migrate:down": "yarn ts-node node_modules/.bin/migrate-mongo down -f config/migrate.js",
     "ts-node": "ts-node -r tsconfig-paths/register -r dotenv-flow/config --transpile-only"
   },
   "// comments for dependencies": {
@@ -53,12 +55,13 @@
   },
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
+    "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^4.4.6-RC.0",
-    "@growi/plugin-attachment-refs": "^4.4.6-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^4.4.6-RC.0",
-    "@growi/plugin-lsx": "^4.4.6-RC.0",
-    "@growi/slack": "^4.4.6-RC.0",
+    "@growi/codemirror-textlint": "^4.4.8-RC.0",
+    "@growi/plugin-attachment-refs": "^4.4.8-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^4.4.8-RC.0",
+    "@growi/plugin-lsx": "^4.4.8-RC.0",
+    "@growi/slack": "^4.4.8-RC.0",
     "@promster/express": "^5.1.0",
     "@promster/server": "^6.0.3",
     "@slack/events-api": "^3.0.0",
@@ -154,7 +157,7 @@
     "@alienfast/i18next-loader": "^1.0.16",
     "@atlaskit/drawer": "^5.3.7",
     "@atlaskit/navigation-next": "^8.0.5",
-    "@growi/ui": "^4.4.6-RC.0",
+    "@growi/ui": "^4.4.8-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

+ 3 - 0
packages/app/public/static/dict/base.dat.gz

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0803327762e1c93ca731e4319ab8343340f2806bb84941207782cde9d2d5a8eb
+size 3956825

+ 3 - 0
packages/app/public/static/dict/cc.dat.gz

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:02b7631be0d4de3a1a75cd9f9cc51536e4f94c9e6b389b813e06ba0f6e7de765
+size 1692067

+ 3 - 0
packages/app/public/static/dict/check.dat.gz

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:193ae0035fff6fe812b58d9ee730e7a7d7ee601d918481ce51075c58114f6cc9
+size 3111633

+ 3 - 0
packages/app/public/static/dict/tid.dat.gz

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d43d831cb6fb0f0a411739cd287a6d5e998e121a8daca614df14a81a0dcac586
+size 1605820

+ 3 - 0
packages/app/public/static/dict/tid_map.dat.gz

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:33efd5ffd87a70f669add093fa39dee44341d58f940844ef107c8fd98bb795b2
+size 1485576

+ 3 - 0
packages/app/public/static/dict/tid_pos.dat.gz

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:60dbfc99a6ab993f30c5dab648bec6ad7f9aaefa5c14e1843837d95e509f8895
+size 5916009

+ 3 - 0
packages/app/public/static/dict/unk.dat.gz

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f7f991cdeb9bfd3e9c0e4577cc50ee0815a11c508cccd444a9d3ab3c81521100
+size 10512

+ 3 - 0
packages/app/public/static/dict/unk_char.dat.gz

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9a8e86fd9aff32d323fbb59f5a7006f05927a11f8173c90712cc56293aeb3225
+size 306

+ 3 - 0
packages/app/public/static/dict/unk_compat.dat.gz

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:50f60aa29bc2e86c2903ab8c825bb6fa604d2b294d96941c1d3924259791899d
+size 338

+ 3 - 0
packages/app/public/static/dict/unk_invoke.dat.gz

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6b210889548457c3006913afd12c8b525562255f2709e404604be9614a25e94c
+size 1140

+ 3 - 0
packages/app/public/static/dict/unk_map.dat.gz

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6df12460e5477230bb6fd9641def918b699fc0a8868016b6c9f794488630509b
+size 1190

+ 3 - 0
packages/app/public/static/dict/unk_pos.dat.gz

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5b183a29f281acc7e0542beca47b83f7985047c0a2d27e78a66f32276be5ad11
+size 10540

+ 0 - 86
packages/app/resource/cdn-manifests.js

@@ -89,92 +89,6 @@ module.exports = {
       },
     },
   ],
-  dict: [
-    {
-      name: 'base.dat',
-      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/base.dat.gz',
-      args: {
-        integrity: '',
-      },
-    },
-    {
-      name: 'cc.dat',
-      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/cc.dat.gz',
-      args: {
-        integrity: '',
-      },
-    },
-    {
-      name: 'check.dat',
-      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/check.dat.gz',
-      args: {
-        integrity: '',
-      },
-    },
-    {
-      name: 'tid_map.dat',
-      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/tid_map.dat.gz',
-      args: {
-        integrity: '',
-      },
-    },
-    {
-      name: 'tid_pos.dat',
-      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/tid_pos.dat.gz',
-      args: {
-        integrity: '',
-      },
-    },
-    {
-      name: 'tid.dat',
-      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/tid.dat.gz',
-      args: {
-        integrity: '',
-      },
-    },
-    {
-      name: 'unk_char.dat',
-      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/unk_char.dat.gz',
-      args: {
-        integrity: '',
-      },
-    },
-    {
-      name: 'unk_compat.dat',
-      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/unk_compat.dat.gz',
-      args: {
-        integrity: '',
-      },
-    },
-    {
-      name: 'unk_invoke.dat',
-      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/unk_invoke.dat.gz',
-      args: {
-        integrity: '',
-      },
-    },
-    {
-      name: 'unk_map.dat',
-      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/unk_map.dat.gz',
-      args: {
-        integrity: '',
-      },
-    },
-    {
-      name: 'unk_pos.dat',
-      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/unk_pos.dat.gz',
-      args: {
-        integrity: '',
-      },
-    },
-    {
-      name: 'unk.dat',
-      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/unk.dat.gz',
-      args: {
-        integrity: '',
-      },
-    },
-  ],
   style: [
     {
       name: 'lato',

+ 5 - 0
packages/app/resource/locales/en_US/translation.json

@@ -454,6 +454,11 @@
       "Post": "Post"
     }
   },
+  "modal_enable_textlint": {
+    "confirm_download_dict_and_enable_textlint": "Are you sure you want to enable Textlint? This will download 20MB of dictionary file.",
+    "enable_textlint": "Enable Textlint",
+    "dont_ask_again": "Don't ask again"
+  },
   "link_edit": {
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",

+ 5 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -455,6 +455,11 @@
       "Post": "投稿"
     }
   },
+  "modal_enable_textlint": {
+    "confirm_download_dict_and_enable_textlint": "Textlintを有効にしますか?20MBの辞書ファイルをダウンロードします。",
+    "enable_textlint": "Textlintを有効にする",
+    "dont_ask_again": "常に許可する"
+  },
   "link_edit": {
     "edit_link": "リンク編集",
     "set_link_and_label": "リンク情報",

+ 5 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -433,6 +433,11 @@
 			"Post": "提交"
 		}
 	},
+  "modal_enable_textlint": {
+    "confirm_download_dict_and_enable_textlint": "您确定要启用 Textlint 吗?这将下载 20MB 的字典文件。",
+    "enable_textlint": "启用Textlint",
+    "dont_ask_again": "不要再问"
+  },
   "link_edit": {
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",

+ 9 - 2
packages/app/src/client/services/EditorContainer.js

@@ -36,7 +36,9 @@ export default class EditorContainer extends Container {
 
       editorOptions: {},
       previewOptions: {},
-      isTextlintEnabled: false,
+
+      // Defaults to null to show modal when not in DB
+      isTextlintEnabled: null,
       textlintRules: [],
 
       indentSize: this.appContainer.config.adminPreferredIndentSize || 4,
@@ -204,13 +206,18 @@ export default class EditorContainer extends Container {
    * Retrieve Editor Settings
    */
   async retrieveEditorSettings() {
+    if (this.appContainer.isGuestUser) {
+      return;
+    }
+
     const { data } = await this.appContainer.apiv3Get('/personal-setting/editor-settings');
 
     if (data?.textlintSettings == null) {
       return;
     }
 
-    const { isTextlintEnabled = false, textlintRules = [] } = data.textlintSettings;
+    // Defaults to null to show modal when not in DB
+    const { isTextlintEnabled = null, textlintRules = [] } = data.textlintSettings;
 
     this.setState({
       isTextlintEnabled,

+ 1 - 1
packages/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx

@@ -65,7 +65,7 @@ class SamlSecurityManagementContents extends React.Component {
                 {t('security_setting.SAML.enable_saml')}
               </label>
             </div>
-            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('ldap') && isSamlEnabled)
+            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('saml') && isSamlEnabled)
               && <div className="badge badge-warning">{t('security_setting.setup_is_not_yet_complete')}</div>}
           </div>
         </div>

+ 4 - 4
packages/app/src/components/Me/EditorSettings.tsx

@@ -46,10 +46,10 @@ const commonRulesMenuItems = [
     name: 'sentence-length',
     description: 'editor_settings.common_settings.sentence_length',
   },
-  {
-    name: 'en-capitalization',
-    description: 'editor_settings.common_settings.en_capitalization',
-  },
+  // {  // omit because en-pos package is too big
+  //   name: 'en-capitalization',
+  //   description: 'editor_settings.common_settings.en_capitalization',
+  // },
   {
     name: 'no-unmatched-pair',
     description: 'editor_settings.common_settings.no_unmatched_pair',

+ 2 - 5
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -33,8 +33,9 @@ import HandsontableModal from './HandsontableModal';
 import EditorIcon from './EditorIcon';
 import DrawioModal from './DrawioModal';
 
-
+// Textlint
 window.JSHINT = JSHINT;
+window.kuromojin = { dicPath: '/static/dict' };
 
 // set save handler
 codemirror.commands.save = (instance) => {
@@ -154,10 +155,6 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.cmCdnRoot = 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0';
     this.cmNoCdnScriptRoot = '/static/js/cdn';
     this.cmNoCdnStyleRoot = '/static/styles/cdn';
-    window.kuromojin = this.props.noCdn
-      ? { dicPath: '/static/dict/cdn' }
-      : { dicPath: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict' };
-
     this.interceptorManager = new InterceptorManager();
     this.interceptorManager.addInterceptors([
       new PreventMarkdownListInterceptor(),

+ 75 - 0
packages/app/src/components/PageEditor/DownloadDictModal.tsx

@@ -0,0 +1,75 @@
+import React, { useState, FC } from 'react';
+import PropTypes from 'prop-types';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+type DownloadDictModalProps = {
+  isModalOpen: boolean
+  onConfirmEnableTextlint?: (isSkipAskingAgainChecked: boolean) => void;
+  onCancel?: () => void;
+};
+
+export const DownloadDictModal: FC<DownloadDictModalProps> = (props) => {
+  const { t } = useTranslation('');
+  const [isSkipAskingAgainChecked, setIsSkipAskingAgainChecked] = useState(true);
+
+  const onCancel = () => {
+    if (props.onCancel != null) {
+      props.onCancel();
+    }
+  };
+
+  const onConfirmEnableTextlint = () => {
+    if (props.onConfirmEnableTextlint != null) {
+      props.onConfirmEnableTextlint(isSkipAskingAgainChecked);
+    }
+  };
+
+  return (
+    <Modal isOpen={props.isModalOpen} toggle={onCancel} className="">
+      <ModalHeader tag="h4" toggle={onCancel} className="bg-warning">
+        <i className="icon-fw icon-question" />
+        Warning
+      </ModalHeader>
+      <ModalBody>
+        {t('modal_enable_textlint.confirm_download_dict_and_enable_textlint')}
+      </ModalBody>
+      <ModalFooter>
+        <div className="mr-3 custom-control custom-checkbox custom-checkbox-info">
+          <input
+            type="checkbox"
+            className="custom-control-input"
+            id="dont-ask-again"
+            checked={isSkipAskingAgainChecked}
+            onChange={e => setIsSkipAskingAgainChecked(e.target.checked)}
+          />
+          <label className="custom-control-label align-center" htmlFor="dont-ask-again">
+            {t('modal_enable_textlint.dont_ask_again')}
+          </label>
+        </div>
+        <button
+          type="button"
+          className="btn btn-outline-secondary"
+          onClick={onCancel}
+        >
+          {t('Cancel')}
+        </button>
+        <button
+          type="button"
+          className="btn btn-outline-primary ml-3"
+          onClick={onConfirmEnableTextlint}
+        >
+          {t('modal_enable_textlint.enable_textlint')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+DownloadDictModal.propTypes = {
+  isModalOpen: PropTypes.bool.isRequired,
+  onConfirmEnableTextlint: PropTypes.func,
+  onCancel: PropTypes.func,
+};

+ 41 - 8
packages/app/src/components/PageEditor/OptionsSelector.jsx

@@ -11,6 +11,7 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import { toastError } from '~/client/util/apiNotification';
+import { DownloadDictModal } from './DownloadDictModal';
 
 
 export const defaultEditorOptions = {
@@ -34,6 +35,8 @@ class OptionsSelector extends React.Component {
     this.state = {
       isCddMenuOpened: false,
       isMathJaxEnabled,
+      isDownloadDictModalShown: false,
+      isSkipAskingAgainChecked: false,
     };
 
     this.availableThemes = [
@@ -53,6 +56,8 @@ class OptionsSelector extends React.Component {
     this.onClickRenderMathJaxInRealtime = this.onClickRenderMathJaxInRealtime.bind(this);
     this.onClickMarkdownTableAutoFormatting = this.onClickMarkdownTableAutoFormatting.bind(this);
     this.switchTextlintEnabledHandler = this.switchTextlintEnabledHandler.bind(this);
+    this.confirmEnableTextlintHandler = this.confirmEnableTextlintHandler.bind(this);
+    this.toggleTextlint = this.toggleTextlint.bind(this);
     this.updateIsTextlintEnabledToDB = this.updateIsTextlintEnabledToDB.bind(this);
     this.onToggleConfigurationDropdown = this.onToggleConfigurationDropdown.bind(this);
     this.onChangeIndentSize = this.onChangeIndentSize.bind(this);
@@ -124,11 +129,29 @@ class OptionsSelector extends React.Component {
     }
   }
 
-  async switchTextlintEnabledHandler() {
+  toggleTextlint() {
     const { editorContainer } = this.props;
     const newVal = !editorContainer.state.isTextlintEnabled;
     editorContainer.setState({ isTextlintEnabled: newVal });
-    this.updateIsTextlintEnabledToDB(newVal);
+    if (this.state.isSkipAskingAgainChecked) {
+      this.updateIsTextlintEnabledToDB(newVal);
+    }
+  }
+
+  switchTextlintEnabledHandler() {
+    const { editorContainer } = this.props;
+    if (editorContainer.state.isTextlintEnabled === null) {
+      this.setState({ isDownloadDictModalShown: true });
+      return;
+    }
+    this.toggleTextlint();
+  }
+
+  confirmEnableTextlintHandler(isSkipAskingAgainChecked) {
+    this.setState(
+      { isSkipAskingAgainChecked, isDownloadDictModalShown: false },
+      () => this.toggleTextlint(),
+    );
   }
 
   onToggleConfigurationDropdown(newValue) {
@@ -359,12 +382,22 @@ class OptionsSelector extends React.Component {
 
   render() {
     return (
-      <div className="d-flex flex-row">
-        <span>{this.renderThemeSelector()}</span>
-        <span className="d-none d-sm-block ml-2 ml-sm-4">{this.renderKeymapModeSelector()}</span>
-        <span className="ml-2 ml-sm-4">{this.renderIndentSizeSelector()}</span>
-        <span className="ml-2 ml-sm-4">{this.renderConfigurationDropdown()}</span>
-      </div>
+      <>
+        <div className="d-flex flex-row">
+          <span>{this.renderThemeSelector()}</span>
+          <span className="d-none d-sm-block ml-2 ml-sm-4">{this.renderKeymapModeSelector()}</span>
+          <span className="ml-2 ml-sm-4">{this.renderIndentSizeSelector()}</span>
+          <span className="ml-2 ml-sm-4">{this.renderConfigurationDropdown()}</span>
+        </div>
+
+        {!this.state.isSkipAskingAgainChecked && (
+          <DownloadDictModal
+            isModalOpen={this.state.isDownloadDictModalShown}
+            onConfirmEnableTextlint={this.confirmEnableTextlintHandler}
+            onCancel={() => this.setState({ isDownloadDictModalShown: false })}
+          />
+        )}
+      </>
     );
   }
 

+ 354 - 0
packages/app/src/components/SearchPage/SearchResult.jsx

@@ -0,0 +1,354 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import * as toastr from 'toastr';
+
+import { withTranslation } from 'react-i18next';
+
+import Page from '../PageList/Page';
+import SearchResultList from './SearchResultList';
+import DeletePageListModal from './DeletePageListModal';
+import AppContainer from '~/client/services/AppContainer';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+class SearchResult extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      deletionMode: false,
+      selectedPages: new Set(),
+      isDeleteCompletely: undefined,
+      isDeleteConfirmModalShown: false,
+      errorMessageForDeleting: undefined,
+    };
+    this.toggleDeleteCompletely = this.toggleDeleteCompletely.bind(this);
+    this.deleteSelectedPages = this.deleteSelectedPages.bind(this);
+    this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
+  }
+
+  isNotSearchedYet() {
+    return !this.props.searchResultMeta.took;
+  }
+
+  isNotFound() {
+    return this.props.searchingKeyword !== '' && this.props.pages.length === 0;
+  }
+
+  isError() {
+    if (this.props.searchError !== null) {
+      return true;
+    }
+    return false;
+  }
+
+  /**
+   * move the page
+   */
+  visitPageButtonHandler(e) {
+    window.location.href = e.currentTarget.value;
+  }
+
+  /**
+   * toggle checkbox and add (or delete from) selected pages list
+   *
+   * @param {any} page
+   * @memberof SearchResult
+   */
+  toggleCheckbox(page) {
+    if (this.state.selectedPages.has(page)) {
+      this.state.selectedPages.delete(page);
+    }
+    else {
+      this.state.selectedPages.add(page);
+    }
+    this.setState({ isDeleteConfirmModalShown: false });
+    this.setState({ selectedPages: this.state.selectedPages });
+  }
+
+  /**
+   * check and return is all pages selected for delete?
+   *
+   * @returns all pages selected (or not)
+   * @memberof SearchResult
+   */
+  isAllSelected() {
+    return this.state.selectedPages.size === this.props.pages.length;
+  }
+
+  /**
+   * handle checkbox clicking that all pages select for delete
+   *
+   * @memberof SearchResult
+   */
+  handleAllSelect() {
+    if (this.isAllSelected()) {
+      this.state.selectedPages.clear();
+    }
+    else {
+      this.state.selectedPages.clear();
+      this.props.pages.map((page) => {
+        this.state.selectedPages.add(page);
+        return;
+      });
+    }
+    this.setState({ selectedPages: this.state.selectedPages });
+  }
+
+  /**
+   * change deletion mode
+   *
+   * @memberof SearchResult
+   */
+  handleDeletionModeChange() {
+    this.state.selectedPages.clear();
+    this.setState({ deletionMode: !this.state.deletionMode });
+  }
+
+  /**
+   * toggle check delete completely
+   *
+   * @memberof SearchResult
+   */
+  toggleDeleteCompletely() {
+    // request で completely が undefined でないと指定アリと見なされるため
+    this.setState({ isDeleteCompletely: this.state.isDeleteCompletely ? undefined : true });
+  }
+
+  /**
+   * delete selected pages
+   *
+   * @memberof SearchResult
+   */
+  deleteSelectedPages() {
+    const deleteCompletely = this.state.isDeleteCompletely;
+    Promise.all(Array.from(this.state.selectedPages).map((page) => {
+      return new Promise((resolve, reject) => {
+        const pageId = page._id;
+        const revisionId = page.revision._id;
+
+        this.props.appContainer.apiPost('/pages.remove', { page_id: pageId, revision_id: revisionId, completely: deleteCompletely })
+          .then((res) => {
+            if (res.ok) {
+              this.state.selectedPages.delete(page);
+              return resolve();
+            }
+
+            return reject();
+
+          })
+          .catch((err) => {
+            console.log(err.message); // eslint-disable-line no-console
+            this.setState({ errorMessageForDeleting: err.message });
+            return reject();
+          });
+      });
+    }))
+      .then(() => {
+        window.location.reload();
+      })
+      .catch((err) => {
+        toastr.error(err, 'Error occured', {
+          closeButton: true,
+          progressBar: true,
+          newestOnTop: false,
+          showDuration: '100',
+          hideDuration: '100',
+          timeOut: '3000',
+        });
+      });
+  }
+
+  /**
+   * open confirm modal for page selection delete
+   *
+   * @memberof SearchResult
+   */
+  showDeleteConfirmModal() {
+    this.setState({ isDeleteConfirmModalShown: true });
+  }
+
+  /**
+   * close confirm modal for page selection delete
+   *
+   * @memberof SearchResult
+   */
+  closeDeleteConfirmModal() {
+    this.setState({
+      isDeleteConfirmModalShown: false,
+      errorMessageForDeleting: undefined,
+    });
+  }
+
+  renderListView(pages) {
+    return pages.map((page) => {
+      // Add prefix 'id_' in pageId, because scrollspy of bootstrap doesn't work when the first letter of id attr of target component is numeral.
+      const pageId = `#id_${page._id}`;
+      return (
+        <li key={page._id} className="nav-item page-list-li w-100 m-0 border-bottom">
+          <a className="nav-link page-list-link d-flex align-items-baseline" href={pageId}>
+            <div className="form-check my-auto">
+              <input className="form-check-input my-auto" type="checkbox" value="" id="flexCheckDefault" />
+            </div>
+            <Page page={page} noLink />
+            <div className="ml-auto d-flex">
+              { this.state.deletionMode
+                && (
+                  <div className="custom-control custom-checkbox custom-checkbox-danger">
+                    <input
+                      type="checkbox"
+                      id={`page-delete-check-${page._id}`}
+                      className="custom-control-input search-result-list-delete-checkbox"
+                      value={pageId}
+                      checked={this.state.selectedPages.has(page)}
+                      onChange={() => { return this.toggleCheckbox(page) }}
+                    />
+                    <label className="custom-control-label" htmlFor={`page-delete-check-${page._id}`}></label>
+                  </div>
+                )
+              }
+              <div className="page-list-option">
+                <button type="button" className="btn btn-link p-0" value={page.path} onClick={this.visitPageButtonHandler}><i className="icon-login" /></button>
+              </div>
+            </div>
+          </a>
+          <div>{page.highlight['body.en']?.map(text => <p dangerouslySetInnerHTML={{ __html: text }} />)}</div>
+        </li>
+      );
+    });
+  }
+
+  render() {
+    const { t } = this.props;
+
+    if (this.isError()) {
+      return (
+        <div className="content-main">
+          <i className="searcing fa fa-warning"></i> Error on searching.
+        </div>
+      );
+    }
+
+    if (this.isNotSearchedYet()) {
+      return <div />;
+    }
+
+    if (this.isNotFound()) {
+      let under = '';
+      if (this.props.tree != null) {
+        under = ` under "${this.props.tree}"`;
+      }
+      return (
+        <div className="content-main">
+          <i className="icon-fw icon-info" /> No page found with &quot;{this.props.searchingKeyword}&quot;{under}
+        </div>
+      );
+
+    }
+
+    let deletionModeButtons = '';
+    let allSelectCheck = '';
+
+    if (this.state.deletionMode) {
+      deletionModeButtons = (
+        <div className="btn-group">
+          <button type="button" className="btn btn-outline-secondary btn-sm rounded-pill-weak" onClick={() => { return this.handleDeletionModeChange() }}>
+            <i className="icon-ban" /> {t('search_result.cancel')}
+          </button>
+          <button
+            type="button"
+            className="btn btn-danger btn-sm rounded-pill-weak"
+            onClick={() => { return this.showDeleteConfirmModal() }}
+            disabled={this.state.selectedPages.size === 0}
+          >
+            <i className="icon-trash" /> {t('search_result.delete')}
+          </button>
+        </div>
+      );
+      allSelectCheck = (
+        <div className="custom-control custom-checkbox custom-checkbox-danger">
+          <input
+            id="all-select-check"
+            className="custom-control-input"
+            type="checkbox"
+            onChange={() => { return this.handleAllSelect() }}
+            checked={this.isAllSelected()}
+          />
+          <label className="custom-control-label" htmlFor="all-select-check">&nbsp;{t('search_result.check_all')}</label>
+        </div>
+      );
+    }
+    else {
+      deletionModeButtons = (
+        <div className="btn-group">
+          <button type="button" className="btn btn-outline-secondary rounded-pill btn-sm" onClick={() => { return this.handleDeletionModeChange() }}>
+            <i className="ti-check-box" /> {t('search_result.deletion_mode_btn_lavel')}
+          </button>
+        </div>
+      );
+    }
+
+    const listView = this.renderListView(this.props.pages);
+
+    /*
+    UI あとで考える
+    <span className="search-result-meta">Found: {this.props.searchResultMeta.total} pages with "{this.props.searchingKeyword}"</span>
+    */
+    return (
+      <div className="content-main">
+        <div className="search-result row" id="search-result">
+          <div className="col-lg-6 d-none d-lg-block page-list search-result-list pr-0" id="search-result-list">
+            <nav>
+              <div className="d-flex align-items-start justify-content-between mt-1">
+                <div className="search-result-meta">
+                  <i className="icon-magnifier" /> Found {this.props.searchResultMeta.total} pages with &quot;{this.props.searchingKeyword}&quot;
+                </div>
+                <div className="text-nowrap">
+                  {deletionModeButtons}
+                  {allSelectCheck}
+                </div>
+              </div>
+
+              <div className="page-list">
+                <ul className="page-list-ul page-list-ul-flat nav nav-pills">{listView}</ul>
+              </div>
+            </nav>
+          </div>
+          <div className="col-lg-6 search-result-content" id="search-result-content">
+            <SearchResultList pages={this.props.pages} searchingKeyword={this.props.searchingKeyword} />
+          </div>
+        </div>
+        <DeletePageListModal
+          isShown={this.state.isDeleteConfirmModalShown}
+          pages={Array.from(this.state.selectedPages)}
+          errorMessage={this.state.errorMessageForDeleting}
+          cancel={this.closeDeleteConfirmModal}
+          confirmedToDelete={this.deleteSelectedPages}
+          isDeleteCompletely={this.state.isDeleteCompletely}
+          toggleDeleteCompletely={this.toggleDeleteCompletely}
+        />
+      </div> // content-main
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const SearchResultWrapper = withUnstatedContainers(SearchResult, [AppContainer]);
+
+SearchResult.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  t: PropTypes.func.isRequired, // i18next
+
+  pages: PropTypes.array.isRequired,
+  searchingKeyword: PropTypes.string.isRequired,
+  searchResultMeta: PropTypes.object.isRequired,
+  searchError: PropTypes.object,
+  tree: PropTypes.string,
+};
+SearchResult.defaultProps = {
+  searchError: null,
+};
+
+export default withTranslation()(SearchResultWrapper);

+ 1 - 1
packages/app/src/components/Sidebar/RecentChanges.jsx

@@ -187,7 +187,7 @@ class RecentChanges extends React.Component {
               className="custom-control-input"
               type="checkbox"
               checked={this.state.isRecentChangesSidebarSmall}
-              onChange={e => this.setState({ isRecentChangesSidebarSmall: e.target.checked })}
+              onChange={this.changeSizeHandler}
             />
             <label className="custom-control-label" htmlFor="recentChangesResize">
             </label>

+ 2 - 3
packages/app/src/migrations/20180926134048-make-email-unique.js

@@ -1,8 +1,7 @@
 import mongoose from 'mongoose';
 
-import config from '^/config/migrate';
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
-import { getModelSafely } from '~/server/util/mongoose-utils';
 
 const logger = loggerFactory('growi:migrate:make-email-unique');
 
@@ -10,7 +9,7 @@ module.exports = {
 
   async up(db, next) {
     logger.info('Start migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const User = getModelSafely('User') || require('~/server/models/user')();
 

+ 3 - 3
packages/app/src/migrations/20180927102719-init-serverurl.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
+import { getMongoUri, mongoOptions } from '@growi/core';
 import Config from '~/server/models/config';
-import config from '^/config/migrate';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:init-serverurl');
@@ -20,7 +20,7 @@ module.exports = {
 
   async up(db) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     // find 'app:siteUrl'
     const siteUrlConfig = await Config.findOne({
@@ -77,7 +77,7 @@ module.exports = {
 
   async down(db) {
     logger.info('Rollback migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     // remote 'app:siteUrl'
     await Config.findOneAndDelete({

+ 3 - 4
packages/app/src/migrations/20181019114028-abolish-page-group-relation.js

@@ -1,8 +1,7 @@
 import mongoose from 'mongoose';
 
-import config from '^/config/migrate';
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
-import { getModelSafely } from '~/server/util/mongoose-utils';
 
 const logger = loggerFactory('growi:migrate:abolish-page-group-relation');
 
@@ -29,7 +28,7 @@ module.exports = {
 
   async up(db) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const isPagegrouprelationsExists = await isCollectionExists(db, 'pagegrouprelations');
     if (!isPagegrouprelationsExists) {
@@ -73,7 +72,7 @@ module.exports = {
 
   async down(db) {
     logger.info('Rollback migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const Page = getModelSafely('Page') || require('~/server/models/page')();
     const UserGroup = getModelSafely('UserGroup') || require('~/server/models/user-group')();

+ 2 - 2
packages/app/src/migrations/20190618055300-abolish-crowi-classic-auth.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
+import { getMongoUri, mongoOptions } from '@growi/core';
 import Config from '~/server/models/config';
-import config from '^/config/migrate';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:abolish-crowi-classic-auth');
@@ -9,7 +9,7 @@ const logger = loggerFactory('growi:migrate:abolish-crowi-classic-auth');
 module.exports = {
   async up(db, next) {
     logger.info('Start migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     // enable passport and delete configs for crowi classic auth
     await Promise.all([

+ 3 - 4
packages/app/src/migrations/20190618104011-add-config-app-installed.js

@@ -1,9 +1,8 @@
 import mongoose from 'mongoose';
 
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
 import Config from '~/server/models/config';
-import config from '^/config/migrate';
 import loggerFactory from '~/utils/logger';
-import { getModelSafely } from '~/server/util/mongoose-utils';
 
 const logger = loggerFactory('growi:migrate:add-config-app-installed');
 
@@ -19,7 +18,7 @@ module.exports = {
 
   async up(db) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const User = getModelSafely('User') || require('~/server/models/user')();
 
@@ -49,7 +48,7 @@ module.exports = {
 
   async down(db) {
     logger.info('Rollback migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     // remote 'app:siteUrl'
     await Config.findOneAndDelete({

+ 2 - 2
packages/app/src/migrations/20190619055421-adjust-page-grant.js

@@ -1,6 +1,6 @@
 import mongoose from 'mongoose';
 
-import config from '^/config/migrate';
+import { getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:adjust-page-grant');
@@ -9,7 +9,7 @@ module.exports = {
 
   async up(db) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const Page = require('~/server/models/page')();
 

+ 2 - 2
packages/app/src/migrations/20190624110950-fill-last-update-user.js

@@ -1,6 +1,6 @@
 import mongoose from 'mongoose';
 
-import config from '^/config/migrate';
+import { getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:abolish-page-group-relation');
@@ -12,7 +12,7 @@ module.exports = {
 
   async up(db) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const Page = require('~/server/models/page')();
 

+ 2 - 2
packages/app/src/migrations/20190629193445-make-root-page-public.js

@@ -1,6 +1,6 @@
 import mongoose from 'mongoose';
 
-import config from '^/config/migrate';
+import { getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:make-root-page-public');
@@ -8,7 +8,7 @@ const logger = loggerFactory('growi:migrate:make-root-page-public');
 module.exports = {
   async up(db) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const Page = require('~/server/models/page')();
 

+ 2 - 2
packages/app/src/migrations/20191102223900-drop-configs-indices.js

@@ -1,6 +1,6 @@
 import mongoose from 'mongoose';
 
-import config from '^/config/migrate';
+import { getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:drop-configs-indices');
@@ -14,7 +14,7 @@ async function dropIndexIfExists(collection, indexName) {
 module.exports = {
   async up(db) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const collection = db.collection('configs');
     await dropIndexIfExists(collection, 'ns_1');

+ 2 - 2
packages/app/src/migrations/20191102223901-drop-pages-indices.js

@@ -1,6 +1,6 @@
 import mongoose from 'mongoose';
 
-import config from '^/config/migrate';
+import { getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:drop-pages-indices');
@@ -21,7 +21,7 @@ async function dropIndexIfExists(db, collectionName, indexName) {
 module.exports = {
   async up(db) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     await dropIndexIfExists(db, 'pages', 'lastUpdateUser_1');
     await dropIndexIfExists(db, 'pages', 'liker_1');

+ 3 - 3
packages/app/src/migrations/20191126173016-adjust-pages-path.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
-import { pathUtils } from '@growi/core';
+import { pathUtils, getMongoUri, mongoOptions } from '@growi/core';
+
 
-import config from '^/config/migrate';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:adjust-pages-path');
@@ -10,7 +10,7 @@ const logger = loggerFactory('growi:migrate:adjust-pages-path');
 module.exports = {
   async up(db) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const Page = require('~/server/models/page')();
 

+ 2 - 2
packages/app/src/migrations/20191127023815-drop-wrong-index-of-page-tag-relation.js

@@ -1,6 +1,6 @@
 import mongoose from 'mongoose';
 
-import config from '^/config/migrate';
+import { getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:drop-wrong-index-of-page-tag-relation');
@@ -21,7 +21,7 @@ async function dropIndexIfExists(db, collectionName, indexName) {
 module.exports = {
   async up(db) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     await dropIndexIfExists(db, 'pagetagrelations', 'page_1_user_1');
 

+ 2 - 3
packages/app/src/migrations/20200402160380-remove-deleteduser-from-relationgroup.js

@@ -1,15 +1,14 @@
 import mongoose from 'mongoose';
 
-import config from '^/config/migrate';
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
-import { getModelSafely } from '~/server/util/mongoose-utils';
 
 const logger = loggerFactory('growi:migrate:remove-deleteduser-from-relationgroup');
 
 module.exports = {
   async up(db) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const User = getModelSafely('User') || require('~/server/models/user')();
     const UserGroupRelation = getModelSafely('UserGroupRelation') || require('~/server/models/user-group-relation')();

+ 2 - 2
packages/app/src/migrations/20200420160390-remove-crowi-layout.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
+import { getMongoUri, mongoOptions } from '@growi/core';
 import Config from '~/server/models/config';
-import config from '^/config/migrate';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:remove-crowi-lauout');
@@ -9,7 +9,7 @@ const logger = loggerFactory('growi:migrate:remove-crowi-lauout');
 module.exports = {
   async up(db) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const query = { key: 'customize:layout', value: JSON.stringify('crowi') };
 

+ 3 - 3
packages/app/src/migrations/20200512005851-remove-behavior-type.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
+import { getMongoUri, mongoOptions } from '@growi/core';
 import Config from '~/server/models/config';
-import config from '^/config/migrate';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:remove-behavior-type');
@@ -9,7 +9,7 @@ const logger = loggerFactory('growi:migrate:remove-behavior-type');
 module.exports = {
   async up(db, client) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     await Config.findOneAndDelete({ key: 'customize:behavior' }); // remove behavior
 
@@ -19,7 +19,7 @@ module.exports = {
   async down(db, client) {
     // do not rollback
     logger.info('Rollback migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const insertConfig = new Config({
       ns: 'crowi',

+ 2 - 2
packages/app/src/migrations/20200514001356-update-theme-color-for-dark.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
+import { getMongoUri, mongoOptions } from '@growi/core';
 import Config from '~/server/models/config';
-import config from '^/config/migrate';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:update-theme-color-for-dark');
@@ -9,7 +9,7 @@ const logger = loggerFactory('growi:migrate:update-theme-color-for-dark');
 module.exports = {
   async up(db, client) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     await Promise.all([
       await Config.findOneAndUpdate({ key: 'customize:theme', value: JSON.stringify('default-dark') }, { value: JSON.stringify('default') }), // update default-dark

+ 2 - 3
packages/app/src/migrations/20200620203632-normalize-locale-id.js

@@ -1,16 +1,15 @@
 import mongoose from 'mongoose';
 
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
 import Config from '~/server/models/config';
-import config from '^/config/migrate';
 import loggerFactory from '~/utils/logger';
-import { getModelSafely } from '~/server/util/mongoose-utils';
 
 const logger = loggerFactory('growi:migrate:normalize-locale-id');
 
 module.exports = {
   async up(db, client) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const User = getModelSafely('User') || require('~/server/models/user')();
 

+ 3 - 3
packages/app/src/migrations/20200827045151-remove-layout-setting.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
+import { getMongoUri, mongoOptions } from '@growi/core';
 import Config from '~/server/models/config';
-import config from '^/config/migrate';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:remove-layout-setting');
@@ -9,7 +9,7 @@ const logger = loggerFactory('growi:migrate:remove-layout-setting');
 module.exports = {
   async up(db, client) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const layoutType = await Config.findOne({ key: 'customize:layout' });
 
@@ -38,7 +38,7 @@ module.exports = {
 
   async down(db, client) {
     logger.info('Rollback migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const theme = await Config.findOne({ key: 'customize:theme' });
     const insertLayoutType = (theme.value === '"kibela"') ? 'kibela' : 'growi';

+ 3 - 3
packages/app/src/migrations/20200828024025-copy-aws-setting.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
+import { getMongoUri, mongoOptions } from '@growi/core';
 import Config from '~/server/models/config';
-import config from '^/config/migrate';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:remove-layout-setting');
@@ -9,7 +9,7 @@ const logger = loggerFactory('growi:migrate:remove-layout-setting');
 module.exports = {
   async up(db, client) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const [accessKeyId, secretAccessKey] = await Promise.all([
       Config.findOne({ key: 'aws:accessKeyId' }),
@@ -55,7 +55,7 @@ module.exports = {
 
   async down(db, client) {
     logger.info('Rollback migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     await Config.deleteMany({ key: { $in: ['mail:sesAccessKeyId', 'mail:sesSecretAccessKey'] } });
 

+ 3 - 3
packages/app/src/migrations/20200901034313-update-mail-transmission.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
+import { getMongoUri, mongoOptions } from '@growi/core';
 import Config from '~/server/models/config';
-import config from '^/config/migrate';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:update-mail-transmission');
@@ -9,7 +9,7 @@ const logger = loggerFactory('growi:migrate:update-mail-transmission');
 module.exports = {
   async up(db, client) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const sesExist = await Config.findOne({
       ns: 'crowi',
@@ -33,7 +33,7 @@ module.exports = {
 
   async down(db, client) {
     logger.info('Rollback migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     // remote 'mail:transmissionMethod'
     await Config.findOneAndDelete({

+ 2 - 2
packages/app/src/migrations/20200901034314-update-mail-transmission-fix.js

@@ -1,3 +1,4 @@
+import { getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 import Config from '~/server/models/config';
@@ -5,12 +6,11 @@ import Config from '~/server/models/config';
 const logger = loggerFactory('growi:migrate:update-mail-transmission-fix');
 
 const mongoose = require('mongoose');
-const config = require('^/config/migrate');
 
 module.exports = {
   async up(db, client) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const transmissionMethod = await Config.findOne({
       ns: 'crowi',

+ 3 - 3
packages/app/src/migrations/20200903080025-remove-timeline-type.js.js

@@ -1,3 +1,4 @@
+import { getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 import Config from '~/server/models/config';
@@ -5,12 +6,11 @@ import Config from '~/server/models/config';
 const logger = loggerFactory('growi:migrate:remove-timeline-type');
 
 const mongoose = require('mongoose');
-const config = require('^/config/migrate');
 
 module.exports = {
   async up(db, client) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     await Config.findOneAndDelete({ key: 'customize:isEnabledTimeline' }); // remove timeline
 
@@ -20,7 +20,7 @@ module.exports = {
   async down(db, client) {
     // do not rollback
     logger.info('Rollback migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const insertConfig = new Config({
       ns: 'crowi',

+ 3 - 3
packages/app/src/migrations/20200915035234-rename-s3-config.js

@@ -1,3 +1,4 @@
+import { getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 import Config from '~/server/models/config';
@@ -5,7 +6,6 @@ import Config from '~/server/models/config';
 const logger = loggerFactory('growi:migrate:remove-timeline-type');
 
 const mongoose = require('mongoose');
-const config = require('^/config/migrate');
 
 const awsConfigs = [
   {
@@ -33,7 +33,7 @@ const awsConfigs = [
 module.exports = {
   async up(db, client) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const request = awsConfigs.map((awsConfig) => {
       return {
@@ -52,7 +52,7 @@ module.exports = {
   async down(db, client) {
     logger.info('Rollback migration');
 
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const request = awsConfigs.map((awsConfig) => {
       return {

+ 2 - 3
packages/app/src/migrations/20210420160380-convert-double-to-date.js

@@ -1,7 +1,6 @@
 import mongoose from 'mongoose';
 
-import config from '^/config/migrate';
-import { getModelSafely } from '~/server/util/mongoose-utils';
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:remove-crowi-lauout');
@@ -9,7 +8,7 @@ const logger = loggerFactory('growi:migrate:remove-crowi-lauout');
 module.exports = {
   async up(db) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     const Page = getModelSafely('Page') || require('~/server/models/page')();
 

+ 35 - 44
packages/app/src/migrations/20210830074539-update-configs-for-slackbot.js

@@ -1,64 +1,55 @@
 import mongoose from 'mongoose';
 
+import { getMongoUri, mongoOptions } from '@growi/core';
 import Config from '~/server/models/config';
-import config from '^/config/migrate';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:update-configs-for-slackbot');
 
+// key: oldKey, value: newKey
+const keyMap = {
+  'slackbot:proxyServerUri': 'slackbot:proxyUri',
+  'slackbot:token': 'slackbot:withoutProxy:botToken',
+  'slackbot:signingSecret': 'slackbot:withoutProxy:signingSecret',
+};
+
 module.exports = {
   async up(db) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
-    await Config.bulkWrite([
-      {
-        updateOne: {
-          filter: { key: 'slackbot:proxyServerUri' },
-          update: { key: 'slackbot:proxyUri' },
-        },
-      },
-      {
-        updateOne: {
-          filter: { key: 'slackbot:token' },
-          update: { key: 'slackbot:withoutProxy:botToken' },
-        },
-      },
-      {
-        updateOne: {
-          filter: { key: 'slackbot:signingSecret' },
-          update: { key: 'slackbot:withoutProxy:signingSecret' },
-        },
-      },
-    ]);
+    for await (const [oldKey, newKey] of Object.entries(keyMap)) {
+      const isExist = (await Config.count({ key: newKey })) > 0;
+
+      // remove old key
+      if (isExist) {
+        await Config.findOneAndRemove({ key: oldKey });
+      }
+      // update with new key
+      else {
+        await Config.findOneAndUpdate({ key: oldKey }, { key: newKey });
+      }
+    }
 
     logger.info('Migration has successfully applied');
   },
 
   async down(db) {
     logger.info('Rollback migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
-
-    await Config.bulkWrite([
-      {
-        updateOne: {
-          filter: { key: 'slackbot:proxyUri' },
-          update: { key: 'slackbot:proxyServerUri' },
-        },
-      },
-      {
-        updateOne: {
-          filter: { key: 'slackbot:withoutProxy:botToken' },
-          update: { key: 'slackbot:token' },
-        },
-      },
-      {
-        updateOne: {
-          filter: { key: 'slackbot:withoutProxy:signingSecret' },
-          update: { key: 'slackbot:signingSecret' },
-        },
-      },
-    ]);
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    for await (const [oldKey, newKey] of Object.entries(keyMap)) {
+      const isExist = (await Config.count({ key: oldKey })) > 0;
+
+      // remove new key
+      if (isExist) {
+        await Config.findOneAndRemove({ key: newKey });
+      }
+      // update with old key
+      else {
+        await Config.findOneAndUpdate({ key: newKey }, { key: oldKey });
+      }
+    }
 
     logger.info('Migration has successfully applied');
   },

+ 3 - 4
packages/app/src/migrations/20210906194521-slack-app-integration-set-default-value.js

@@ -1,8 +1,7 @@
 import mongoose from 'mongoose';
 
 import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
-import { getModelSafely } from '~/server/util/mongoose-utils';
-import config from '^/config/migrate';
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:slack-app-integration-set-default-value');
@@ -10,7 +9,7 @@ const logger = loggerFactory('growi:migrate:slack-app-integration-set-default-va
 module.exports = {
   async up(db) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    mongoose.connect(getMongoUri(), mongoOptions);
 
     // Add columns + set all default commands if supportedCommandsForBroadcastUse column does not exist
     const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
@@ -18,7 +17,7 @@ module.exports = {
     // Add togetter command if supportedCommandsForBroadcastUse already exists
     const slackAppIntegrations = await SlackAppIntegration.find();
     slackAppIntegrations.forEach(async(doc) => {
-      if (!doc.supportedCommandsForSingleUse.includes('togetter')) {
+      if (doc.supportedCommandsForSingleUse != null && !doc.supportedCommandsForSingleUse.includes('togetter')) {
         doc.supportedCommandsForSingleUse.push('togetter');
       }
       await doc.save();

+ 57 - 43
packages/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js

@@ -1,51 +1,66 @@
 import mongoose from 'mongoose';
 import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
 
-import config from '^/config/migrate';
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
-import { getModelSafely } from '~/server/util/mongoose-utils';
 
 
 const logger = loggerFactory('growi:migrate:update-configs-for-slackbot');
 
+// create default data
+const defaultDataForBroadcastUse = {};
+defaultSupportedCommandsNameForBroadcastUse.forEach((commandName) => {
+  defaultDataForBroadcastUse[commandName] = false;
+});
+const defaultDataForSingleUse = {};
+defaultSupportedCommandsNameForSingleUse.forEach((commandName) => {
+  defaultDataForSingleUse[commandName] = false;
+});
+
 module.exports = {
   async up(db) {
     logger.info('Apply migration');
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    await mongoose.connect(getMongoUri(), mongoOptions);
 
     const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
 
     const slackAppIntegrations = await SlackAppIntegration.find();
 
-    // create default data
-    const defaultDataForBroadcastUse = {};
-    defaultSupportedCommandsNameForBroadcastUse.forEach((commandName) => {
-      defaultDataForBroadcastUse[commandName] = false;
-    });
-    const defaultDataForSingleUse = {};
-    defaultSupportedCommandsNameForSingleUse.forEach((commandName) => {
-      defaultDataForSingleUse[commandName] = false;
-    });
+    if (slackAppIntegrations.length === 0) return;
 
     // create operations
     const operations = slackAppIntegrations.map((doc) => {
-      const copyForBroadcastUse = { ...defaultDataForBroadcastUse };
-      const copyForSingleUse = { ...defaultDataForSingleUse };
-      // when the document does NOT have supportedCommandsFor... columns
-      if (doc._doc.supportedCommandsForBroadcastUse == null) {
-        defaultSupportedCommandsNameForBroadcastUse.forEach((commandName) => {
+      let copyForBroadcastUse = { ...defaultDataForBroadcastUse };
+      let copyForSingleUse = { ...defaultDataForSingleUse };
+      // when the document already has permissionsFor... colums
+      if (doc._doc.permissionsForBroadcastUseCommands != null) {
+        // merge
+        copyForBroadcastUse = {
+          ...defaultDataForBroadcastUse,
+          ...Object.fromEntries(doc._doc.permissionsForBroadcastUseCommands),
+        };
+        copyForSingleUse = {
+          ...defaultDataForSingleUse,
+          ...Object.fromEntries(doc._doc.permissionsForSingleUseCommands),
+        };
+      }
+      // when the document has supportedCommandsFor... columns
+      else if (doc._doc.supportedCommandsForBroadcastUse != null) {
+        // merge
+        doc._doc.supportedCommandsForBroadcastUse.forEach((commandName) => {
           copyForBroadcastUse[commandName] = true;
         });
-        defaultSupportedCommandsNameForSingleUse.forEach((commandName) => {
+        doc._doc.supportedCommandsForSingleUse.forEach((commandName) => {
           copyForSingleUse[commandName] = true;
         });
       }
-      // // when the document has supportedCommandsFor... columns
+      // when the document does NOT have supportedCommandsFor... columns
       else {
-        doc._doc.supportedCommandsForBroadcastUse.forEach((commandName) => {
+        // turn on all
+        defaultSupportedCommandsNameForBroadcastUse.forEach((commandName) => {
           copyForBroadcastUse[commandName] = true;
         });
-        doc._doc.supportedCommandsForSingleUse.forEach((commandName) => {
+        defaultSupportedCommandsNameForSingleUse.forEach((commandName) => {
           copyForSingleUse[commandName] = true;
         });
       }
@@ -53,35 +68,35 @@ module.exports = {
       return {
         updateOne: {
           filter: { _id: doc._id },
-          update: [
-            {
-              $set: {
-                permissionsForBroadcastUseCommands: copyForBroadcastUse,
-                permissionsForSingleUseCommands: copyForSingleUse,
-              },
+          update: {
+            $set: {
+              permissionsForBroadcastUseCommands: copyForBroadcastUse,
+              permissionsForSingleUseCommands: copyForSingleUse,
             },
-            {
-              $unset: ['supportedCommandsForBroadcastUse', 'supportedCommandsForSingleUse'],
+            $unset: {
+              supportedCommandsForBroadcastUse: '',
+              supportedCommandsForSingleUse: '',
             },
-          ],
+          },
         },
       };
     });
 
-    await SlackAppIntegration.bulkWrite(operations);
+    await db.collection('slackappintegrations').bulkWrite(operations);
 
     logger.info('Migration has successfully applied');
   },
 
   async down(db, next) {
     logger.info('Rollback migration');
-    // return next();
-    mongoose.connect(config.mongoUri, config.mongodb.options);
+    await mongoose.connect(getMongoUri(), mongoOptions);
 
     const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
 
     const slackAppIntegrations = await SlackAppIntegration.find();
 
+    if (slackAppIntegrations.length === 0) return next();
+
     // create operations
     const operations = slackAppIntegrations.map((doc) => {
       const dataForBroadcastUse = [];
@@ -100,22 +115,21 @@ module.exports = {
       return {
         updateOne: {
           filter: { _id: doc._id },
-          update: [
-            {
-              $set: {
-                supportedCommandsForBroadcastUse: dataForBroadcastUse,
-                supportedCommandsForSingleUse: dataForSingleUse,
-              },
+          update: {
+            $set: {
+              supportedCommandsForBroadcastUse: dataForBroadcastUse,
+              supportedCommandsForSingleUse: dataForSingleUse,
             },
-            {
-              $unset: ['permissionsForBroadcastUseCommands', 'permissionsForSingleUseCommands'],
+            $unset: {
+              permissionsForBroadcastUseCommands: '',
+              permissionsForSingleUseCommands: '',
             },
-          ],
+          },
         },
       };
     });
 
-    await SlackAppIntegration.bulkWrite(operations);
+    await db.collection('slackappintegrations').bulkWrite(operations);
 
     next();
     logger.info('Migration has successfully applied');

+ 1 - 1
packages/app/src/server/console.js

@@ -2,7 +2,7 @@ const repl = require('repl');
 const fs = require('fs');
 const path = require('path');
 const mongoose = require('mongoose');
-const { initMongooseGlobalSettings, getMongoUri, mongoOptions } = require('~/server/util/mongoose-utils');
+const { initMongooseGlobalSettings, getMongoUri, mongoOptions } = require('@growi/core');
 
 const models = require('./models');
 

+ 28 - 5
packages/app/src/server/crowi/index.js

@@ -1,15 +1,18 @@
 /* eslint-disable @typescript-eslint/no-this-alias */
 
 import path from 'path';
+import http from 'http';
 import mongoose from 'mongoose';
 
+import { createTerminus } from '@godaddy/terminus';
+
+import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '@growi/core';
 import pkg from '^/package.json';
 
 import CdnResourcesService from '~/services/cdn-resources-service';
 import InterceptorManager from '~/services/interceptor-manager';
 import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
-import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { projectRoot } from '~/utils/project-dir-utils';
 
 import ConfigManager from '../service/config-manager';
@@ -409,10 +412,17 @@ Crowi.prototype.start = async function() {
   this.pluginService = new PluginService(this, express);
   await this.pluginService.autoDetectAndLoadPlugins();
 
-  const server = (this.node_env === 'development') ? this.crowiDev.setupServer(express) : express;
+  const app = (this.node_env === 'development') ? this.crowiDev.setupServer(express) : express;
+
+  const httpServer = http.createServer(app);
+
+  // setup terminus
+  this.setupTerminus(httpServer);
+  // attach to socket.io
+  this.socketIoService.attachServer(httpServer);
 
   // listen
-  const serverListening = server.listen(this.port, () => {
+  const serverListening = httpServer.listen(this.port, () => {
     logger.info(`[${this.node_env}] Express server is listening on port ${this.port}`);
     if (this.node_env === 'development') {
       this.crowiDev.setupExpressAfterListening(express);
@@ -428,8 +438,6 @@ Crowi.prototype.start = async function() {
     });
   }
 
-  this.socketIoService.attachServer(serverListening);
-
   // setup Express Routes
   this.setupRoutesAtLast();
 
@@ -463,6 +471,21 @@ Crowi.prototype.buildServer = async function() {
   this.express = express;
 };
 
+Crowi.prototype.setupTerminus = function(server) {
+  createTerminus(server, {
+    signals: ['SIGINT', 'SIGTERM'],
+    onSignal: async() => {
+      logger.info('Server is starting cleanup');
+
+      await mongoose.disconnect();
+      return;
+    },
+    onShutdown: async() => {
+      logger.info('Cleanup finished, server is shutting down');
+    },
+  });
+};
+
 /**
  * setup Express Routes
  * !! this must be at last because it includes '/*' route !!

+ 1 - 1
packages/app/src/server/models/config.ts

@@ -1,7 +1,7 @@
 import { Types, Schema } from 'mongoose';
 import uniqueValidator from 'mongoose-unique-validator';
 
-import { getOrCreateModel } from '../util/mongoose-utils';
+import { getOrCreateModel } from '@growi/core';
 
 export interface Config {
   _id: Types.ObjectId;

+ 1 - 1
packages/app/src/server/models/editor-settings.ts

@@ -1,7 +1,7 @@
 import {
   Schema, Model, Document,
 } from 'mongoose';
-import { getOrCreateModel } from '../util/mongoose-utils';
+import { getOrCreateModel } from '@growi/core';
 
 
 export interface ILintRule {

+ 1 - 1
packages/app/src/server/models/password-reset-order.ts

@@ -4,7 +4,7 @@ import mongoose, {
 
 import uniqueValidator from 'mongoose-unique-validator';
 import crypto from 'crypto';
-import { getOrCreateModel } from '../util/mongoose-utils';
+import { getOrCreateModel } from '@growi/core';
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 

+ 1 - 1
packages/app/src/server/models/update-post.ts

@@ -3,7 +3,7 @@
 import {
   Types, Schema, Model, Document,
 } from 'mongoose';
-import { getOrCreateModel } from '../util/mongoose-utils';
+import { getOrCreateModel } from '@growi/core';
 
 export interface IUpdatePost {
   pathPattern: string

+ 37 - 0
packages/app/src/server/models/vo/slack-command-handler-error.ts

@@ -0,0 +1,37 @@
+import ExtensibleCustomError from 'extensible-custom-error';
+
+import { RespondBodyForResponseUrl, markdownSectionBlock } from '@growi/slack';
+
+export const generateDefaultRespondBodyForInternalServerError = (message: string): RespondBodyForResponseUrl => {
+  return {
+    text: message,
+    blocks: [
+      markdownSectionBlock(`*An error occured*\n ${message}`),
+    ],
+  };
+};
+
+type Opts = {
+  responseUrl?: string,
+  respondBody?: RespondBodyForResponseUrl,
+}
+
+/**
+ * Error class for slackbot service
+ */
+export class SlackCommandHandlerError extends ExtensibleCustomError {
+
+  responseUrl?: string;
+
+  respondBody: RespondBodyForResponseUrl;
+
+  constructor(
+      message: string,
+      opts: Opts = {},
+  ) {
+    super(message);
+    this.responseUrl = opts.responseUrl;
+    this.respondBody = opts.respondBody || generateDefaultRespondBodyForInternalServerError(message);
+  }
+
+}

+ 0 - 22
packages/app/src/server/models/vo/slackbot-error.js

@@ -1,22 +0,0 @@
-/**
- * Error class for slackbot service
- */
-class SlackbotError extends Error {
-
-  constructor({
-    method, to, popupMessage, mainMessage,
-  } = {}) {
-    super();
-    this.method = method;
-    this.to = to;
-    this.popupMessage = popupMessage;
-    this.mainMessage = mainMessage;
-  }
-
-  static isSlackbotError(obj) {
-    return obj instanceof this;
-  }
-
-}
-
-module.exports = SlackbotError;

+ 5 - 3
packages/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -164,7 +164,7 @@ module.exports = (crowi) => {
    */
   router.get('/', accessTokenParser, loginRequiredStrictly, adminRequired, async(req, res) => {
 
-    const { configManager } = crowi;
+    const { configManager, slackIntegrationService } = crowi;
     const currentBotType = configManager.getConfig('crowi', 'slackbot:currentBotType');
 
     // retrieve settings
@@ -177,8 +177,7 @@ module.exports = (crowi) => {
       settings.commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
     }
     else {
-      settings.proxyServerUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyUri');
-      settings.proxyUriEnvVars = configManager.getConfigFromEnvVars('crowi', 'slackbot:proxyUri');
+      settings.proxyServerUri = slackIntegrationService.proxyUriForCurrentType;
     }
 
     // retrieve connection statuses
@@ -435,6 +434,8 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(msg, 'create-slackAppIntegeration-failed'), 500);
     }
 
+    const count = await SlackAppIntegration.count();
+
     const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
     try {
       const initialSupportedCommandsForBroadcastUse = new Map();
@@ -452,6 +453,7 @@ module.exports = (crowi) => {
         tokenPtoG,
         permissionsForBroadcastUseCommands: initialSupportedCommandsForBroadcastUse,
         permissionsForSingleUseCommands: initialSupportedCommandsForSingleUse,
+        isPrimary: count === 0,
       });
       return res.apiv3(slackAppTokens, 200);
     }

+ 101 - 59
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -1,15 +1,18 @@
+import { markdownSectionBlock, InvalidGrowiCommandError } from '@growi/slack';
 import loggerFactory from '~/utils/logger';
 
 const express = require('express');
 const mongoose = require('mongoose');
 const urljoin = require('url-join');
 
-const { verifySlackRequest, parseSlashCommand } = require('@growi/slack');
+const {
+  verifySlackRequest, parseSlashCommand, InteractionPayloadAccessor, respond,
+} = require('@growi/slack');
 
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const router = express.Router();
 const SlackAppIntegration = mongoose.model('SlackAppIntegration');
-const { respondIfSlackbotError } = require('../../service/slack-command-handler/respond-if-slackbot-error');
+const { handleError } = require('../../service/slack-command-handler/error-handler');
 const { checkPermission } = require('../../util/slack-integration');
 
 module.exports = (crowi) => {
@@ -56,45 +59,51 @@ module.exports = (crowi) => {
 
   // REFACTORIMG THIS MIDDLEWARE GW-7441
   async function checkCommandsPermission(req, res, next) {
-    if (req.body.text == null) return next(); // when /relation-test
+    let { growiCommand } = req.body;
+
+    // when /relation-test or from proxy
+    if (req.body.text == null && growiCommand == null) return next();
+
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const extractPermissions = await extractPermissionsCommands(tokenPtoG);
+    const fromChannel = req.body.channel_name;
+    const siteUrl = crowi.appService.getSiteUrl();
 
     let commandPermission;
     if (extractPermissions != null) { // with proxy
       const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = extractPermissions;
       commandPermission = Object.fromEntries([...permissionsForBroadcastUseCommands, ...permissionsForSingleUseCommands]);
-    }
-    else { // without proxy
-      commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
+      const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
+      if (isPermitted) return next();
+      return res.status(403).send(`It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`);
     }
 
-    const growiCommand = parseSlashCommand(req.body);
-    const fromChannel = req.body.channel_name;
-    const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
-    if (isPermitted) return next();
+    // without proxy
+    growiCommand = parseSlashCommand(req.body);
+    commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
 
-    // IT IS NOT WORKING. FIX THIS GW-7441
-    return res.status(403).send('It is not allowed to run the command to this GROWI.');
+    const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
+    if (isPermitted) {
+      return next();
+    }
+    // show ephemeral error message if not permitted
+    res.json({
+      response_type: 'ephemeral',
+      text: 'Command forbidden',
+      blocks: [
+        markdownSectionBlock(`It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`),
+      ],
+    });
   }
 
   // REFACTORIMG THIS MIDDLEWARE GW-7441
   async function checkInteractionsPermission(req, res, next) {
-    const payload = JSON.parse(req.body.payload);
-    if (payload == null) return next(); // when /relation-test
-
-    let actionId = '';
-    let callbackId = '';
-    let fromChannel = '';
+    const { interactionPayload, interactionPayloadAccessor } = req;
+    const siteUrl = crowi.appService.getSiteUrl();
 
-    if (payload.actions) { // when request is to /interactions && block_actions
-      actionId = payload.actions[0].action_id;
-      fromChannel = payload.channel.name;
-    }
-    else { // when request is to /interactions && view_submission
-      callbackId = payload.view.callback_id;
-      fromChannel = JSON.parse(payload.view.private_metadata).channelName;
-    }
+    const { actionId, callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
+    const callbacIdkOrActionId = callbackId || actionId;
+    const fromChannel = interactionPayloadAccessor.getChannelName();
 
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const extractPermissions = await extractPermissionsCommands(tokenPtoG);
@@ -102,17 +111,27 @@ module.exports = (crowi) => {
     if (extractPermissions != null) { // with proxy
       const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = extractPermissions;
       commandPermission = Object.fromEntries([...permissionsForBroadcastUseCommands, ...permissionsForSingleUseCommands]);
-    }
-    else { // without proxy
-      commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
+      const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
+      if (isPermitted) return next();
+
+      return res.status(403).send(`This interaction is forbidden on this GROWI: ${siteUrl}`);
     }
 
-    const callbacIdkOrActionId = callbackId || actionId;
-    const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
-    if (isPermitted) return next();
+    // without proxy
+    commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
 
-    // IT IS NOT WORKING FIX. THIS GW-7441
-    return res.status(403).send('It is not allowed to run the command to this GROWI.');
+    const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
+    if (isPermitted) {
+      return next();
+    }
+    // show ephemeral error message if not permitted
+    res.json({
+      response_type: 'ephemeral',
+      text: 'Interaction forbidden',
+      blocks: [
+        markdownSectionBlock(`This interaction is forbidden on this GROWI: ${siteUrl}`),
+      ],
+    });
   }
 
   const addSigningSecretToReq = (req, res, next) => {
@@ -120,10 +139,43 @@ module.exports = (crowi) => {
     return next();
   };
 
+  const parseSlackInteractionRequest = (req, res, next) => {
+    if (req.body.payload == null) {
+      return next(new Error('The payload is not in the request from slack or proxy.'));
+    }
+
+    req.interactionPayload = JSON.parse(req.body.payload);
+    req.interactionPayloadAccessor = new InteractionPayloadAccessor(req.interactionPayload);
+
+    return next();
+  };
+
   async function handleCommands(req, res, client) {
     const { body } = req;
+    let { growiCommand } = body;
 
-    if (body.text == null) {
+    if (growiCommand == null) {
+      try {
+        growiCommand = parseSlashCommand(body);
+      }
+      catch (err) {
+        if (err instanceof InvalidGrowiCommandError) {
+          res.json({
+            blocks: [
+              markdownSectionBlock('*Command type is not specified.*'),
+              markdownSectionBlock('Run `/growi help` to check the commands you can use.'),
+            ],
+          });
+        }
+        logger.error(err.message);
+        return;
+      }
+    }
+
+    const { text } = growiCommand;
+
+
+    if (text == null) {
       return 'No text.';
     }
 
@@ -138,18 +190,17 @@ module.exports = (crowi) => {
       text: 'Processing your request ...',
     });
 
-    const args = body.text.split(' ');
-    const command = args[0];
 
     try {
-      await crowi.slackIntegrationService.handleCommandRequest(command, client, body, args);
+      await crowi.slackIntegrationService.handleCommandRequest(growiCommand, client, body);
     }
     catch (err) {
-      await respondIfSlackbotError(client, body, err);
+      await handleError(err, growiCommand.responseUrl);
     }
 
   }
 
+  // TODO: do investigation and fix if needed GW-7519
   router.post('/commands', addSigningSecretToReq, verifySlackRequest, checkCommandsPermission, async(req, res) => {
     const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
     return handleCommands(req, res, client);
@@ -168,32 +219,22 @@ module.exports = (crowi) => {
     return handleCommands(req, res, client);
   });
 
-  async function handleInteractions(req, res, client) {
+  async function handleInteractionsRequest(req, res, client) {
 
     // Send response immediately to avoid opelation_timeout error
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
     res.send();
 
-    const payload = JSON.parse(req.body.payload);
-    const { type } = payload;
+    const { interactionPayload, interactionPayloadAccessor } = req;
+    const { type } = interactionPayload;
 
     try {
       switch (type) {
         case 'block_actions':
-          try {
-            await crowi.slackIntegrationService.handleBlockActionsRequest(client, payload);
-          }
-          catch (err) {
-            await respondIfSlackbotError(client, req.body, err);
-          }
+          await crowi.slackIntegrationService.handleBlockActionsRequest(client, interactionPayload, interactionPayloadAccessor);
           break;
         case 'view_submission':
-          try {
-            await crowi.slackIntegrationService.handleViewSubmissionRequest(client, payload);
-          }
-          catch (err) {
-            await respondIfSlackbotError(client, req.body, err);
-          }
+          await crowi.slackIntegrationService.handleViewSubmissionRequest(client, interactionPayload, interactionPayloadAccessor);
           break;
         default:
           break;
@@ -201,20 +242,21 @@ module.exports = (crowi) => {
     }
     catch (error) {
       logger.error(error);
+      await handleError(error, interactionPayloadAccessor.getResponseUrl());
     }
-
   }
 
-  router.post('/interactions', addSigningSecretToReq, verifySlackRequest, checkInteractionsPermission, async(req, res) => {
+  // TODO: do investigation and fix if needed GW-7519
+  router.post('/interactions', addSigningSecretToReq, verifySlackRequest, parseSlackInteractionRequest, checkInteractionsPermission, async(req, res) => {
     const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
-    return handleInteractions(req, res, client);
+    return handleInteractionsRequest(req, res, client);
   });
 
-  router.post('/proxied/interactions', verifyAccessTokenFromProxy, checkInteractionsPermission, async(req, res) => {
+  router.post('/proxied/interactions', verifyAccessTokenFromProxy, parseSlackInteractionRequest, checkInteractionsPermission, async(req, res) => {
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
 
-    return handleInteractions(req, res, client);
+    return handleInteractionsRequest(req, res, client);
   });
 
   router.get('/supported-commands', verifyAccessTokenFromProxy, async(req, res) => {

+ 5 - 5
packages/app/src/server/routes/login-passport.js

@@ -467,6 +467,11 @@ module.exports = function(crowi, app) {
       userInfo.name = `${response[attrMapFirstName]} ${response[attrMapLastName]}`.trim();
     }
 
+    // Attribute-based Login Control
+    if (!crowi.passportService.verifySAMLResponseByABLCRule(response)) {
+      return loginFailureHandler(req, res, 'Sign in failure due to insufficient privileges.');
+    }
+
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
       return loginFailureHandler(req, res);
@@ -474,11 +479,6 @@ module.exports = function(crowi, app) {
 
     const user = await externalAccount.getPopulatedUser();
 
-    // Attribute-based Login Control
-    if (!crowi.passportService.verifySAMLResponseByABLCRule(response)) {
-      return loginFailureHandler(req, res, 'Sign in failure due to insufficient privileges.');
-    }
-
     // login
     req.logIn(user, (err) => {
       if (err != null) {

+ 2 - 1
packages/app/src/server/routes/search.js

@@ -151,10 +151,11 @@ module.exports = function(crowi, app) {
       const ids = esResult.data.map((page) => { return page._id });
       const findResult = await Page.findListByPageIds(ids);
 
-      // add tag data to result pages
+      // add tags and snippet data to result pages
       findResult.pages.map((page) => {
         const data = esResult.data.find((data) => { return page.id === data._id });
         page._doc.tags = data._source.tag_names;
+        page._doc.snippet = data._highlight;
         return page;
       });
 

+ 1 - 1
packages/app/src/server/service/search-delegator/elasticsearch.js

@@ -867,7 +867,7 @@ class ElasticsearchDelegator {
     query.body.highlight = {
       fields: {
         '*': {
-          fragment_size: 30,
+          fragment_size: 40,
           fragmenter: 'simple',
         },
       },

+ 19 - 29
packages/app/src/server/service/slack-command-handler/create-page-service.js

@@ -1,10 +1,9 @@
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:service:CreatePageService');
-const { reshapeContentsBody } = require('@growi/slack');
+const { reshapeContentsBody, respond, markdownSectionBlock } = require('@growi/slack');
 const mongoose = require('mongoose');
 const pathUtils = require('growi-commons').pathUtils;
-const SlackbotError = require('../../models/vo/slackbot-error');
 
 class CreatePageService {
 
@@ -12,35 +11,26 @@ class CreatePageService {
     this.crowi = crowi;
   }
 
-  async createPageInGrowi(client, payload, path, channelId, contentsBody) {
+  async createPageInGrowi(interactionPayloadAccessor, path, contentsBody) {
     const Page = this.crowi.model('Page');
     const reshapedContentsBody = reshapeContentsBody(contentsBody);
-    try {
-      // sanitize path
-      const sanitizedPath = this.crowi.xss.process(path);
-      const normalizedPath = pathUtils.normalizePath(sanitizedPath);
-
-      // generate a dummy id because Operation to create a page needs ObjectId
-      const dummyObjectIdOfUser = new mongoose.Types.ObjectId();
-      const page = await Page.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
-
-      // Send a message when page creation is complete
-      const growiUri = this.crowi.appService.getSiteUrl();
-      await client.chat.postEphemeral({
-        channel: channelId,
-        user: payload.user.id,
-        text: `The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`,
-      });
-    }
-    catch (err) {
-      logger.error('Failed to create page in GROWI.', err);
-      throw new SlackbotError({
-        method: 'postMessage',
-        to: 'dm',
-        popupMessage: 'Cannot create new page to existed path.',
-        mainMessage: `Cannot create new page to existed path\n *Contents* :memo:\n ${reshapedContentsBody}`,
-      });
-    }
+
+    // sanitize path
+    const sanitizedPath = this.crowi.xss.process(path);
+    const normalizedPath = pathUtils.normalizePath(sanitizedPath);
+
+    // generate a dummy id because Operation to create a page needs ObjectId
+    const dummyObjectIdOfUser = new mongoose.Types.ObjectId();
+    const page = await Page.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
+
+    // Send a message when page creation is complete
+    const growiUri = this.crowi.appService.getSiteUrl();
+    await respond(interactionPayloadAccessor.getResponseUrl(), {
+      text: 'Page has been created',
+      blocks: [
+        markdownSectionBlock(`The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`),
+      ],
+    });
   }
 
 }

+ 27 - 9
packages/app/src/server/service/slack-command-handler/create.js

@@ -1,6 +1,8 @@
 import loggerFactory from '~/utils/logger';
 
-const { markdownSectionBlock, inputSectionBlock } = require('@growi/slack');
+const {
+  markdownSectionBlock, inputSectionBlock, respond, inputBlock,
+} = require('@growi/slack');
 
 const logger = loggerFactory('growi:service:SlackCommandHandler:create');
 
@@ -9,8 +11,14 @@ module.exports = (crowi) => {
   const createPageService = new CreatePageService(crowi);
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
+  const conversationsSelectElement = {
+    action_id: 'conversation',
+    type: 'conversations_select',
+    response_url_enabled: true,
+    default_to_current_conversation: true,
+  };
 
-  handler.handleCommand = async(client, body) => {
+  handler.handleCommand = async(growiCommand, client, body) => {
     await client.views.open({
       trigger_id: body.trigger_id,
 
@@ -31,6 +39,7 @@ module.exports = (crowi) => {
         },
         blocks: [
           markdownSectionBlock('Create new page.'),
+          inputBlock(conversationsSelectElement, 'conversation', 'Channel name to display in the page to be created'),
           inputSectionBlock('path', 'Path', 'path_input', false, '/path'),
           inputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
         ],
@@ -39,15 +48,24 @@ module.exports = (crowi) => {
     });
   };
 
-  handler.handleBlockActions = async function(client, payload, handlerMethodName) {
-    await this[handlerMethodName](client, payload);
+  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName) {
+    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor);
   };
 
-  handler.createPage = async function(client, payload) {
-    const path = payload.view.state.values.path.path_input.value;
-    const channelId = JSON.parse(payload.view.private_metadata).channelId;
-    const contentsBody = payload.view.state.values.contents.contents_input.value;
-    await createPageService.createPageInGrowi(client, payload, path, channelId, contentsBody);
+  handler.createPage = async function(client, interactionPayload, interactionPayloadAccessor) {
+    const path = interactionPayloadAccessor.getStateValues()?.path.path_input.value;
+    const privateMetadata = interactionPayloadAccessor.getViewPrivateMetaData();
+    if (privateMetadata == null) {
+      await respond(interactionPayloadAccessor.getResponseUrl(), {
+        text: 'Error occurred',
+        blocks: [
+          markdownSectionBlock('Failed to create a page.'),
+        ],
+      });
+      return;
+    }
+    const contentsBody = interactionPayloadAccessor.getStateValues()?.contents.contents_input.value;
+    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody);
   };
 
   return handler;

+ 69 - 0
packages/app/src/server/service/slack-command-handler/error-handler.ts

@@ -0,0 +1,69 @@
+import assert from 'assert';
+import { ChatPostEphemeralResponse, WebClient } from '@slack/web-api';
+
+import { respond, RespondBodyForResponseUrl, markdownSectionBlock } from '@growi/slack';
+
+
+import { SlackCommandHandlerError } from '../../models/vo/slack-command-handler-error';
+
+function generateRespondBodyForInternalServerError(message): RespondBodyForResponseUrl {
+  return {
+    text: message,
+    blocks: [
+      markdownSectionBlock(`*GROWI Internal Server Error occured.*\n \`${message}\``),
+    ],
+  };
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+async function handleErrorWithWebClient(error: Error, client: WebClient, body: any): Promise<ChatPostEphemeralResponse> {
+
+  const isInteraction = !body.channel_id;
+
+  // this method is expected to use when system couldn't response_url
+  assert(!(error instanceof SlackCommandHandlerError) || error.responseUrl == null);
+
+  const payload = JSON.parse(body.payload);
+
+  const channel = isInteraction ? payload.channel.id : body.channel_id;
+  const user = isInteraction ? payload.user.id : body.user_id;
+
+  return client.chat.postEphemeral({
+    channel,
+    user,
+    ...generateRespondBodyForInternalServerError(error.message),
+  });
+}
+
+
+export async function handleError(error: SlackCommandHandlerError | Error, responseUrl?: string): Promise<void>;
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function handleError(error: Error, client: WebClient, body: any): Promise<ChatPostEphemeralResponse>;
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function handleError(error: SlackCommandHandlerError | Error, ...args: any[]): Promise<void|ChatPostEphemeralResponse> {
+
+  // handle a SlackCommandHandlerError
+  if (error instanceof SlackCommandHandlerError) {
+    const responseUrl = args[0] || error.responseUrl;
+
+    assert(responseUrl != null, 'Specify responseUrl.');
+
+    return respond(responseUrl, error.respondBody);
+  }
+
+  const secondArg = args[0];
+  assert(secondArg != null, 'Couldn\'t handle Error without the second argument.');
+
+  // handle a normal Error with response_url
+  if (typeof secondArg === 'string') {
+    const respondBody = generateRespondBodyForInternalServerError(error.message);
+    return respond(secondArg, respondBody);
+  }
+
+  assert(args[0] instanceof WebClient);
+
+  // handle with WebClient
+  return handleErrorWithWebClient(error, args[0], args[1]);
+}

+ 3 - 5
packages/app/src/server/service/slack-command-handler/help.js

@@ -1,10 +1,10 @@
-const { markdownSectionBlock } = require('@growi/slack');
+const { markdownSectionBlock, respond } = require('@growi/slack');
 
 module.exports = () => {
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
 
-  handler.handleCommand = (client, body) => {
+  handler.handleCommand = (growiCommand, client, body) => {
     // adjust spacing
     let message = '*Help*\n\n';
     message += 'Usage:     `/growi [command] [args]`\n\n';
@@ -12,9 +12,7 @@ module.exports = () => {
     message += '`/growi create`                          Create new page\n\n';
     message += '`/growi search [keyword]`       Search pages\n\n';
     message += '`/growi togetter`                      Create new page with existing slack conversations (Alpha)\n\n';
-    client.chat.postEphemeral({
-      channel: body.channel_id,
-      user: body.user_id,
+    await respond(growiCommand.responseUrl, {
       text: 'Help',
       blocks: [
         markdownSectionBlock(message),

+ 0 - 66
packages/app/src/server/service/slack-command-handler/respond-if-slackbot-error.js

@@ -1,66 +0,0 @@
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:service:SlackCommandHandler:slack-bot-response');
-const { markdownSectionBlock } = require('@growi/slack');
-const SlackbotError = require('../../models/vo/slackbot-error');
-
-async function respondIfSlackbotError(client, body, err) {
-  // check if the request is to /commands OR /interactions
-  const isInteraction = !body.channel_id;
-
-  // throw non-SlackbotError
-  if (!SlackbotError.isSlackbotError(err)) {
-    logger.error(`A non-SlackbotError error occured.\n${err.toString()}`);
-    throw err;
-  }
-
-  // for both postMessage and postEphemeral
-  let toChannel;
-  // for only postEphemeral
-  let toUser;
-  // decide which channel to send to
-  switch (err.to) {
-    case 'dm':
-      toChannel = isInteraction ? JSON.parse(body.payload).user.id : body.user_id;
-      toUser = toChannel;
-      break;
-    case 'channel':
-      toChannel = isInteraction ? JSON.parse(body.payload).channel.id : body.channel_id;
-      toUser = isInteraction ? JSON.parse(body.payload).user.id : body.user_id;
-      break;
-    default:
-      logger.error('The "to" property of SlackbotError must be "dm" or "channel".');
-      break;
-  }
-
-  // argumentObj object to pass to postMessage OR postEphemeral
-  let argumentsObj = {};
-  switch (err.method) {
-    case 'postMessage':
-      argumentsObj = {
-        channel: toChannel,
-        text: err.popupMessage,
-        blocks: [
-          markdownSectionBlock(err.mainMessage),
-        ],
-      };
-      break;
-    case 'postEphemeral':
-      argumentsObj = {
-        channel: toChannel,
-        user: toUser,
-        text: err.popupMessage,
-        blocks: [
-          markdownSectionBlock(err.mainMessage),
-        ],
-      };
-      break;
-    default:
-      logger.error('The "method" property of SlackbotError must be "postMessage" or "postEphemeral".');
-      break;
-  }
-
-  await client.chat[err.method](argumentsObj);
-}
-
-module.exports = { respondIfSlackbotError };

+ 170 - 267
packages/app/src/server/service/slack-command-handler/search.js

@@ -2,212 +2,68 @@ import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 
-const { markdownSectionBlock, divider } = require('@growi/slack');
+const {
+  markdownSectionBlock, divider, respond, respondInChannel, replaceOriginal, deleteOriginal,
+} = require('@growi/slack');
 const { formatDistanceStrict } = require('date-fns');
-const axios = require('axios');
-const SlackbotError = require('../../models/vo/slackbot-error');
 
-const PAGINGLIMIT = 10;
+const PAGINGLIMIT = 7;
+
 
 module.exports = (crowi) => {
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler(crowi);
 
-  handler.handleCommand = async function(client, body, args) {
-    let searchResult;
-    try {
-      searchResult = await this.retrieveSearchResults(client, body, args);
-    }
-    catch (err) {
-      logger.error('Failed to get search results.', err);
-      throw new SlackbotError({
-        method: 'postEphemeral',
-        to: 'channel',
-        popupMessage: 'Failed To Search',
-        mainMessage: '*Failed to search.*\n Hint\n `/growi search [keyword]`',
-      });
-    }
-
-    const appUrl = crowi.appService.getSiteUrl();
-    const appTitle = crowi.appService.getAppTitle();
-
-    const {
-      pages, offset, resultsTotal,
-    } = searchResult;
-
-    if (pages.length === 0) {
-      return;
-    }
-
-    const keywords = this.getKeywords(args);
-
-
-    let searchResultsDesc;
-
-    switch (resultsTotal) {
-      case 1:
-        searchResultsDesc = `*${resultsTotal}* page is found.`;
-        break;
-
-      default:
-        searchResultsDesc = `*${resultsTotal}* pages are found.`;
-        break;
-    }
 
+  function getKeywords(growiCommandArgs) {
+    const keywords = growiCommandArgs.join(' ');
+    return keywords;
+  }
 
-    const contextBlock = {
-      type: 'context',
-      elements: [
-        {
-          type: 'mrkdwn',
-          text: `keyword(s) : *"${keywords}"*  |  Current: ${offset + 1} - ${offset + pages.length}  |  Total ${resultsTotal} pages`,
-        },
-      ],
-    };
+  function appendSpeechBaloon(mrkdwn, commentCount) {
+    return (commentCount != null && commentCount > 0)
+      ? `${mrkdwn}   :speech_balloon: ${commentCount}`
+      : mrkdwn;
+  }
 
-    const now = new Date();
-    const blocks = [
-      markdownSectionBlock(`:mag: <${decodeURI(appUrl)}|*${appTitle}*>\n${searchResultsDesc}`),
-      contextBlock,
-      { type: 'divider' },
-      // create an array by map and extract
-      ...pages.map((page) => {
-        const { path, updatedAt, commentCount } = page;
-        // generate URL
-        const url = new URL(path, appUrl);
-        const { href, pathname } = url;
+  function generateSearchResultPageLinkMrkdwn(appUrl, growiCommandArgs) {
+    const url = new URL('/_search', appUrl);
+    url.searchParams.append('q', growiCommandArgs.map(kwd => encodeURIComponent(kwd)).join('+'));
+    return `<${url.href} | Results page>`;
+  }
 
-        return {
-          type: 'section',
-          text: {
-            type: 'mrkdwn',
-            text: `${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`
-              + `\n    Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}`,
-          },
-          accessory: {
-            type: 'button',
-            action_id: 'search:shareSinglePageResult',
-            text: {
-              type: 'plain_text',
-              text: 'Share',
-            },
-            value: JSON.stringify({ page, href, pathname }),
-          },
-        };
-      }),
-      { type: 'divider' },
-      contextBlock,
-    ];
+  function generatePageLinkMrkdwn(pathname, href) {
+    return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
+  }
 
-    // DEFAULT show "Share" button
-    // const actionBlocks = {
-    //   type: 'actions',
-    //   elements: [
-    //     {
-    //       type: 'button',
-    //       text: {
-    //         type: 'plain_text',
-    //         text: 'Share',
-    //       },
-    //       style: 'primary',
-    //       action_id: 'shareSearchResults',
-    //     },
-    //   ],
-    // };
-    const actionBlocks = {
-      type: 'actions',
-      elements: [
-        {
-          type: 'button',
-          text: {
-            type: 'plain_text',
-            text: 'Dismiss',
-          },
-          style: 'danger',
-          action_id: 'search:dismissSearchResults',
-        },
-      ],
-    };
-    // show "Next" button if next page exists
-    if (resultsTotal > offset + PAGINGLIMIT) {
-      actionBlocks.elements.unshift(
-        {
-          type: 'button',
-          text: {
-            type: 'plain_text',
-            text: 'Next',
-          },
-          action_id: 'search:showNextResults',
-          value: JSON.stringify({ offset, body, args }),
-        },
-      );
+  function generateLastUpdateMrkdwn(updatedAt, baseDate) {
+    if (updatedAt != null) {
+      // cast to date
+      const date = new Date(updatedAt);
+      return formatDistanceStrict(date, baseDate);
     }
-    blocks.push(actionBlocks);
-
-    await client.chat.postEphemeral({
-      channel: body.channel_id,
-      user: body.user_id,
-      text: 'Successed To Search',
-      blocks,
-    });
-  };
-
-  handler.handleBlockActions = async function(client, payload, handlerMethodName) {
-    await this[handlerMethodName](client, payload);
-  };
-
-  handler.shareSinglePageResult = async function(client, payload) {
-    const { channel, user, actions } = payload;
-
-    const appUrl = crowi.appService.getSiteUrl();
-    const appTitle = crowi.appService.getAppTitle();
+    return '';
+  }
 
-    const channelId = channel.id;
-    const action = actions[0]; // shareSinglePage action must have button action
+  async function retrieveSearchResults(growiCommandArgs, offset = 0) {
+    const keywords = getKeywords(growiCommandArgs);
 
-    // restore page data from value
-    const { page, href, pathname } = JSON.parse(action.value);
-    const { updatedAt, commentCount } = page;
+    const { searchService } = crowi;
+    const options = { limit: PAGINGLIMIT, offset };
+    const results = await searchService.searchKeyword(keywords, null, {}, options);
+    const resultsTotal = results.meta.total;
 
-    // share
-    const now = new Date();
-    return client.chat.postMessage({
-      channel: channelId,
-      blocks: [
-        { type: 'divider' },
-        markdownSectionBlock(`${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
-        {
-          type: 'context',
-          elements: [
-            {
-              type: 'mrkdwn',
-              text: `<${decodeURI(appUrl)}|*${appTitle}*>  |  Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}  |  Shared by *${user.username}*`,
-            },
-          ],
-        },
-      ],
+    const pages = results.data.map((data) => {
+      const { path, updated_at: updatedAt, comment_count: commentCount } = data._source;
+      return { path, updatedAt, commentCount };
     });
-  };
 
-  handler.showNextResults = async function(client, payload) {
-    const parsedValue = JSON.parse(payload.actions[0].value);
-
-    const { body, args, offset: offsetNum } = parsedValue;
-    const newOffsetNum = offsetNum + 10;
-    let searchResult;
-    try {
-      searchResult = await this.retrieveSearchResults(client, body, args, newOffsetNum);
-    }
-    catch (err) {
-      logger.error('Failed to get search results.', err);
-      throw new SlackbotError({
-        method: 'postEphemeral',
-        to: 'channel',
-        popupMessage: 'Failed To Search',
-        mainMessage: '*Failed to search.*\n Hint\n `/growi search [keyword]`',
-      });
-    }
+    return {
+      pages, offset, resultsTotal,
+    };
+  }
 
+  function buildRespondBodyForSearchResult(searchResult, growiCommandArgs) {
     const appUrl = crowi.appService.getSiteUrl();
     const appTitle = crowi.appService.getAppTitle();
 
@@ -215,28 +71,27 @@ module.exports = (crowi) => {
       pages, offset, resultsTotal,
     } = searchResult;
 
-    const keywords = this.getKeywords(args);
-
+    const keywords = getKeywords(growiCommandArgs);
 
     let searchResultsDesc;
-
     switch (resultsTotal) {
       case 1:
         searchResultsDesc = `*${resultsTotal}* page is found.`;
         break;
-
       default:
         searchResultsDesc = `*${resultsTotal}* pages are found.`;
         break;
     }
 
-
     const contextBlock = {
       type: 'context',
       elements: [
         {
           type: 'mrkdwn',
-          text: `keyword(s) : *"${keywords}"*  |  Current: ${offset + 1} - ${offset + pages.length}  |  Total ${resultsTotal} pages`,
+          text: `keyword(s) : *"${keywords}"*`
+          + `  |  Total ${resultsTotal} pages`
+          + `  |  Current: ${offset + 1} - ${offset + pages.length}`
+          + `  |  ${generateSearchResultPageLinkMrkdwn(appUrl, growiCommandArgs)}`,
         },
       ],
     };
@@ -257,8 +112,8 @@ module.exports = (crowi) => {
           type: 'section',
           text: {
             type: 'mrkdwn',
-            text: `${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`
-              + `\n    Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}`,
+            text: `${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`
+              + `  \`${generateLastUpdateMrkdwn(updatedAt, now)}\``,
           },
           accessory: {
             type: 'button',
@@ -275,21 +130,6 @@ module.exports = (crowi) => {
       contextBlock,
     ];
 
-    // DEFAULT show "Share" button
-    // const actionBlocks = {
-    //   type: 'actions',
-    //   elements: [
-    //     {
-    //       type: 'button',
-    //       text: {
-    //         type: 'plain_text',
-    //         text: 'Share',
-    //       },
-    //       style: 'primary',
-    //       action_id: 'shareSearchResults',
-    //     },
-    //   ],
-    // };
     const actionBlocks = {
       type: 'actions',
       elements: [
@@ -304,65 +144,65 @@ module.exports = (crowi) => {
         },
       ],
     };
+    // show "Prev" button if previous page exists
+    // eslint-disable-next-line yoda
+    if (0 < offset) {
+      actionBlocks.elements.unshift(
+        {
+          type: 'button',
+          text: {
+            type: 'plain_text',
+            text: '< Prev',
+          },
+          action_id: 'search:showPrevResults',
+          value: JSON.stringify({ offset, growiCommandArgs }),
+        },
+      );
+    }
     // show "Next" button if next page exists
-    if (resultsTotal > offset + PAGINGLIMIT) {
+    if (offset + PAGINGLIMIT < resultsTotal) {
       actionBlocks.elements.unshift(
         {
           type: 'button',
           text: {
             type: 'plain_text',
-            text: 'Next',
+            text: 'Next >',
           },
           action_id: 'search:showNextResults',
-          value: JSON.stringify({ offset, body, args }),
+          value: JSON.stringify({ offset, growiCommandArgs }),
         },
       );
     }
     blocks.push(actionBlocks);
 
-    await client.chat.postEphemeral({
-      channel: body.channel_id,
-      user: body.user_id,
+    return {
       text: 'Successed To Search',
       blocks,
-    });
-  };
+    };
+  }
 
-  handler.dismissSearchResults = async function(client, payload) {
-    const { response_url: responseUrl } = payload;
 
-    return axios.post(responseUrl, {
-      delete_original: true,
-    });
-  };
+  async function buildRespondBody(growiCommandArgs) {
+    const firstKeyword = growiCommandArgs[0];
 
-  handler.retrieveSearchResults = async function(client, body, args, offset = 0) {
-    const firstKeyword = args[1];
+    // enpty keyword
     if (firstKeyword == null) {
-      client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
+      return {
         text: 'Input keywords',
         blocks: [
           markdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
         ],
-      });
-      return { pages: [] };
+      };
     }
 
-    const keywords = this.getKeywords(args);
-
-    const { searchService } = crowi;
-    const options = { limit: 10, offset };
-    const results = await searchService.searchKeyword(keywords, null, {}, options);
-    const resultsTotal = results.meta.total;
+    const searchResult = await retrieveSearchResults(growiCommandArgs);
 
     // no search results
-    if (results.data.length === 0) {
+    if (searchResult.resultsTotal === 0) {
+      const keywords = getKeywords(growiCommandArgs);
       logger.info(`No page found with "${keywords}"`);
-      client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
+
+      return {
         text: `No page found with "${keywords}"`,
         blocks: [
           markdownSectionBlock(`*No page matches your keyword(s) "${keywords}".*`),
@@ -382,43 +222,106 @@ module.exports = (crowi) => {
           divider(),
           markdownSectionBlock('`-tag:wiki` \n Exclude pages with wiki tag'),
         ],
-      });
-      return { pages: [] };
+      };
     }
 
-    const pages = results.data.map((data) => {
-      const { path, updated_at: updatedAt, comment_count: commentCount } = data._source;
-      return { path, updatedAt, commentCount };
-    });
+    return buildRespondBodyForSearchResult(searchResult, growiCommandArgs);
+  }
 
-    return {
-      pages, offset, resultsTotal,
-    };
-  };
 
-  handler.getKeywords = function(args) {
-    const keywordsArr = args.slice(1);
-    const keywords = keywordsArr.join(' ');
-    return keywords;
+  handler.handleCommand = async function(growiCommand, client, body) {
+    const { responseUrl, growiCommandArgs } = growiCommand;
+
+    const respondBody = await buildRespondBody(growiCommandArgs);
+    await respond(responseUrl, respondBody);
   };
 
-  handler.appendSpeechBaloon = function(mrkdwn, commentCount) {
-    return (commentCount != null && commentCount > 0)
-      ? `${mrkdwn}   :speech_balloon: ${commentCount}`
-      : mrkdwn;
+  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName) {
+    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor);
   };
 
-  handler.generatePageLinkMrkdwn = function(pathname, href) {
-    return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
+  handler.shareSinglePageResult = async function(client, payload, interactionPayloadAccessor) {
+    const { user } = payload;
+    const responseUrl = interactionPayloadAccessor.getResponseUrl();
+
+    const appUrl = crowi.appService.getSiteUrl();
+    const appTitle = crowi.appService.getAppTitle();
+
+    const value = interactionPayloadAccessor.firstAction()?.value; // shareSinglePage action must have button action
+    if (value == null) {
+      await respond(responseUrl, {
+        text: 'Error occurred',
+        blocks: [
+          markdownSectionBlock('Failed to share the result.'),
+        ],
+      });
+      return;
+    }
+
+    // restore page data from value
+    const { page, href, pathname } = JSON.parse(value);
+    const { updatedAt, commentCount } = page;
+
+    // share
+    const now = new Date();
+    return respondInChannel(responseUrl, {
+      blocks: [
+        { type: 'divider' },
+        markdownSectionBlock(`${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
+        {
+          type: 'context',
+          elements: [
+            {
+              type: 'mrkdwn',
+              text: `<${decodeURI(appUrl)}|*${appTitle}*>`
+                + `  |  Last updated: \`${generateLastUpdateMrkdwn(updatedAt, now)}\``
+                + `  |  Shared by *${user.username}*`,
+            },
+          ],
+        },
+      ],
+    });
   };
 
-  handler.generateLastUpdateMrkdwn = function(updatedAt, baseDate) {
-    if (updatedAt != null) {
-      // cast to date
-      const date = new Date(updatedAt);
-      return formatDistanceStrict(date, baseDate);
+  async function showPrevOrNextResults(interactionPayloadAccessor, isNext = true) {
+    const responseUrl = interactionPayloadAccessor.getResponseUrl();
+
+    const value = interactionPayloadAccessor.firstAction()?.value;
+    if (value == null) {
+      await respond(responseUrl, {
+        text: 'Error occurred',
+        blocks: [
+          markdownSectionBlock('Failed to show the next results.'),
+        ],
+      });
+      return;
     }
-    return '';
+    const parsedValue = JSON.parse(value);
+
+    const { growiCommandArgs, offset: offsetNum } = parsedValue;
+    const newOffsetNum = isNext
+      ? offsetNum + PAGINGLIMIT
+      : offsetNum - PAGINGLIMIT;
+
+    const searchResult = await retrieveSearchResults(growiCommandArgs, newOffsetNum);
+
+    await replaceOriginal(responseUrl, buildRespondBodyForSearchResult(searchResult, growiCommandArgs));
+  }
+
+  handler.showPrevResults = async function(client, payload, interactionPayloadAccessor) {
+    return showPrevOrNextResults(interactionPayloadAccessor, false);
+  };
+
+  handler.showNextResults = async function(client, payload, interactionPayloadAccessor) {
+    return showPrevOrNextResults(interactionPayloadAccessor, true);
+  };
+
+  handler.dismissSearchResults = async function(client, payload) {
+    const { response_url: responseUrl } = payload;
+
+    return deleteOriginal(responseUrl, {
+      delete_original: true,
+    });
   };
 
   return handler;

Некоторые файлы не были показаны из-за большого количества измененных файлов