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

Merge branch 'master' into feat/6982-textlint

Steven Fukase 4 лет назад
Родитель
Сommit
9570239f72
100 измененных файлов с 1575 добавлено и 707 удалено
  1. 1 0
      .devcontainer/devcontainer.json
  2. 0 1
      .devcontainer/docker-compose.yml
  3. 4 0
      .eslintrc.js
  4. 2 2
      .github/workflows/ci-slackbot-proxy.yml
  5. 12 9
      .github/workflows/ci.yml
  6. 1 1
      .github/workflows/release-rc.yml
  7. 2 2
      .github/workflows/release.yml
  8. 4 0
      .markdownlint.yml
  9. 20 0
      .vscode/launch.json
  10. 9 0
      CHANGES.md
  11. 21 3
      package.json
  12. 2 2
      packages/app/.env.development
  13. 1 1
      packages/app/.env.production
  14. 1 0
      packages/app/.eslintignore
  15. 1 1
      packages/app/.gitignore
  16. 22 14
      packages/app/bin/generate-plugin-definitions-source.ts
  17. 2 0
      packages/app/config/ci/.env.local.for-ci
  18. 7 5
      packages/app/config/webpack.common.js
  19. 34 24
      packages/app/docker/Dockerfile
  20. 3 3
      packages/app/docker/docker-entrypoint.sh
  21. 1 0
      packages/app/jest.config.js
  22. 12 20
      packages/app/package.json
  23. 3 7
      packages/app/resource/locales/en_US/admin/admin.json
  24. 3 6
      packages/app/resource/locales/ja_JP/admin/admin.json
  25. 311 313
      packages/app/resource/locales/zh_CN/admin/admin.json
  26. 20 1
      packages/app/src/client/models/Linker.js
  27. 4 2
      packages/app/src/client/plugin.js
  28. 1 1
      packages/app/src/client/services/AdminGitHubSecurityContainer.js
  29. 1 1
      packages/app/src/client/services/AdminGoogleSecurityContainer.js
  30. 1 1
      packages/app/src/client/services/AdminOidcSecurityContainer.js
  31. 1 1
      packages/app/src/client/services/AdminSamlSecurityContainer.js
  32. 1 1
      packages/app/src/client/services/AdminTwitterSecurityContainer.js
  33. 3 1
      packages/app/src/client/services/PageContainer.js
  34. 1 1
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  35. 5 1
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  36. 144 0
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  37. 5 1
      packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  38. 21 1
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  39. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx
  40. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.jsx
  41. 1 1
      packages/app/src/components/Admin/Users/UserTable.jsx
  42. 3 1
      packages/app/src/components/ComparePathsTable.jsx
  43. 3 1
      packages/app/src/components/ContentLinkButtons.jsx
  44. 1 1
      packages/app/src/components/CreateTemplateModal.jsx
  45. 3 1
      packages/app/src/components/DuplicatedPathsTable.jsx
  46. 1 1
      packages/app/src/components/Fab.jsx
  47. 4 2
      packages/app/src/components/Navbar/AuthorInfo.jsx
  48. 1 1
      packages/app/src/components/Navbar/GrowiSubNavigation.jsx
  49. 1 1
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  50. 3 1
      packages/app/src/components/Page/CopyDropdown.jsx
  51. 3 1
      packages/app/src/components/Page/PageManagement.jsx
  52. 1 1
      packages/app/src/components/Page/TrashPageAlert.jsx
  53. 1 1
      packages/app/src/components/PageAttachment/DeleteAttachmentModal.jsx
  54. 1 1
      packages/app/src/components/PageAttachment/PageAttachmentList.jsx
  55. 1 1
      packages/app/src/components/PageComment/Comment.jsx
  56. 1 1
      packages/app/src/components/PageComment/CommentEditor.jsx
  57. 1 1
      packages/app/src/components/PageComment/DeleteCommentModal.jsx
  58. 6 4
      packages/app/src/components/PageCreateModal.jsx
  59. 6 19
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  60. 1 1
      packages/app/src/components/PageHistory/Revision.jsx
  61. 2 3
      packages/app/src/components/PageList/Page.jsx
  62. 0 24
      packages/app/src/components/PageList/PagePath.jsx
  63. 1 1
      packages/app/src/components/PagePathAutoComplete.jsx
  64. 3 1
      packages/app/src/components/RevisionComparer/RevisionComparer.jsx
  65. 2 3
      packages/app/src/components/SearchTypeahead.jsx
  66. 3 3
      packages/app/src/components/ShareLink/ShareLinkForm.jsx
  67. 2 2
      packages/app/src/components/Sidebar/RecentChanges.jsx
  68. 1 1
      packages/app/src/components/User/UserInfo.jsx
  69. 1 1
      packages/app/src/components/User/UserPictureList.jsx
  70. 1 1
      packages/app/src/migrations/20191126173016-adjust-pages-path.js
  71. 3 6
      packages/app/src/models/linked-page-path.js
  72. 6 1
      packages/app/src/server/crowi/express-init.js
  73. 1 1
      packages/app/src/server/crowi/index.js
  74. 3 4
      packages/app/src/server/middlewares/admin-required.js
  75. 5 3
      packages/app/src/server/models/page.js
  76. 2 0
      packages/app/src/server/models/slack-app-integration.js
  77. 22 0
      packages/app/src/server/models/vo/slackbot-error.js
  78. 38 0
      packages/app/src/server/plugins/plugin-utils-v4.ts
  79. 15 7
      packages/app/src/server/plugins/plugin-utils.js
  80. 16 11
      packages/app/src/server/plugins/plugin.service.js
  81. 2 1
      packages/app/src/server/routes/apiv3/admin-home.js
  82. 2 2
      packages/app/src/server/routes/apiv3/healthcheck.js
  83. 3 2
      packages/app/src/server/routes/apiv3/page.js
  84. 7 2
      packages/app/src/server/routes/apiv3/pages.js
  85. 2 2
      packages/app/src/server/routes/apiv3/search.js
  86. 75 11
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  87. 76 63
      packages/app/src/server/routes/apiv3/slack-integration.js
  88. 2 2
      packages/app/src/server/routes/apiv3/statistics.js
  89. 2 1
      packages/app/src/server/routes/page.js
  90. 2 2
      packages/app/src/server/service/app.ts
  91. 1 1
      packages/app/src/server/service/customize.ts
  92. 3 2
      packages/app/src/server/service/export.js
  93. 3 1
      packages/app/src/server/service/global-notification/global-notification-slack.js
  94. 3 1
      packages/app/src/server/service/page.js
  95. 48 0
      packages/app/src/server/service/slack-command-handler/create-page-service.js
  96. 37 38
      packages/app/src/server/service/slack-command-handler/create.js
  97. 66 0
      packages/app/src/server/service/slack-command-handler/respond-if-slackbot-error.js
  98. 199 30
      packages/app/src/server/service/slack-command-handler/search.js
  99. 10 0
      packages/app/src/server/service/slack-command-handler/slack-command-handler.js
  100. 173 2
      packages/app/src/server/service/slack-command-handler/togetter.js

+ 1 - 0
.devcontainer/devcontainer.json

@@ -16,6 +16,7 @@
 	"extensions": [
 		"dbaeumer.vscode-eslint",
 		"eamodio.gitlens",
+    "firsttris.vscode-jest-runner",
 		"msjsdiag.debugger-for-chrome",
 		"firefox-devtools.vscode-firefox-debug",
 		"editorconfig.editorconfig",

+ 0 - 1
.devcontainer/docker-compose.yml

@@ -25,7 +25,6 @@ services:
       - ..:/workspace/growi:delegated
       - node_modules:/workspace/growi/node_modules
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
-      - ../../node_modules:/workspace/node_modules:delegated
 
     tty: true
 

+ 4 - 0
.eslintrc.js

@@ -26,5 +26,9 @@ module.exports = {
         FunctionExpression: { body: 1, parameters: 2 },
       },
     ],
+    'jest/no-standalone-expect': [
+      'error',
+      { additionalTestBlockFunctions: ['each.test'] },
+    ],
   },
 };

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

@@ -120,7 +120,7 @@ jobs:
     - name: yarn dev:ci
       working-directory: ./packages/slackbot-proxy
       run: |
-        cp config/ci/.env.local.for-ci .env.local
+        cp config/ci/.env.local.for-ci .env.development.local
         yarn dev:ci
       env:
         TYPEORM_CONNECTION: mysql
@@ -211,7 +211,7 @@ jobs:
     - name: yarn start:prod:ci
       working-directory: ./packages/slackbot-proxy
       run: |
-        cp config/ci/.env.local.for-ci .env.local
+        cp config/ci/.env.local.for-ci .env.production.local
         yarn start:prod:ci
       env:
         TYPEORM_CONNECTION: mysql

+ 12 - 9
.github/workflows/ci.yml

@@ -51,10 +51,10 @@ jobs:
         yarn list --depth=0
     - name: lerna run lint for plugins
       run: |
-        yarn lerna run lint --scope @growi/plugin-pukiwiki-like-linker
+        yarn lerna run lint --scope @growi/plugin-*
     - name: lerna run lint for app
       run: |
-        yarn lerna run lint --scope @growi/app
+        yarn lerna run lint --scope @growi/app --scope @growi/core --scope @growi/ui
 
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
@@ -130,6 +130,12 @@ jobs:
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb36.ports['27017'] }}/growi_test
 
+    - name: Upload coverage report as artifact
+      uses: actions/upload-artifact@v2
+      with:
+        name: Coverage Report
+        path: packages/app/coverage
+
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
       if: failure()
@@ -206,7 +212,7 @@ jobs:
     - name: yarn dev:ci
       working-directory: ./packages/app
       run: |
-        cp config/ci/.env.local.for-ci .env.local
+        cp config/ci/.env.local.for-ci .env.development.local
         yarn dev:ci
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_dev
@@ -281,7 +287,7 @@ jobs:
         yarn list --depth=0
     - name: Build
       run: |
-        yarn lerna run build --scope @growi/slack --scope @growi/app
+        yarn lerna run build --scope @growi/core --scope @growi/slack --scope @growi/plugin-* --scope @growi/app
     - name: lerna bootstrap --production
       run: |
         npx lerna bootstrap -- --production
@@ -297,20 +303,17 @@ jobs:
     - name: yarn server:ci
       working-directory: ./packages/app
       run: |
+        cp config/ci/.env.local.for-ci .env.production.local
         yarn server:ci
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi-${{ steps.getdbname.outputs.suffix }}
     - name: yarn server:ci with MongoDB 3.6
       working-directory: ./packages/app
       run: |
+        cp config/ci/.env.local.for-ci .env.production.local
         yarn server:ci
       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: Report
-        path: report
 
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master

+ 1 - 1
.github/workflows/release-rc.yml

@@ -55,7 +55,7 @@ jobs:
       uses: docker/build-push-action@v2
       with:
         context: .
-        file: ./docker/Dockerfile
+        file: ./packages/app/docker/Dockerfile
         platforms: linux/amd64
         push: true
         cache-from: type=local,src=/tmp/.buildx-cache

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

@@ -113,7 +113,7 @@ jobs:
       uses: docker/build-push-action@v2
       with:
         context: .
-        file: ./docker/Dockerfile
+        file: ./packages/app/docker/Dockerfile
         platforms: linux/amd64
         push: true
         cache-from: type=local,src=/tmp/.buildx-cache
@@ -131,7 +131,7 @@ jobs:
         username: wsmoogle
         password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
         repository: weseek/growi
-        readme-filepath: ./docker/README.md
+        readme-filepath: ./packages/app/docker/README.md
 
     - name: Slack Notification
       uses: weseek/ghaction-release-slack-notification@master

+ 4 - 0
.markdownlint.yml

@@ -7,3 +7,7 @@ no-multiple-blanks: false
 no-duplicate-heading: false
 no-inline-html: false
 no-trailing-punctuation: false
+
+MD002: false
+MD012: false
+MD041: false

+ 20 - 0
.vscode/launch.json

@@ -44,6 +44,26 @@
         "url": "http://localhost:3000",
         "webRoot": "${workspaceFolder}/packages/app/public",
         "pathMappings": [
+          {
+            "url": "webpack:///core",
+            "path": "${workspaceFolder}/packages/core"
+          },
+          {
+            "url": "webpack:///plugin-attachment-refs",
+            "path": "${workspaceFolder}/packages/plugin-attachment-refs"
+          },
+          {
+            "url": "webpack:///plugin-pukiwiki-like-linker",
+            "path": "${workspaceFolder}/packages/plugin-pukiwiki-like-linker"
+          },
+          {
+            "url": "webpack:///plugin-lsx",
+            "path": "${workspaceFolder}/packages/plugin-lsx"
+          },
+          {
+            "url": "webpack:///ui",
+            "path": "${workspaceFolder}/packages/ui"
+          },
           {
             "url": "webpack:///src",
             "path": "${workspaceFolder}/packages/app/src"

+ 9 - 0
CHANGES.md

@@ -2,10 +2,19 @@
 
 ## v4.3.3-RC
 
+* Fix: Encode spaces in page path in LinkEditModal
+* Fix: Layout is broken when editing users page ([#4128](https://github.com/weseek/growi/issues/4128))
+* Support: Create @growi/core package
+* Support: Create @growi/ui package
+* Support: Improve error handling for @growi/slackbot-proxy
+* Support: Include official plugins as sub packages
 * Support: Upgrade libs
     * @slack/web-api
+    * date-fns
     * escape-string-regexp
+    * helmet
     * morgan
+    * socket.io
 
 ## v4.3.2
 

+ 21 - 3
package.json

@@ -21,8 +21,12 @@
   },
   "private": true,
   "workspaces": {
-    "packages": ["packages/*"],
-    "nohoist": ["**/slackbot-proxy/bootstrap"]
+    "packages": [
+      "packages/*"
+    ],
+    "nohoist": [
+      "**/slackbot-proxy/bootstrap"
+    ]
   },
   "scripts": {
     "start": "yarn app:server",
@@ -37,8 +41,17 @@
     "server:prod": "echo !!! CAUTION !!! ==> The script 'server:prod' is deprecated. Use 'yarn app:build' instead. && yarn app:server"
   },
   "dependencies": {
+    "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",
+    "typescript": "^4.2.3"
   },
   "devDependencies": {
+    "@types/jest": "^26.0.22",
+    "@types/node": "^14.14.35",
+    "@types/rewire": "^2.5.28",
     "@typescript-eslint/eslint-plugin": "^4.28.5",
     "@typescript-eslint/parser": "^4.28.5",
     "eslint": "^7.31.0",
@@ -48,7 +61,12 @@
     "eslint-plugin-jest": "^24.3.2",
     "eslint-plugin-react": "^7.24.0",
     "eslint-plugin-react-hooks": "^4.2.0",
-    "lerna": "^4.0.0"
+    "jest": "^27.0.6",
+    "jest-date-mock": "^1.0.8",
+    "jest-localstorage-mock": "^2.4.14",
+    "lerna": "^4.0.0",
+    "rewire": "^5.0.0",
+    "ts-jest": "^27.0.4"
   },
   "engines": {
     "node": "^12 || ^14",

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

@@ -3,7 +3,7 @@
 ## https://nextjs.org/docs/basic-features/environment-variables
 ##
 FILE_UPLOAD=mongodb
-# MONGO_GRIDFS_TOTAL_LIMIT=10485760 # 10MB
+# MONGO_GRIDFS_TOTAL_LIMIT=10485760
 MATHJAX=1
 # NO_CDN=true
 MONGO_URI="mongodb://mongo:27017/growi"
@@ -17,7 +17,7 @@ HACKMD_URI_FOR_SERVER="http://hackmd:3000"
 # PUBLISH_OPEN_API=true
 # USER_UPPER_LIMIT=0
 # DEV_HTTPS=true
-# FORCE_WIKI_MODE=private # 'public', 'private', undefined
+# FORCE_WIKI_MODE=private
 # PROMSTER_ENABLED=true
 # SLACK_SIGNING_SECRET=''
 # SLACK_BOT_TOKEN=''

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

@@ -2,4 +2,4 @@
 ## Handled by Next.js with dotenv or dotenv-flow
 ## https://nextjs.org/docs/basic-features/environment-variables
 ##
-FORMAT_NODE_LOG=false # default: true
+FORMAT_NODE_LOG=false

+ 1 - 0
packages/app/.eslintignore

@@ -1,3 +1,4 @@
+/dist/**
 /public/**
 /src/client/legacy/thirdparty-js/**
 /src/client/util/reveal/plugins/markdown.js

+ 1 - 1
packages/app/.gitignore

@@ -13,7 +13,7 @@
 
 # dist (for GROWI v4.x and below)
 /public/*.chunk.js
-/public/*.chunk.js.LICENSE
+/public/*.chunk.js.LICENSE.txt
 /public/*.bundle.js
 /public/manifest.json
 /public/dll

+ 22 - 14
packages/app/bin/generate-plugin-definitions-source.ts

@@ -7,6 +7,8 @@ import fs from 'graceful-fs';
 import normalize from 'normalize-path';
 import swig from 'swig-templates';
 
+import { PluginDefinitionV4 } from '@growi/core';
+
 import PluginUtils from '../src/server/plugins/plugin-utils';
 import loggerFactory from '../src/utils/logger';
 import { resolveFromRoot } from '../src/utils/project-dir-utils';
@@ -23,26 +25,32 @@ const OUT = resolveFromRoot('tmp/plugins/plugin-definitions.js');
 const pluginNames: string[] = pluginUtils.listPluginNames();
 logger.info('Detected plugins: ', pluginNames);
 
-// get definitions
-const definitions = pluginNames
-  .map((name) => {
-    return pluginUtils.generatePluginDefinition(name, true);
-  })
-  .map((definition) => {
-    if (definition == null) {
-      return null;
+async function main(): Promise<void> {
+
+  // get definitions
+  const definitions: PluginDefinitionV4[] = [];
+  for (const pluginName of pluginNames) {
+    // eslint-disable-next-line no-await-in-loop
+    const definition = await pluginUtils.generatePluginDefinition(pluginName, true);
+    if (definition != null) {
+      definitions.push(definition);
     }
+  }
 
+  definitions.map((definition) => {
     // convert backslash to slash
     definition.entries = definition.entries.map((entryPath) => {
       return normalize(entryPath);
     });
     return definition;
-  })
-  .filter(definition => definition != null);
+  });
+
+  const compiledTemplate = swig.compileFile(TEMPLATE);
+  const code = compiledTemplate({ definitions });
+
+  // write
+  fs.writeFileSync(OUT, code);
 
-const compiledTemplate = swig.compileFile(TEMPLATE);
-const code = compiledTemplate({ definitions });
+}
 
-// write
-fs.writeFileSync(OUT, code);
+main();

+ 2 - 0
packages/app/config/ci/.env.local.for-ci

@@ -1,2 +1,4 @@
+FORMAT_NODE_LOG=true
+
 # disable Elasticsearch
 ELASTICSEARCH_URI=

+ 7 - 5
packages/app/config/webpack.common.js

@@ -9,6 +9,7 @@ const webpack = require('webpack');
   */
 const WebpackAssetsManifest = require('webpack-assets-manifest');
 const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
+const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
 
 /*
   * Webpack configuration
@@ -62,11 +63,12 @@ module.exports = (options) => {
     },
     resolve: {
       extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
-      // modules: ((options.resolve && options.resolve.modules) || []).concat([path.resolve(__dirname, 'node_modules')]),
-      alias: {
-        '~': path.resolve(__dirname, '../src'), // src
-        '^': path.resolve(__dirname, '../'), // project root
-      },
+      plugins: [
+        new TsconfigPathsPlugin({
+          configFile: path.resolve(__dirname, '../tsconfig.build.client.json'),
+          extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
+        }),
+      ],
     },
     node: {
       fs: 'empty',

+ 34 - 24
packages/app/docker/Dockerfile

@@ -17,16 +17,21 @@ COPY ./package.json .
 COPY ./yarn.lock .
 COPY ./lerna.json .
 COPY ./packages/app/package.json packages/app/
+COPY ./packages/core/package.json packages/core/
+COPY ./packages/plugin-attachment-refs/package.json packages/plugin-attachment-refs/
+COPY ./packages/plugin-lsx/package.json packages/plugin-lsx/
+COPY ./packages/plugin-pukiwiki-like-linker/package.json packages/plugin-pukiwiki-like-linker/
 COPY ./packages/slack/package.json packages/slack/
+COPY ./packages/ui/package.json packages/ui/
 
 # setup
 RUN yarn config set network-timeout 300000
 RUN npx lerna bootstrap
 
 # make artifacts
-RUN tar cf node_modules.tar node_modules \
-  packages/app/node_modules \
-  packages/slack/node_modules
+RUN tar cf node_modules.tar \
+  node_modules \
+  packages/*/node_modules
 
 
 
@@ -39,9 +44,9 @@ FROM deps-resolver AS deps-resolver-prod
 RUN yarn install --production
 
 # make artifacts
-RUN tar cf node_modules.tar node_modules \
-  packages/app/node_modules \
-  packages/slack/node_modules
+RUN tar cf node_modules.tar \
+  node_modules \
+  packages/*/node_modules
 
 
 
@@ -88,16 +93,14 @@ COPY ./package.json ./
 COPY ./yarn.lock ./
 COPY ./lerna.json ./
 COPY ./tsconfig.base.json ./
-COPY ./babel.config.js ./
-COPY ./bin ./bin
-COPY ./config ./config
-COPY ./public ./public
-COPY ./resource ./resource
-COPY ./src ./src
-COPY ./tmp ./tmp
 # copy all related packages
-COPY packages/slack packages/slack
 COPY packages/app packages/app
+COPY packages/core packages/core
+COPY packages/plugin-attachment-refs packages/plugin-attachment-refs
+COPY packages/plugin-lsx packages/plugin-lsx
+COPY packages/plugin-pukiwiki-like-linker packages/plugin-pukiwiki-like-linker
+COPY packages/slack packages/slack
+COPY packages/ui packages/ui
 
 # build
 RUN yarn lerna run build
@@ -106,15 +109,18 @@ RUN yarn lerna run build
 RUN tar cf packages.tar \
   package.json \
   yarn.lock \
-  config \
-  public \
-  resource \
-  src \
-  tmp \
+  tsconfig.base.json \
   packages/app/package.json \
-  packages/slack/package.json \
-  packages/slack/dist
-
+  packages/app/config \
+  packages/app/dist \
+  packages/app/public \
+  packages/app/resource \
+  packages/app/tmp \
+  packages/app/.env.production \
+  packages/app/tsconfig.base.json \
+  packages/app/tsconfig.json \
+  packages/*/package.json \
+  packages/*/dist
 
 
 
@@ -124,6 +130,8 @@ RUN tar cf packages.tar \
 FROM node:14-slim
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
+ENV NODE_ENV production
+
 ENV appDir /opt/growi
 
 # Add gosu
@@ -154,12 +162,14 @@ RUN rm node_modules.tar packages.tar
 
 USER root
 
-COPY docker/docker-entrypoint.sh /
+COPY packages/app/docker/docker-entrypoint.sh /
 RUN chmod 700 /docker-entrypoint.sh
 RUN chown node:node ${appDir}
 
+WORKDIR ${appDir}/packages/app
+
 VOLUME /data
 EXPOSE 3000
 
 ENTRYPOINT ["/tini", "-e", "143", "--", "/docker-entrypoint.sh"]
-CMD ["yarn", "server:prod"]
+CMD ["node", "-r", "dotenv-flow/config", "--expose_gc", "dist/server/app.js"]

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

@@ -4,11 +4,11 @@ set -e
 
 # Support `FILE_UPLOAD=local`
 mkdir -p /data/uploads
-if [ ! -e "$appDir/public/uploads" ]; then
-  ln -s /data/uploads $appDir/public/uploads
+if [ ! -e "./public/uploads" ]; then
+  ln -s /data/uploads ./public/uploads
 fi
 
 chown -R node:node /data/uploads
-chown -h node:node $appDir/public/uploads
+chown -h node:node ./public/uploads
 
 gosu node $@

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

@@ -5,6 +5,7 @@
 const MODULE_NAME_MAPPING = {
   '^\\^/(.+)$': '<rootDir>/$1',
   '^~/(.+)$': '<rootDir>/src/$1',
+  '^@growi/(.+)$': '<rootDir>/../$1/src',
 };
 
 module.exports = {

+ 12 - 20
packages/app/package.json

@@ -8,8 +8,9 @@
     "build": "run-p build:*",
     "build:client": "cross-env NODE_ENV=production webpack --config config/webpack.prod.js --profile --bail",
     "build:server": "cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
-    "prebuild": "run-p resources:*",
-    "postbuild": "npx shx mv transpiled/src dist && npx shx rm -r transpiled",
+    "clean": "npx shx rm -rf dist transpiled",
+    "prebuild": "run-p clean resources:*",
+    "postbuild": "npx shx mv transpiled/src dist && npx shx rm -rf transpiled",
     "server": "cross-env NODE_ENV=production node -r dotenv-flow/config --expose_gc dist/server/app.js",
     "server:ci": "yarn server --ci",
     "preserver": "yarn migrate",
@@ -54,7 +55,9 @@
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@google-cloud/storage": "^5.8.5",
+    "@growi/plugin-attachment-refs": "^4.3.3-RC",
     "@growi/plugin-pukiwiki-like-linker": "^4.3.3-RC",
+    "@growi/plugin-lsx": "^4.3.3-RC",
     "@growi/slack": "^4.3.3-RC",
     "@kobalab/socket.io-session": "^1.0.3",
     "@promster/express": "^5.0.1",
@@ -76,12 +79,10 @@
     "connect-mongo": "^4.4.1",
     "connect-redis": "^4.0.4",
     "cookie-parser": "^1.4.5",
-    "cross-env": "^7.0.0",
     "csrf": "^3.1.0",
-    "date-fns": "^2.0.0",
+    "date-fns": "^2.23.0",
     "detect-indent": "^6.0.0",
     "diff": "^5.0.0",
-    "dotenv-flow": "^3.2.0",
     "elasticsearch": "^16.0.0",
     "entities": "^2.0.0",
     "esa-nodejs": "^0.0.7",
@@ -95,9 +96,8 @@
     "express-webpack-assets": "^0.1.0",
     "graceful-fs": "^4.1.11",
     "growi-commons": "^5.0.4",
-    "growi-plugin-attachment-refs": "^2.0.2",
-    "growi-plugin-lsx": "^4.0.3",
-    "helmet": "^3.13.0",
+    "helmet": "^4.6.0",
+    "nocache": "^3.0.1",
     "http-errors": "~1.6.2",
     "i18next": "^20.3.2",
     "i18next-express-middleware": "^2.0.0",
@@ -117,7 +117,6 @@
     "multer-autoreap": "^1.0.3",
     "nodemailer": "^6.6.2",
     "nodemailer-ses-transport": "~1.5.0",
-    "npm-run-all": "^4.1.2",
     "openid-client": "=2.5.0",
     "passport": "^0.4.0",
     "passport-github": "^1.1.0",
@@ -133,14 +132,11 @@
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "rimraf": "^3.0.0",
-    "socket.io": "^2.3.0",
+    "socket.io": "^4.0.0",
     "stream-to-promise": "^3.0.0",
     "string-width": "=4.2.2",
     "swagger-jsdoc": "^3.4.0",
     "swig-templates": "^2.0.2",
-    "ts-node": "^9.1.1",
-    "tsconfig-paths": "^3.9.0",
-    "typescript": "^4.2.3",
     "uglifycss": "^0.0.29",
     "universal-bunyan": "^0.9.2",
     "unzipper": "^0.10.5",
@@ -158,13 +154,13 @@
     "@alienfast/i18next-loader": "^1.0.16",
     "@atlaskit/drawer": "^5.3.7",
     "@atlaskit/navigation-next": "^8.0.5",
+    "@growi/ui": "^4.3.3-RC",
     "@handsontable/react": "=2.1.0",
     "@textlint/kernel": "^12.0.2",
     "@types/codemirror": "^5.60.2",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/multer": "^1.4.5",
-    "@types/node": "^14.14.35",
     "@types/react-dom": "^17.0.9",
     "autoprefixer": "^9.0.0",
     "bootstrap": "^4.5.0",
@@ -184,8 +180,6 @@
     "hard-source-webpack-plugin": "^0.13.1",
     "i18next-browser-languagedetector": "^4.0.1",
     "imports-loader": "^0.8.0",
-    "jest": "^27.0.6",
-    "jest-date-mock": "^1.0.8",
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
@@ -224,15 +218,13 @@
     "react-frame-component": "^4.0.0",
     "react-hotkeys": "^2.0.0",
     "react-i18next": "^11.1.0",
-    "react-images": "1.0.0",
-    "react-motion": "^0.5.2",
     "react-waypoint": "^10.1.0",
     "reactstrap": "^8.9.0",
     "replacestream": "^4.0.3",
     "reveal.js": "^3.5.0",
     "sass-loader": "^8.0.0",
     "simple-load-script": "^1.0.2",
-    "socket.io-client": "^2.3.0",
+    "socket.io-client": "^4.0.0",
     "sticky-events": "^3.1.3",
     "style-loader": "^1.0.0",
     "styled-components": "^5.0.1",
@@ -246,10 +238,10 @@
     "textlint-rule-max-comma": "^2.0.2",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
-    "ts-jest": "^27.0.4",
     "ts-loader": "^8.3.0",
     "ts-node-dev": "^1.1.6",
     "tsc-alias": "^1.2.9",
+    "tsconfig-paths-webpack-plugin": "^3.5.1",
     "unstated": "^2.1.1",
     "webpack": "^4.39.3",
     "webpack-assets-manifest": "^3.1.1",

+ 3 - 7
packages/app/resource/locales/en_US/admin/admin.json

@@ -137,21 +137,14 @@
       "tab_switch_desc2": "By invalidating, you can make page transition as the only object for forward/back command of the browser.",
       "attach_title_header": "Add h1 section when create new page automatically",
       "attach_title_header_desc": "Add page path to the first line as h1 section when create new page.",
-
       "list_num_s": "Number of list displayed on modals",
       "list_num_desc_s": "Set number of list per page such as 'Page List', 'Timeline', 'Page History' and 'Attachment' pages.",
-
       "list_num_m": "Number of list displayed on article pages included other contents",
       "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages.",
-
       "list_num_l": "Number of list displayed on 'Search' pages",
       "list_num_desc_l": "Set number of list per page such as 'Search' pages.",
-
       "list_num_xl": "Number of list displayed on article pages",
       "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages.",
-
-
-
       "stale_notification": "Display notification on stale pages",
       "stale_notification_desc": "Displays the notification to pages more than 1 year since the last update.",
       "show_all_reply_comments": "Show all reply comments",
@@ -326,6 +319,9 @@
       "install_complete_if_checked": "Confirm that \"Install your app\" is checked.",
       "invite_bot_to_channel": "Invite GROWI bot to channel by calling @example.",
       "register_secret_and_token": "Set Signing Secret and Bot Token",
+      "manage_commands": "Manage GROWI commands",
+      "multiple_growi_command": "Commands that could be sent to multiple GROWI instances at once",
+      "single_growi_command": "Commands that could be sent to single GROWI instance at a time",
       "test_connection": "Test Connection",
       "test_connection_by_pressing_button": "Press the button to test the connection",
       "error_check_logs_below": "An error has occurred. Please check the logs below.",

+ 3 - 6
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -137,19 +137,14 @@
       "tab_switch_desc2": "無効化することで、ページ遷移のみを戻る/進む操作の対象にすることができます。",
       "attach_title_header": "新規ページ作成時の h1 セクション自動挿入",
       "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
-
       "list_num_s": "モーダルに表示されるリスト数",
       "list_num_desc_s": "モーダルにおける <ページリスト> <タイムライン> <更新履歴> <添付ファイル>での、1ページあたりの表示数を設定します。",
-
       "list_num_m": "ユーザーページに表示されるリスト数",
       "list_num_desc_m": "ユーザーページにおける <Bookmarks> <Recently Created>での、1ページあたりの表示数を設定します。",
-
       "list_num_l": "検索ページに表示されるリスト数",
       "list_num_desc_l": "<Search>での、1ページあたりの表示数を設定します。",
-
       "list_num_xl": "Not FoundページやTrashページに表示されるリスト数",
       "list_num_desc_xl": "記事エリアにおける<Not Found> <Trash>での、1ページあたりの表示数を設定します。",
-
       "stale_notification": "古いページに通知を表示する",
       "stale_notification_desc": "最後の更新から1年を超えるページへの通知を表示します。",
       "show_all_reply_comments": "返信コメントを全て表示する",
@@ -323,6 +318,9 @@
       "install_complete_if_checked": "Install your app の右側に緑色のチェックがつけばワークスペースへのインストール完了です。",
       "invite_bot_to_channel": "GROWI bot を使いたいチャンネルに @example を使用して招待します。",
       "register_secret_and_token": "Signing Secret と Bot Token を登録する",
+      "manage_commands": "使用可能なGROWIコマンドを設定する",
+      "multiple_growi_command": "複数のGROWIに対して送信できるコマンド",
+      "single_growi_command": "一つのGROWIに対して送信できるコマンド",
       "test_connection": "連携状況のテストをする",
       "test_connection_by_pressing_button": "以下のテストボタンを押して、Slack連携が完了しているかの確認をしましょう",
       "error_check_logs_below": "エラーが発生しました。下記のログを確認してください。",
@@ -334,7 +332,6 @@
       "integration_is_not_complete": "連携は完了していません<br>下の連携手順を進めてください",
       "integration_successful": "連携は完了しています。",
       "integration_some_ws_is_not_complete": "連携に失敗している ワークスペースがあります。"
-
     },
     "custom_bot_with_proxy_integration": "Custom bot with proxy 連携",
     "official_bot_integration": "Official bot 連携",

+ 311 - 313
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -1,84 +1,84 @@
 {
-  "mailer_setup_required":"<a href='/admin/app'>Email settings</a> are required to send.",
-	"admin_top": {
-		"management_wiki": "管理Wiki",
-		"system_information": "系统信息",
-		"wiki_administrator": "只有wiki管理员可以访问此页",
-		"assign_administrator": "您可以使用“授予管理员访问权限”按钮在“用户管理”页上将所选用户指定为wiki管理员",
-		"list_of_installed_plugins": "已安装插件列表",
-		"package_name": "包名称",
-		"specified_version": "指定版本",
-		"installed_version": "已安装版本",
-		"list_of_env_vars": "环境变量列表",
-		"env_var_priority": "对于安全性以外的环境变量,优先获取数据库的值。",
-		"about_security": "检查安全环境变量的<a href='/admin/security'>安全设置</a>。"
-	},
-	"app_setting": {
-		"site_name": "网站名称 ",
-		"sitename_change": "您可以更改用于标题和HTML标题的网站名称。",
-		"header_content": "此处输入的内容将显示在标题等中。",
-		"site_url_desc": "用于网站URL设置。",
-		"site_url_warn": "某些功能不起作用,因为未设置网站URL。",
-		"siteurl_help": "网站完整URL起始于 <code>http://</code> or <code>https://</code>.",
-		"confidential_name": "内部名称",
-		"confidential_example": "ex):仅供内部使用",
-		"default_language": "新用户的默认语言",
-		"default_mail_visibility": "新用户的默认电子邮件可见性",
-		"file_uploading": "文件上传",
-		"enable_files_except_image": "启用此选项将允许上传任何文件类型。如果没有此选项,则仅支持图像文件上载。",
-		"attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
-		"update": "更新",
-		"mail_settings": "邮件设置",
+  "mailer_setup_required": "<a href='/admin/app'>Email settings</a> are required to send.",
+  "admin_top": {
+    "management_wiki": "管理Wiki",
+    "system_information": "系统信息",
+    "wiki_administrator": "只有wiki管理员可以访问此页",
+    "assign_administrator": "您可以使用“授予管理员访问权限”按钮在“用户管理”页上将所选用户指定为wiki管理员",
+    "list_of_installed_plugins": "已安装插件列表",
+    "package_name": "包名称",
+    "specified_version": "指定版本",
+    "installed_version": "已安装版本",
+    "list_of_env_vars": "环境变量列表",
+    "env_var_priority": "对于安全性以外的环境变量,优先获取数据库的值。",
+    "about_security": "检查安全环境变量的<a href='/admin/security'>安全设置</a>。"
+  },
+  "app_setting": {
+    "site_name": "网站名称 ",
+    "sitename_change": "您可以更改用于标题和HTML标题的网站名称。",
+    "header_content": "此处输入的内容将显示在标题等中。",
+    "site_url_desc": "用于网站URL设置。",
+    "site_url_warn": "某些功能不起作用,因为未设置网站URL。",
+    "siteurl_help": "网站完整URL起始于 <code>http://</code> or <code>https://</code>.",
+    "confidential_name": "内部名称",
+    "confidential_example": "ex):仅供内部使用",
+    "default_language": "新用户的默认语言",
+    "default_mail_visibility": "新用户的默认电子邮件可见性",
+    "file_uploading": "文件上传",
+    "enable_files_except_image": "启用此选项将允许上传任何文件类型。如果没有此选项,则仅支持图像文件上载。",
+    "attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
+    "update": "更新",
+    "mail_settings": "邮件设置",
     "mailer_is_not_set_up": "邮件设置尚未完成。",
-    "transmission_method":"传送方法",
-    "smtp_label":"SMTP",
-    "ses_label":"SES(AWS)",
-		"from_e-mail_address": "邮件发出地址",
+    "transmission_method": "传送方法",
+    "smtp_label": "SMTP",
+    "ses_label": "SES(AWS)",
+    "from_e-mail_address": "邮件发出地址",
     "send_test_email": "发送测试邮件",
     "success_to_send_test_email": "成功发送了一封测试邮件",
     "smtp_settings": "SMTP 设置",
-		"host": "服务器",
-		"port": "端口号",
-		"user": "用户名",
+    "host": "服务器",
+    "port": "端口号",
+    "user": "用户名",
     "initialize_mail_settings": "重置邮件设置",
     "initialize_mail_modal_header": "重置邮件设置",
     "confirm_to_initialize_mail_settings": "当前设置将被清空且不可恢复。确认重置?",
-    "file_upload_settings":"文件上传设置",
-    "file_upload_method":"文件上传方法",
-    "file_delivery_method":"File Delivery Method",
-    "file_delivery_method_redirect":"Redirect",
-    "file_delivery_method_relay":"Internal System Relay",
-    "file_delivery_method_redirect_info":"Redirect: It redirects to a signed URL without GROWI server, it gives excellent performance.",
-    "file_delivery_method_relay_info":"Internal System Relay: The GROWI server delivers to clients, it provides complete security.",
+    "file_upload_settings": "文件上传设置",
+    "file_upload_method": "文件上传方法",
+    "file_delivery_method": "File Delivery Method",
+    "file_delivery_method_redirect": "Redirect",
+    "file_delivery_method_relay": "Internal System Relay",
+    "file_delivery_method_redirect_info": "Redirect: It redirects to a signed URL without GROWI server, it gives excellent performance.",
+    "file_delivery_method_relay_info": "Internal System Relay: The GROWI server delivers to clients, it provides complete security.",
     "gcs_label": "GCP(GCS)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "gridfs_label": "MongoDB(GridFS)",
-    "ses_settings":"SES设置",
+    "ses_settings": "SES设置",
     "test_connection": "测试邮件服务器连接",
-		"": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
-		"change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
-		"region": "Region",
-		"bucket_name": "Bucket name",
-		"custom_endpoint": "Custom endpoint",
-		"custom_endpoint_change": "输入对象存储服务(如MinIO)端点的URL,MinIO具有与S3兼容的API。如果为空,则使用Amazon S3。",
-		"plugin_settings": "插件设置",
-		"enable_plugin_loading": "启用插件加载",
-		"load_plugins": "加载插件",
-		"enable": "启用",
-		"disable": "停用",
-		"use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <cod>{{variable}}</code> 启用。",
+    "": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
+    "change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
+    "region": "Region",
+    "bucket_name": "Bucket name",
+    "custom_endpoint": "Custom endpoint",
+    "custom_endpoint_change": "输入对象存储服务(如MinIO)端点的URL,MinIO具有与S3兼容的API。如果为空,则使用Amazon S3。",
+    "plugin_settings": "插件设置",
+    "enable_plugin_loading": "启用插件加载",
+    "load_plugins": "加载插件",
+    "enable": "启用",
+    "disable": "停用",
+    "use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <cod>{{variable}}</code> 启用。",
     "note_for_the_only_env_option": "The GCS settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> ."
   },
-	"markdown_setting": {
-		"lineBreak_header": "换行设置",
-		"lineBreak_desc": "您可以更改换行设置。",
-		"lineBreak_options": {
-			"enable_lineBreak": "启用换行符",
-			"enable_lineBreak_desc": "HTML中将文本页中的换行符转换为<code>&lt;br&gt;</code>",
-			"enable_lineBreak_for_comment": "注释中启用换行符",
-			"enable_lineBreak_for_comment_desc": "HTML中将注释中的换行符转换为<code>&lt;br&gt;</code>"
-		},
+  "markdown_setting": {
+    "lineBreak_header": "换行设置",
+    "lineBreak_desc": "您可以更改换行设置。",
+    "lineBreak_options": {
+      "enable_lineBreak": "启用换行符",
+      "enable_lineBreak_desc": "HTML中将文本页中的换行符转换为<code>&lt;br&gt;</code>",
+      "enable_lineBreak_for_comment": "注释中启用换行符",
+      "enable_lineBreak_for_comment_desc": "HTML中将注释中的换行符转换为<code>&lt;br&gt;</code>"
+    },
     "indent_header": "缩进设置",
     "indent_desc": "您可以更改缩进设置。",
     "indent_options": {
@@ -88,184 +88,179 @@
       "disallow_indent_change_desc": "您可以不允许用户更改缩进值。"
     },
     "presentation_header": "演示文稿设置",
-		"presentation_desc": "您可以更改演示文稿设置。",
-		"presentation_options": {
-			"page_break_setting": "分页设置",
-			"preset_one_separator": "预设 1",
-			"preset_one_separator_desc": "3 空行",
-			"preset_one_separator_value": "\\n\\n\\n",
-			"preset_two_separator": "预设 2",
-			"preset_two_separator_desc": "5 连字符",
-			"preset_two_separator_value": "-----",
-			"custom_separator": "自定义",
-			"custom_separator_desc": "正则表达式"
-		},
-		"xss_header": "阻止XSS(跨站点脚本)设置",
-		"xss_desc": "您可以更改标记文本中HTML标记的处理方式。",
-		"xss_options": {
-			"enable_xss_prevention": "启用XSS预防",
-			"remove_all_tags": "删除所有标记",
-			"remove_all_tags_desc": "Stripe all HTML tags and attributes",
-			"recommended_setting": "推荐设置",
-			"custom_whitelist": "自定义白名单",
-			"tag_names": "标记名",
-			"tag_attributes": "标记属性",
-			"import_recommended": "导入建议 {{target}}"
-		}
-	},
-	"customize_setting": {
+    "presentation_desc": "您可以更改演示文稿设置。",
+    "presentation_options": {
+      "page_break_setting": "分页设置",
+      "preset_one_separator": "预设 1",
+      "preset_one_separator_desc": "3 空行",
+      "preset_one_separator_value": "\\n\\n\\n",
+      "preset_two_separator": "预设 2",
+      "preset_two_separator_desc": "5 连字符",
+      "preset_two_separator_value": "-----",
+      "custom_separator": "自定义",
+      "custom_separator_desc": "正则表达式"
+    },
+    "xss_header": "阻止XSS(跨站点脚本)设置",
+    "xss_desc": "您可以更改标记文本中HTML标记的处理方式。",
+    "xss_options": {
+      "enable_xss_prevention": "启用XSS预防",
+      "remove_all_tags": "删除所有标记",
+      "remove_all_tags_desc": "Stripe all HTML tags and attributes",
+      "recommended_setting": "推荐设置",
+      "custom_whitelist": "自定义白名单",
+      "tag_names": "标记名",
+      "tag_attributes": "标记属性",
+      "import_recommended": "导入建议 {{target}}"
+    }
+  },
+  "customize_setting": {
     "layout": "布局",
     "layout_options": {
       "default": "默认内容宽度 ",
       "expanded": "内容宽度100% "
     },
-		"theme": "主体",
-		"behavior": "行为",
-		"behavior_desc": {
-			"growi_text1": "<code>/page</code> and <code>/page/</code> 都显示同一页。",
-			"growi_text2": "<code>/nonexistent_page</code> 显示编辑表单",
-			"growi_text3": "如果使用GROWI增强布局,则所有页面都显示子页面列表",
-			"crowi_text1": "<code>/page</code> 显示页面",
-			"crowi_text2": "<code>/page/</code> 显示子页列表",
-			"crowi_text3": "如果portal应用于<code>/page/</code>,则会显示portal和子页面列表",
-			"crowi_text4": "<code>/nonexistent_page</code> 显示编辑表单<",
-			"crowi_text5": "<code>/nonexistent_page/</code> 子页列表"
-		},
-		"theme_desc": {
-			"light_and_dark": "明暗模式",
-			"unique": "只有一种模式"
-		},
-		"function": "功能",
-		"function_desc": "您可以选择函数的有效/无效",
-		"function_options": {
-			"timeline": "时间线函数",
-			"timeline_desc1": "您可以显示子页的时间线。",
-			"timeline_desc2": "如果有许多子页,则在加载页时性能会降低。",
-			"timeline_desc3": "通过使列表页无效,可以加快列表页的显示速度。",
-			"tab_switch": "在浏览器中保存选项卡切换",
-			"tab_switch_desc1": "在浏览器中保存编辑选项卡和历史选项卡切换,并使其成为浏览器的前向/后向命令的对象。",
-			"tab_switch_desc2": "通过失效,您可以将页面转换作为浏览器的前向/后向命令的唯一对象。",
-			"attach_title_header": "自动创建新页面时添加h1节",
+    "theme": "主体",
+    "behavior": "行为",
+    "behavior_desc": {
+      "growi_text1": "<code>/page</code> and <code>/page/</code> 都显示同一页。",
+      "growi_text2": "<code>/nonexistent_page</code> 显示编辑表单",
+      "growi_text3": "如果使用GROWI增强布局,则所有页面都显示子页面列表",
+      "crowi_text1": "<code>/page</code> 显示页面",
+      "crowi_text2": "<code>/page/</code> 显示子页列表",
+      "crowi_text3": "如果portal应用于<code>/page/</code>,则会显示portal和子页面列表",
+      "crowi_text4": "<code>/nonexistent_page</code> 显示编辑表单<",
+      "crowi_text5": "<code>/nonexistent_page/</code> 子页列表"
+    },
+    "theme_desc": {
+      "light_and_dark": "明暗模式",
+      "unique": "只有一种模式"
+    },
+    "function": "功能",
+    "function_desc": "您可以选择函数的有效/无效",
+    "function_options": {
+      "timeline": "时间线函数",
+      "timeline_desc1": "您可以显示子页的时间线。",
+      "timeline_desc2": "如果有许多子页,则在加载页时性能会降低。",
+      "timeline_desc3": "通过使列表页无效,可以加快列表页的显示速度。",
+      "tab_switch": "在浏览器中保存选项卡切换",
+      "tab_switch_desc1": "在浏览器中保存编辑选项卡和历史选项卡切换,并使其成为浏览器的前向/后向命令的对象。",
+      "tab_switch_desc2": "通过失效,您可以将页面转换作为浏览器的前向/后向命令的唯一对象。",
+      "attach_title_header": "自动创建新页面时添加h1节",
       "attach_title_header_desc": "创建新页面时,将页面路径作为h1节添加到第一行",
-
       "list_num_s": "Number of list displayed on modals",
       "list_num_desc_s": "Set number of list per page such as 'Page List', 'Timeline', 'Page History' and 'Attachment' pages.",
-
       "list_num_m": "Number of list displayed on article pages included other contents",
       "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages.",
-
       "list_num_l": "Number of list displayed on 'Search' pages",
       "list_num_desc_l": "Set number of list per page such as 'Search' pages.",
-
       "list_num_xl": "Number of list displayed on article pages",
       "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages.",
-
-			"stale_notification": "在过期页上显示通知",
-			"stale_notification_desc": "显示自上次更新以来超过1年的页面通知。",
-			"show_all_reply_comments": "显示所有回复评论",
-			"show_all_reply_comments_desc": "当设置值为“关”时,将忽略最近两个之外的注释。"
-		},
-		"code_highlight": "代码突出显示",
-		"nocdn_desc": "当强制应用环境变量<code>NO_CDN=true</code><br>Github样式时,此函数被禁用。",
-		"custom_title": "自定义标题",
-		"custom_title_detail": "您可以自定义<code>&lt;title&gt;</code>标记。<br><code>&123;&123;sitename&&125;&125;</code>将自动替换为应用程序名称,并且<code>&123;&123;page&&125;&125;</code>将替换为页面名称/路径。",
-		"custom_title_detail_placeholder1": "<code>&#123;&#123;站点名称&#125;&#125;</code>-此wiki的站点名称。",
-		"custom_title_detail_placeholder2": "<code>&#123;&#123;页名&#125;&#125;</code>-当前页的页名。",
-		"custom_title_detail_placeholder3": "<code>&#123;&#123;页面路径&#125;&#125;</code>-当前页面的页面路径。",
-		"custom_header": "自定义HTML标题",
-		"custom_header_detail": "您可以自定义应用所有页面的HTML标题。您的自定义脚本将插入<code>&lt;header&gt;</code>中,但位于其他<code>&lt;script&gt;</code>标记之上。<br>重新链接页面以查看更改。",
-		"custom_css": "自定义CSS",
-		"write_css": "您可以编写应用于整个系统的CSS。",
-		"ctrl_space": "Ctrl+Space 自动完成",
-		"custom_script": "定制纸条",
-		"write_java": "您可以编写应用于整个系统的Javascript。",
-		"reflect_change": "您需要重新加载页面以反映更改。"
-	},
-	"importer_management": {
-		"beta_warning": "这个函数是Beta。",
-		"import_from": "Import from {{from}}",
-		"import_growi_archive": "Import GROWI archive",
-		"growi_settings": {
-			"description_of_import_mode": {
-				"about": "When you import data with the same name as an existing one, choose from the following three modes below.",
-				"insert": "Insert: Skip importing the data.",
-				"upsert": "Upsert: Overwrite and update the existing data with imported data.",
-				"flash_and_insert": "Flash and Insert: After deleting the existing data completely, import the data"
-			},
-			"growi_archive_file": "GROWI Archive File",
-			"uploaded_data": "Uploaded Data",
-			"extracted_file": "Extracted File",
-			"collection": "Collection",
-			"upload": "Upload",
-			"discard": "Discard uploaded data",
-			"errors": {
+      "stale_notification": "在过期页上显示通知",
+      "stale_notification_desc": "显示自上次更新以来超过1年的页面通知。",
+      "show_all_reply_comments": "显示所有回复评论",
+      "show_all_reply_comments_desc": "当设置值为“关”时,将忽略最近两个之外的注释。"
+    },
+    "code_highlight": "代码突出显示",
+    "nocdn_desc": "当强制应用环境变量<code>NO_CDN=true</code><br>Github样式时,此函数被禁用。",
+    "custom_title": "自定义标题",
+    "custom_title_detail": "您可以自定义<code>&lt;title&gt;</code>标记。<br><code>&123;&123;sitename&&125;&125;</code>将自动替换为应用程序名称,并且<code>&123;&123;page&&125;&125;</code>将替换为页面名称/路径。",
+    "custom_title_detail_placeholder1": "<code>&#123;&#123;站点名称&#125;&#125;</code>-此wiki的站点名称。",
+    "custom_title_detail_placeholder2": "<code>&#123;&#123;页名&#125;&#125;</code>-当前页的页名。",
+    "custom_title_detail_placeholder3": "<code>&#123;&#123;页面路径&#125;&#125;</code>-当前页面的页面路径。",
+    "custom_header": "自定义HTML标题",
+    "custom_header_detail": "您可以自定义应用所有页面的HTML标题。您的自定义脚本将插入<code>&lt;header&gt;</code>中,但位于其他<code>&lt;script&gt;</code>标记之上。<br>重新链接页面以查看更改。",
+    "custom_css": "自定义CSS",
+    "write_css": "您可以编写应用于整个系统的CSS。",
+    "ctrl_space": "Ctrl+Space 自动完成",
+    "custom_script": "定制纸条",
+    "write_java": "您可以编写应用于整个系统的Javascript。",
+    "reflect_change": "您需要重新加载页面以反映更改。"
+  },
+  "importer_management": {
+    "beta_warning": "这个函数是Beta。",
+    "import_from": "Import from {{from}}",
+    "import_growi_archive": "Import GROWI archive",
+    "growi_settings": {
+      "description_of_import_mode": {
+        "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
+        "insert": "Insert: Skip importing the data.",
+        "upsert": "Upsert: Overwrite and update the existing data with imported data.",
+        "flash_and_insert": "Flash and Insert: After deleting the existing data completely, import the data"
+      },
+      "growi_archive_file": "GROWI Archive File",
+      "uploaded_data": "Uploaded Data",
+      "extracted_file": "Extracted File",
+      "collection": "Collection",
+      "upload": "Upload",
+      "discard": "Discard uploaded data",
+      "errors": {
         "versions_not_met": "this growi and the uploaded data versions are not met",
-				"at_least_one": "Select one or more collections.",
-				"page_and_revision": "'Pages' and 'Revisions' must be imported both.",
-				"depends": "'{{target}}' must be selected when '{{condition}}' is selected."
-			},
-			"configuration": {
-				"pages": {
-					"overwrite_author": {
-						"label": "Overwrite page's author with the current user",
-						"desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
-					},
-					"set_public_to_page": {
-						"label": "Set 'Public' to the pages that is '{{from}}'",
-						"desc": "Make sure that this configuration makes all <b>'{{from}}'</b> pages readable from <span class=\"text-danger\">ANY users</span>."
-					},
-					"initialize_meta_datas": {
-						"label": "Initialize page's like, read users and comment count",
-						"desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
-					},
-					"initialize_hackmd_related_datas": {
-						"label": "Initialize HackMD related data",
-						"desc": "Recommended to check this unless there is important drafts on HackMD."
-					}
-				},
-				"revisions": {
-					"overwrite_author": {
-						"label": "Overwrite revision's author with the current user",
-						"desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
-					}
-				}
-			}
-		},
-		"esa_settings": {
-			"team_name": "Team name",
-			"access_token": "Access token",
-			"test_connection": "Test connection to esa"
-		},
-		"qiita_settings": {
-			"team_name": "Team name",
-			"access_token": "Access token",
-			"test_connection": "Test connection to qiita:team"
-		},
+        "at_least_one": "Select one or more collections.",
+        "page_and_revision": "'Pages' and 'Revisions' must be imported both.",
+        "depends": "'{{target}}' must be selected when '{{condition}}' is selected."
+      },
+      "configuration": {
+        "pages": {
+          "overwrite_author": {
+            "label": "Overwrite page's author with the current user",
+            "desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
+          },
+          "set_public_to_page": {
+            "label": "Set 'Public' to the pages that is '{{from}}'",
+            "desc": "Make sure that this configuration makes all <b>'{{from}}'</b> pages readable from <span class=\"text-danger\">ANY users</span>."
+          },
+          "initialize_meta_datas": {
+            "label": "Initialize page's like, read users and comment count",
+            "desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
+          },
+          "initialize_hackmd_related_datas": {
+            "label": "Initialize HackMD related data",
+            "desc": "Recommended to check this unless there is important drafts on HackMD."
+          }
+        },
+        "revisions": {
+          "overwrite_author": {
+            "label": "Overwrite revision's author with the current user",
+            "desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
+          }
+        }
+      }
+    },
+    "esa_settings": {
+      "team_name": "Team name",
+      "access_token": "Access token",
+      "test_connection": "Test connection to esa"
+    },
+    "qiita_settings": {
+      "team_name": "Team name",
+      "access_token": "Access token",
+      "test_connection": "Test connection to qiita:team"
+    },
     "import": "Import",
     "skip_username_and_email_when_overlapped": "Skip username and email using same username and email in new environment",
-    "prepare_new_account_for_migration":"Prepare new account for migration",
-    "archive_data_import_detail":"More details? Click here.",
-    "admin_archive_data_import_guide_url":"https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
-		"page_skip": "Pages with a name that already exists on GROWI are not imported",
-		"Directory_hierarchy_tag": "Directory hierarchy tag"
-	},
-	"export_management": {
-		"exporting_collection_list": "正在导出集合列表",
-		"exported_data_list": "导出的存档数据列表",
-		"export_collections": "导出集合",
-		"check_all": "全部检查",
-		"uncheck_all": "全部取消选中",
-		"desc_password_seed": "<p>还原用户数据时,不要忘记将当前的<code>密码种子设置到新的GROWI系统,否则用户将无法使用其密码登录。<br><br><strong>提示:</strong><br>当前的<code>密码种子将存储在<code>meta.json格式</code>在导出的zip压缩包中。</p>",
-		"create_new_archive_data": "创建新的存档数据",
-		"export": "导出",
-		"cancel": "取消",
-		"file": "文件",
-		"growi_version": "Growi Version",
-		"collections": "Collections",
-		"exported_at": "Exported At",
-		"export_menu": "导出菜单",
-		"download": "下载",
-		"delete": "删除"
+    "prepare_new_account_for_migration": "Prepare new account for migration",
+    "archive_data_import_detail": "More details? Click here.",
+    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
+    "page_skip": "Pages with a name that already exists on GROWI are not imported",
+    "Directory_hierarchy_tag": "Directory hierarchy tag"
+  },
+  "export_management": {
+    "exporting_collection_list": "正在导出集合列表",
+    "exported_data_list": "导出的存档数据列表",
+    "export_collections": "导出集合",
+    "check_all": "全部检查",
+    "uncheck_all": "全部取消选中",
+    "desc_password_seed": "<p>还原用户数据时,不要忘记将当前的<code>密码种子设置到新的GROWI系统,否则用户将无法使用其密码登录。<br><br><strong>提示:</strong><br>当前的<code>密码种子将存储在<code>meta.json格式</code>在导出的zip压缩包中。</p>",
+    "create_new_archive_data": "创建新的存档数据",
+    "export": "导出",
+    "cancel": "取消",
+    "file": "文件",
+    "growi_version": "Growi Version",
+    "collections": "Collections",
+    "exported_at": "Exported At",
+    "export_menu": "导出菜单",
+    "download": "下载",
+    "delete": "删除"
   },
   "slack_integration": {
     "selecting_bot_types": {
@@ -304,8 +299,8 @@
     "delete": "取消",
     "integration_procedure": "协作程序",
     "custom_bot_without_proxy_settings": "Custom Bot without proxy 设置",
-    "integration_failed":"联动失败",
-    "reset":"重置",
+    "integration_failed": "联动失败",
+    "reset": "重置",
     "reset_all_settings": "重置所有设置",
     "delete_slackbot_settings": "删除 Slack Bot 设置",
     "slackbot_settings_notice": "Slak 工作区集成过程已被删除。 <br> 你确定吗?",
@@ -333,6 +328,9 @@
       "install_complete_if_checked": "确认已选中 \"Install your app\"。",
       "invite_bot_to_channel": "通过调用 @example 邀请 GROWI Bot 进行频道。",
       "register_secret_and_token": "设置签名秘密和BOT令牌",
+      "manage_commands": "管理 GROWI 命令",
+      "multiple_growi_command": "可以一次发送到多个 GROWI 实例的命令",
+      "single_growi_command": "可以一次发送到一个 GROWI 实例的命令",
       "test_connection": "测试连接",
       "test_connection_by_pressing_button": "按下按钮以测试连接",
       "error_check_logs_below": "发生了错误。请检查以下日志。",
@@ -357,93 +355,93 @@
       "custom_bot_with_proxy_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/custom-bot-with-proxy-settings.html"
     }
   },
-	"user_management": {
-		"invite_users": "临时发布新用户",
-		"click_twice_same_checkbox": "您应该至少选中一个复选框。",
-		"invite_modal": {
-			"emails": "电子邮件",
-      "description1":"通过电子邮件地址临时发布新用户。",
-      "description2":"将为首次登录生成一个临时密码。",
-      "mail_setting_link":"<i class='icon-settings mr-2'></i><a href='/admin/app'>Email settings</a>",
-			"valid_email": "需要有效的电子邮件地址",
-			"invite_thru_email": "发送邀请电子邮件",
-			"temporary_password": "创建的用户具有临时密码",
-			"send_new_password": "请将新密码发送给用户。",
-			"send_temporary_password": "如果你没有发送电子邮件邀请,请复制此屏幕上的临时密码并联系邀请人。",
+  "user_management": {
+    "invite_users": "临时发布新用户",
+    "click_twice_same_checkbox": "您应该至少选中一个复选框。",
+    "invite_modal": {
+      "emails": "电子邮件",
+      "description1": "通过电子邮件地址临时发布新用户。",
+      "description2": "将为首次登录生成一个临时密码。",
+      "mail_setting_link": "<i class='icon-settings mr-2'></i><a href='/admin/app'>Email settings</a>",
+      "valid_email": "需要有效的电子邮件地址",
+      "invite_thru_email": "发送邀请电子邮件",
+      "temporary_password": "创建的用户具有临时密码",
+      "send_new_password": "请将新密码发送给用户。",
+      "send_temporary_password": "如果你没有发送电子邮件邀请,请复制此屏幕上的临时密码并联系邀请人。",
       "send_email": "你也可以从用户表中的下拉菜单中发送或重新发送邀请邮件。",
-			"existing_email": "以下电子邮件已存在",
+      "existing_email": "以下电子邮件已存在",
       "issue": "Issue"
-		},
-		"user_table": {
-			"administrator": "管理员",
-			"edit_menu": "编辑菜单",
-			"reset_password": "重置密码",
-			"administrator_menu": "管理员菜单",
-			"accept": "接受",
-			"deactivate_account": "停用帐户",
-			"your_own": "您不能停用自己的帐户",
-			"remove_admin_access": "删除管理员访问权限",
-			"cannot_remove": "您不能从管理员中删除自己",
-			"give_admin_access": "授予管理员访问权限",
+    },
+    "user_table": {
+      "administrator": "管理员",
+      "edit_menu": "编辑菜单",
+      "reset_password": "重置密码",
+      "administrator_menu": "管理员菜单",
+      "accept": "接受",
+      "deactivate_account": "停用帐户",
+      "your_own": "您不能停用自己的帐户",
+      "remove_admin_access": "删除管理员访问权限",
+      "cannot_remove": "您不能从管理员中删除自己",
+      "give_admin_access": "授予管理员访问权限",
       "send_invitation_email": "发送邀请邮件",
       "resend_invitation_email": "重发邀请函"
-		},
-		"reset_password": "重置密码",
-		"reset_password_modal": {
-			"password_never_seen": "The temporary password can never be retrieved after this screen is closed.",
-			"password_reset_message": "Let the user know the new password below and strongly recommend to change another one immediately.",
-			"send_new_password": "Please send the new password to the user.",
-			"target_user": "Target User",
-			"new_password": "New Password"
-		},
-		"external_account": "外部账户管理",
-		"external_accounts": "外部账户",
-		"create_external_account": "创建外部账户",
+    },
+    "reset_password": "重置密码",
+    "reset_password_modal": {
+      "password_never_seen": "The temporary password can never be retrieved after this screen is closed.",
+      "password_reset_message": "Let the user know the new password below and strongly recommend to change another one immediately.",
+      "send_new_password": "Please send the new password to the user.",
+      "target_user": "Target User",
+      "new_password": "New Password"
+    },
+    "external_account": "外部账户管理",
+    "external_accounts": "外部账户",
+    "create_external_account": "创建外部账户",
     "external_account_list": "外部账户列表",
-    "external_account_none":"No External Account",
-		"invite": "邀请",
-		"invited": "已邀请用户",
-		"back_to_user_management": "返回用户管理",
-		"authentication_provider": "身份认证",
-		"manage": "管理",
-		"password_setting": "密码设置",
-		"password_setting_help": "是否设置了密码?",
-		"set": "是",
-		"unset": "否",
-		"related_username": "相关用户的",
-		"cannot_invite_maximum_users": "邀请的用户数不能超过最大值。",
-		"current_users": "当前用户:"
-	},
-	"user_group_management": {
-		"create_group": "创建新组",
-		"deny_create_group": "不能用当前设置创建新组。",
-		"group_name": "组名",
-		"group_example": "e.g.:第1组",
-		"add_modal": {
-			"add_user": "将用户添加到创建的组",
-			"search_option": "搜索选项",
-			"enable_option": "启用{{option}",
-			"forward_match": "Forword匹配",
-			"partial_match": "部分匹配",
-			"backward_match": "向后匹配"
-		},
-		"group_list": "组列表",
-		"back_to_list": "返回组列表",
-		"basic_info": "基本信息",
-		"user_list": "用户列表",
-		"created_group": "已创建组",
-		"is_loading_data": "获取数据。。。",
-		"no_pages": "组没有查看权限的页面。",
-		"remove_from_group": "删除此用户",
-		"delete_modal": {
-			"header": "删除组",
-			"desc": "删除后,将无法检索已删除的组及其私人页。",
-			"dropdown_desc": "为私人页选择操作",
-			"select_group": "选择组",
-			"no_groups": "没有可选择的组",
-			"publish_pages": "全部发布",
-			"delete_pages": "全部删除",
-			"transfer_pages": "转移到另一组"
-		}
-	}
+    "external_account_none": "No External Account",
+    "invite": "邀请",
+    "invited": "已邀请用户",
+    "back_to_user_management": "返回用户管理",
+    "authentication_provider": "身份认证",
+    "manage": "管理",
+    "password_setting": "密码设置",
+    "password_setting_help": "是否设置了密码?",
+    "set": "是",
+    "unset": "否",
+    "related_username": "相关用户的",
+    "cannot_invite_maximum_users": "邀请的用户数不能超过最大值。",
+    "current_users": "当前用户:"
+  },
+  "user_group_management": {
+    "create_group": "创建新组",
+    "deny_create_group": "不能用当前设置创建新组。",
+    "group_name": "组名",
+    "group_example": "e.g.:第1组",
+    "add_modal": {
+      "add_user": "将用户添加到创建的组",
+      "search_option": "搜索选项",
+      "enable_option": "启用{{option}",
+      "forward_match": "Forword匹配",
+      "partial_match": "部分匹配",
+      "backward_match": "向后匹配"
+    },
+    "group_list": "组列表",
+    "back_to_list": "返回组列表",
+    "basic_info": "基本信息",
+    "user_list": "用户列表",
+    "created_group": "已创建组",
+    "is_loading_data": "获取数据。。。",
+    "no_pages": "组没有查看权限的页面。",
+    "remove_from_group": "删除此用户",
+    "delete_modal": {
+      "header": "删除组",
+      "desc": "删除后,将无法检索已删除的组及其私人页。",
+      "dropdown_desc": "为私人页选择操作",
+      "select_group": "选择组",
+      "no_groups": "没有可选择的组",
+      "publish_pages": "全部发布",
+      "delete_pages": "全部删除",
+      "transfer_pages": "转移到另一组"
+    }
+  }
 }

+ 20 - 1
packages/app/src/client/models/Linker.js

@@ -1,3 +1,8 @@
+
+import { pagePathUtils } from '@growi/core';
+
+const { encodeSpaces } = pagePathUtils;
+
 export default class Linker {
 
   constructor(
@@ -5,10 +10,15 @@ export default class Linker {
       label = '',
       link = '',
   ) {
+
     this.type = type;
     this.label = label;
     this.link = link;
 
+    if (type === Linker.types.markdownLink) {
+      this.initWhenMarkdownLink();
+    }
+
     this.generateMarkdownText = this.generateMarkdownText.bind(this);
   }
 
@@ -25,9 +35,18 @@ export default class Linker {
     markdownLink: /^\[(?<label>.*)\]\((?<link>.*)\)$/, // https://regex101.com/r/DZCKP3/2
   }
 
+  initWhenMarkdownLink() {
+    // fill label with link if empty
+    if (this.label === '') {
+      this.label = this.link;
+    }
+    // encode spaces
+    this.link = encodeSpaces(this.link);
+  }
+
   generateMarkdownText() {
     if (this.type === Linker.types.pukiwikiLink) {
-      if (this.label === this.link) return `[[${this.link}]]`;
+      if (this.label === '') return `[[${this.link}]]`;
       return `[[${this.label}>${this.link}]]`;
     }
     if (this.type === Linker.types.growiLink) {

+ 4 - 2
packages/app/src/client/plugin.js

@@ -29,15 +29,17 @@ export default class GrowiPlugin {
       const meta = definition.meta;
 
       switch (meta.pluginSchemaVersion) {
-        // v1 is deprecated
+        // v1, v2 and v3 is deprecated
         case 1:
           logger.warn('pluginSchemaVersion 1 is deprecated', definition);
           break;
-        // v2 is deprecated
         case 2:
           logger.warn('pluginSchemaVersion 2 is deprecated', definition);
           break;
         case 3:
+          logger.warn('pluginSchemaVersion 2 is deprecated', definition);
+          break;
+        case 4:
           definition.entries.forEach((entry) => {
             entry(appContainer);
           });

+ 1 - 1
packages/app/src/client/services/AdminGitHubSecurityContainer.js

@@ -1,6 +1,6 @@
 import { Container } from 'unstated';
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';

+ 1 - 1
packages/app/src/client/services/AdminGoogleSecurityContainer.js

@@ -1,6 +1,6 @@
 import { Container } from 'unstated';
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';

+ 1 - 1
packages/app/src/client/services/AdminOidcSecurityContainer.js

@@ -1,6 +1,6 @@
 import { Container } from 'unstated';
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';

+ 1 - 1
packages/app/src/client/services/AdminSamlSecurityContainer.js

@@ -1,7 +1,7 @@
 import { Container } from 'unstated';
 
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';

+ 1 - 1
packages/app/src/client/services/AdminTwitterSecurityContainer.js

@@ -1,6 +1,6 @@
 import { Container } from 'unstated';
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';

+ 3 - 1
packages/app/src/client/services/PageContainer.js

@@ -3,8 +3,8 @@ import { Container } from 'unstated';
 
 import * as entities from 'entities';
 import * as toastr from 'toastr';
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
-import { isTrashPage } from '~/utils/path-utils';
 import { toastError } from '../util/apiNotification';
 
 import {
@@ -16,6 +16,8 @@ import {
   DrawioInterceptor,
 } from '../util/interceptor/drawio-interceptor';
 
+const { isTrashPage } = pagePathUtils;
+
 const logger = loggerFactory('growi:services:PageContainer');
 
 /**

+ 1 - 1
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -6,7 +6,7 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import urljoin from 'url-join';
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';

+ 5 - 1
packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx

@@ -103,7 +103,9 @@ const CustomBotWithProxySettings = (props) => {
 
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
-          const { tokenGtoP, tokenPtoG, _id } = slackAppIntegration;
+          const {
+            tokenGtoP, tokenPtoG, _id, supportedCommandsForBroadcastUse, supportedCommandsForSingleUse,
+          } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
             <React.Fragment key={slackAppIntegration._id}>
@@ -125,6 +127,8 @@ const CustomBotWithProxySettings = (props) => {
                 slackAppIntegrationId={slackAppIntegration._id}
                 tokenGtoP={tokenGtoP}
                 tokenPtoG={tokenPtoG}
+                supportedCommandsForBroadcastUse={supportedCommandsForBroadcastUse}
+                supportedCommandsForSingleUse={supportedCommandsForSingleUse}
                 onUpdateTokens={onUpdateTokens}
                 onSubmitForm={onSubmitForm}
               />

+ 144 - 0
packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx

@@ -0,0 +1,144 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
+import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
+import loggerFactory from '~/utils/logger';
+
+import { toastSuccess, toastError } from '../../../client/util/apiNotification';
+
+const logger = loggerFactory('growi:SlackIntegration:ManageCommandsProcess');
+
+const ManageCommandsProcess = ({
+  apiv3Put, slackAppIntegrationId, supportedCommandsForBroadcastUse, supportedCommandsForSingleUse,
+}) => {
+  const { t } = useTranslation();
+  const [selectedCommandsForBroadcastUse, setSelectedCommandsForBroadcastUse] = useState(new Set(supportedCommandsForBroadcastUse));
+  const [selectedCommandsForSingleUse, setSelectedCommandsForSingleUse] = useState(new Set(supportedCommandsForSingleUse));
+
+  const toggleCheckboxForBroadcast = (e) => {
+    const { target } = e;
+    const { name, checked } = target;
+
+    setSelectedCommandsForBroadcastUse((prevState) => {
+      const selectedCommands = new Set(prevState);
+      if (checked) {
+        selectedCommands.add(name);
+      }
+      else {
+        selectedCommands.delete(name);
+      }
+
+      return selectedCommands;
+    });
+  };
+
+  const toggleCheckboxForSingleUse = (e) => {
+    const { target } = e;
+    const { name, checked } = target;
+
+    setSelectedCommandsForSingleUse((prevState) => {
+      const selectedCommands = new Set(prevState);
+      if (checked) {
+        selectedCommands.add(name);
+      }
+      else {
+        selectedCommands.delete(name);
+      }
+
+      return selectedCommands;
+    });
+  };
+
+  const updateCommandsHandler = async() => {
+    try {
+      await apiv3Put(`/slack-integration-settings/${slackAppIntegrationId}/supported-commands`, {
+        supportedCommandsForBroadcastUse: Array.from(selectedCommandsForBroadcastUse),
+        supportedCommandsForSingleUse: Array.from(selectedCommandsForSingleUse),
+      });
+      toastSuccess(t('toaster.update_successed', { target: 'Token' }));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  };
+
+
+  return (
+    <div className="py-4 px-5">
+      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.manage_commands')}</p>
+      <div className="d-flex flex-column align-items-center">
+
+        <div>
+          <p className="font-weight-bold mb-0">Multiple GROWI</p>
+          <p className="text-muted mb-2">{t('admin:slack_integration.accordion.multiple_growi_command')}</p>
+          <div className="custom-control custom-checkbox">
+            <div className="row mb-5">
+              {defaultSupportedCommandsNameForBroadcastUse.map((commandName) => {
+                return (
+                  <div className="col-sm-6 my-1" key={commandName}>
+                    <input
+                      type="checkbox"
+                      className="custom-control-input"
+                      id={commandName}
+                      name={commandName}
+                      value={commandName}
+                      checked={selectedCommandsForBroadcastUse.has(commandName)}
+                      onChange={toggleCheckboxForBroadcast}
+                    />
+                    <label className="text-capitalize custom-control-label ml-3" htmlFor={commandName}>
+                      {commandName}
+                    </label>
+                  </div>
+                );
+              })}
+            </div>
+          </div>
+
+          <p className="font-weight-bold mb-0">Single GROWI</p>
+          <p className="text-muted mb-2">{t('admin:slack_integration.accordion.single_growi_command')}</p>
+          <div className="custom-control custom-checkbox">
+            <div className="row mb-5">
+              {defaultSupportedCommandsNameForSingleUse.map((commandName) => {
+                return (
+                  <div className="col-sm-6 my-1" key={commandName}>
+                    <input
+                      type="checkbox"
+                      className="custom-control-input"
+                      id={commandName}
+                      name={commandName}
+                      value={commandName}
+                      checked={selectedCommandsForSingleUse.has(commandName)}
+                      onChange={toggleCheckboxForSingleUse}
+                    />
+                    <label className="text-capitalize custom-control-label ml-3" htmlFor={commandName}>
+                      {commandName}
+                    </label>
+                  </div>
+                );
+              })}
+            </div>
+          </div>
+        </div>
+      </div>
+      <div className="row">
+        <button
+          type="button"
+          className="btn btn-primary mx-auto"
+          onClick={updateCommandsHandler}
+        >
+          { t('Update') }
+        </button>
+      </div>
+    </div>
+  );
+};
+
+ManageCommandsProcess.propTypes = {
+  apiv3Put: PropTypes.func,
+  slackAppIntegrationId: PropTypes.string.isRequired,
+  supportedCommandsForBroadcastUse: PropTypes.arrayOf(PropTypes.string),
+  supportedCommandsForSingleUse: PropTypes.arrayOf(PropTypes.string),
+};
+
+export default ManageCommandsProcess;

+ 5 - 1
packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx

@@ -69,7 +69,9 @@ const OfficialBotSettings = (props) => {
 
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
-          const { tokenGtoP, tokenPtoG, _id } = slackAppIntegration;
+          const {
+            tokenGtoP, tokenPtoG, _id, supportedCommandsForBroadcastUse, supportedCommandsForSingleUse,
+          } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
             <React.Fragment key={slackAppIntegration._id}>
@@ -91,6 +93,8 @@ const OfficialBotSettings = (props) => {
                 slackAppIntegrationId={slackAppIntegration._id}
                 tokenGtoP={tokenGtoP}
                 tokenPtoG={tokenPtoG}
+                supportedCommandsForBroadcastUse={supportedCommandsForBroadcastUse}
+                supportedCommandsForSingleUse={supportedCommandsForSingleUse}
                 onUpdateTokens={onUpdateTokens}
                 onSubmitForm={onSubmitForm}
               />

+ 21 - 1
packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -11,6 +11,7 @@ import AppContainer from '~/client/services/AppContainer';
 import Accordion from '../Common/Accordion';
 import { addLogs } from './slak-integration-util';
 import MessageBasedOnConnection from './MessageBasedOnConnection';
+import ManageCommandsProcess from './ManageCommandsProcess';
 
 const logger = loggerFactory('growi:SlackIntegration:WithProxyAccordionsWrapper');
 
@@ -287,7 +288,6 @@ const WithProxyAccordions = (props) => {
       props.onSubmitForm();
     }
   };
-
   const submitFormFailed = () => {
     setIsLatestConnectionSuccess(false);
   };
@@ -309,6 +309,15 @@ const WithProxyAccordions = (props) => {
       />,
     },
     '③': {
+      title: 'manage_commands',
+      content: <ManageCommandsProcess
+        apiv3Put={props.appContainer.apiv3.put}
+        slackAppIntegrationId={props.slackAppIntegrationId}
+        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
+        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
+      />,
+    },
+    '④': {
       title: 'test_connection',
       content: <TestProcess
         apiv3Post={props.appContainer.apiv3.post}
@@ -344,6 +353,15 @@ const WithProxyAccordions = (props) => {
       content: <RegisteringProxyUrlProcess />,
     },
     '⑤': {
+      title: 'manage_commands',
+      content: <ManageCommandsProcess
+        apiv3Put={props.appContainer.apiv3.put}
+        slackAppIntegrationId={props.slackAppIntegrationId}
+        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
+        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
+      />,
+    },
+    '⑥': {
       title: 'test_connection',
       content: <TestProcess
         apiv3Post={props.appContainer.apiv3.post}
@@ -392,6 +410,8 @@ WithProxyAccordions.propTypes = {
   slackAppIntegrationId: PropTypes.string.isRequired,
   tokenPtoG: PropTypes.string,
   tokenGtoP: PropTypes.string,
+  supportedCommandsForBroadcastUse: PropTypes.arrayOf(PropTypes.string),
+  supportedCommandsForSingleUse: PropTypes.arrayOf(PropTypes.string),
 };
 
 export default WithProxyAccordionsWrapper;

+ 1 - 1
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx

@@ -4,11 +4,11 @@ import { withTranslation } from 'react-i18next';
 
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 import { debounce } from 'throttle-debounce';
+import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import UserPicture from '../../User/UserPicture';
 
 class UserGroupUserFormByInput extends React.Component {
 

+ 1 - 1
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.jsx

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import dateFnsFormat from 'date-fns/format';
 
-import UserPicture from '../../User/UserPicture';
+import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';

+ 1 - 1
packages/app/src/components/Admin/Users/UserTable.jsx

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import dateFnsFormat from 'date-fns/format';
 
-import UserPicture from '../../User/UserPicture';
+import { UserPicture } from '@growi/ui';
 import UserMenu from './UserMenu';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';

+ 3 - 1
packages/app/src/components/ComparePathsTable.jsx

@@ -2,10 +2,12 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
+import { pagePathUtils } from '@growi/core';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 import PageContainer from '~/client/services/PageContainer';
-import { convertToNewAffiliationPath } from '~/utils/path-utils';
+
+const { convertToNewAffiliationPath } = pagePathUtils;
 
 function ComparePathsTable(props) {
   const {

+ 3 - 1
packages/app/src/components/ContentLinkButtons.jsx

@@ -1,7 +1,7 @@
 import React, { useMemo } from 'react';
 import PropTypes from 'prop-types';
 
-import { isTopPage } from '~/utils/path-utils';
+import { pagePathUtils } from '@growi/core';
 import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
@@ -10,6 +10,8 @@ import { withUnstatedContainers } from './UnstatedUtils';
 
 import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
 
+const { isTopPage } = pagePathUtils;
+
 const WIKI_HEADER_LINK = 120;
 
 /**

+ 1 - 1
packages/app/src/components/CreateTemplateModal.jsx

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 
 import { withTranslation } from 'react-i18next';
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import { withUnstatedContainers } from './UnstatedUtils';
 

+ 3 - 1
packages/app/src/components/DuplicatedPathsTable.jsx

@@ -2,10 +2,12 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
+import { pagePathUtils } from '@growi/core';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 import PageContainer from '~/client/services/PageContainer';
-import { convertToNewAffiliationPath } from '~/utils/path-utils';
+
+const { convertToNewAffiliationPath } = pagePathUtils;
 
 function DuplicatedPathsTable(props) {
   const {

+ 1 - 1
packages/app/src/components/Fab.jsx

@@ -62,7 +62,7 @@ const Fab = (props) => {
   }
 
   return (
-    <div className="grw-fab d-none d-md-block">
+    <div className="grw-fab d-none d-md-block d-edit-none">
       {currentUser != null && renderPageCreateButton()}
       <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }}>
         <button

+ 4 - 2
packages/app/src/components/Navbar/AuthorInfo.jsx

@@ -1,9 +1,11 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { format } from 'date-fns';
-import { userPageRoot } from '~/utils/path-utils';
+import { UserPicture } from '@growi/ui';
+import { pagePathUtils } from '@growi/core';
+
+const { userPageRoot } = pagePathUtils;
 
-import UserPicture from '../User/UserPicture';
 
 const formatType = 'yyyy/MM/dd HH:mm';
 const AuthorInfo = (props) => {

+ 1 - 1
packages/app/src/components/Navbar/GrowiSubNavigation.jsx

@@ -3,8 +3,8 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
+import { DevidedPagePath } from '@growi/core';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
-import DevidedPagePath from '~/models/devided-page-path';
 import LinkedPagePath from '~/models/linked-page-path';
 
 import { withUnstatedContainers } from '../UnstatedUtils';

+ 1 - 1
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -5,6 +5,7 @@ import { withTranslation } from 'react-i18next';
 
 import { UncontrolledTooltip } from 'reactstrap';
 
+import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
@@ -18,7 +19,6 @@ import {
   updateUserPreferenceWithOsSettings,
 } from '~/client/util/color-scheme';
 
-import UserPicture from '../User/UserPicture';
 
 import SidebarDrawerIcon from '../Icons/SidebarDrawerIcon';
 import SidebarDockIcon from '../Icons/SidebarDockIcon';

+ 3 - 1
packages/app/src/components/Page/CopyDropdown.jsx

@@ -12,7 +12,9 @@ import {
 
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 
-import { encodeSpaces } from '~/utils/path-utils';
+import { pagePathUtils } from '@growi/core';
+
+const { encodeSpaces } = pagePathUtils;
 
 /* eslint-disable react/prop-types */
 const DropdownItemContents = ({ title, contents }) => (

+ 3 - 1
packages/app/src/components/Page/PageManagement.jsx

@@ -4,7 +4,7 @@ import { UncontrolledTooltip } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
 import urljoin from 'url-join';
 
-import { isTopPage } from '~/utils/path-utils';
+import { pagePathUtils } from '@growi/core';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
@@ -15,6 +15,8 @@ import CreateTemplateModal from '../CreateTemplateModal';
 import PagePresentationModal from '../PagePresentationModal';
 import PresentationIcon from '../Icons/PresentationIcon';
 
+const { isTopPage } = pagePathUtils;
+
 
 const PageManagement = (props) => {
   const {

+ 1 - 1
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -3,10 +3,10 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
+import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
-import UserPicture from '../User/UserPicture';
 import PutbackPageModal from '../PutbackPageModal';
 import EmptyTrashModal from '../EmptyTrashModal';
 import PageDeleteModal from '../PageDeleteModal';

+ 1 - 1
packages/app/src/components/PageAttachment/DeleteAttachmentModal.jsx

@@ -6,7 +6,7 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import UserPicture from '../User/UserPicture';
+import { UserPicture } from '@growi/ui';
 import Username from '../User/Username';
 
 export default class DeleteAttachmentModal extends React.Component {

+ 1 - 1
packages/app/src/components/PageAttachment/PageAttachmentList.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import Attachment from './Attachment';
+import { Attachment } from '@growi/ui';
 
 export default class PageAttachmentList extends React.Component {
 

+ 1 - 1
packages/app/src/components/PageComment/Comment.jsx

@@ -6,6 +6,7 @@ import { format } from 'date-fns';
 
 import { UncontrolledTooltip } from 'reactstrap';
 
+import { UserPicture } from '@growi/ui';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 
@@ -13,7 +14,6 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 
 import FormattedDistanceDate from '../FormattedDistanceDate';
 import RevisionBody from '../Page/RevisionBody';
-import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
 import CommentEditor from './CommentEditor';
 import CommentControl from './CommentControl';

+ 1 - 1
packages/app/src/components/PageComment/CommentEditor.jsx

@@ -8,6 +8,7 @@ import {
 
 import * as toastr from 'toastr';
 
+import { UserPicture } from '@growi/ui';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 import CommentContainer from '~/client/services/CommentContainer';
@@ -15,7 +16,6 @@ import EditorContainer from '~/client/services/EditorContainer';
 import GrowiRenderer from '~/client/util/GrowiRenderer';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
-import UserPicture from '../User/UserPicture';
 import Editor from '../PageEditor/Editor';
 import SlackNotification from '../SlackNotification';
 

+ 1 - 1
packages/app/src/components/PageComment/DeleteCommentModal.jsx

@@ -7,7 +7,7 @@ import {
 
 import { format } from 'date-fns';
 
-import UserPicture from '../User/UserPicture';
+import { UserPicture } from '@growi/ui';
 import Username from '../User/Username';
 
 export default class DeleteCommentModal extends React.Component {

+ 6 - 4
packages/app/src/components/PageCreateModal.jsx

@@ -7,10 +7,8 @@ import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
 import { format } from 'date-fns';
 
-import { pathUtils } from 'growi-commons';
-import {
-  userPageRoot, isCreatablePage, generateEditorPath,
-} from '~/utils/path-utils';
+import { pagePathUtils, pathUtils } from '@growi/core';
+
 
 import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
@@ -19,6 +17,10 @@ import { toastError } from '~/client/util/apiNotification';
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 
+const {
+  userPageRoot, isCreatablePage, generateEditorPath,
+} = pagePathUtils;
+
 const PageCreateModal = (props) => {
   const { t, appContainer, navigationContainer } = props;
 

+ 6 - 19
packages/app/src/components/PageEditor/LinkEditModal.jsx

@@ -13,17 +13,18 @@ import {
 import path from 'path';
 import validator from 'validator';
 import { withTranslation } from 'react-i18next';
-import PreviewWithSuspense from './PreviewWithSuspense';
-import PagePreviewIcon from '../Icons/PagePreviewIcon';
 
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
+import Linker from '~/client/models/Linker';
 
+import PreviewWithSuspense from './PreviewWithSuspense';
+import PagePreviewIcon from '../Icons/PagePreviewIcon';
 import SearchTypeahead from '../SearchTypeahead';
-import Linker from '~/client/models/Linker';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 
+
 class LinkEditModal extends React.PureComponent {
 
   constructor(props) {
@@ -175,22 +176,8 @@ class LinkEditModal extends React.PureComponent {
     this.setState({ markdown, previewError, permalink });
   }
 
-  getLinkForPreview() {
-    const linker = this.generateLink();
-
-    if (this.isUsePermanentLink && this.permalink != null) {
-      linker.link = this.permalink;
-    }
-
-    if (linker.label === '') {
-      linker.label = linker.link;
-    }
-
-    return linker;
-  }
-
   renderLinkPreview() {
-    const linker = this.getLinkForPreview();
+    const linker = this.generateLink();
     return (
       <div className="d-flex justify-content-between mb-3 flex-column flex-sm-row">
         <div className="card card-disabled w-100 p-1 mb-0">
@@ -245,7 +232,7 @@ class LinkEditModal extends React.PureComponent {
   }
 
   save() {
-    const linker = this.getLinkForPreview();
+    const linker = this.generateLink();
 
     if (this.props.onSave != null) {
       this.props.onSave(linker.generateMarkdownText());

+ 1 - 1
packages/app/src/components/PageHistory/Revision.jsx

@@ -1,9 +1,9 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { UserPicture } from '@growi/ui';
 import UserDate from '../User/UserDate';
 import Username from '../User/Username';
-import UserPicture from '../User/UserPicture';
 
 export default class Revision extends React.Component {
 

+ 2 - 3
packages/app/src/components/PageList/Page.jsx

@@ -1,9 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import UserPicture from '../User/UserPicture';
-import PageListMeta from './PageListMeta';
-import PagePathLabel from './PagePathLabel';
+import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
+
 
 export default class Page extends React.Component {
 

+ 0 - 24
packages/app/src/components/PageList/PagePath.jsx

@@ -1,24 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import PagePathLabel from './PagePathLabel';
-
-/**
- * !!DEPRECATED!!
- *
- * maintained for backward compatibility for growi-lsx-plugin(<= 3.1.1)
- */
-const PagePath = props => (
-  <PagePathLabel isLatterOnly={props.isShortPathOnly} {...props} />
-);
-
-PagePath.propTypes = {
-  isShortPathOnly: PropTypes.bool,
-  ...PagePathLabel.propTypes,
-};
-
-PagePath.defaultProps = {
-  ...PagePathLabel.defaultProps,
-};
-
-export default PagePath;

+ 1 - 1
packages/app/src/components/PagePathAutoComplete.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 
 import SearchTypeahead from './SearchTypeahead';
 

+ 3 - 1
packages/app/src/components/RevisionComparer/RevisionComparer.jsx

@@ -6,7 +6,7 @@ import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 
-import { encodeSpaces } from '~/utils/path-utils';
+import { pagePathUtils } from '@growi/core';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 
@@ -14,6 +14,8 @@ import RevisionComparerContainer from '~/client/services/RevisionComparerContain
 
 import RevisionDiff from '../PageHistory/RevisionDiff';
 
+const { encodeSpaces } = pagePathUtils;
+
 /* eslint-disable react/prop-types */
 const DropdownItemContents = ({ title, contents }) => (
   <>

+ 2 - 3
packages/app/src/components/SearchTypeahead.jsx

@@ -4,9 +4,8 @@ import PropTypes from 'prop-types';
 import { noop } from 'lodash/noop';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
-import UserPicture from './User/UserPicture';
-import PageListMeta from './PageList/PageListMeta';
-import PagePathLabel from './PageList/PagePathLabel';
+import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
+
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 

+ 3 - 3
packages/app/src/components/ShareLink/ShareLinkForm.jsx

@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
-import { dateFnsFormat, parse } from 'date-fns';
+import { format, parse } from 'date-fns';
 
 import { isInteger } from 'core-js/fn/number';
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -20,8 +20,8 @@ class ShareLinkForm extends React.Component {
       expirationType: 'unlimited',
       numberOfDays: '7',
       description: '',
-      customExpirationDate: dateFnsFormat(new Date(), 'yyyy-MM-dd'),
-      customExpirationTime: dateFnsFormat(new Date(), 'HH:mm'),
+      customExpirationDate: format(new Date(), 'yyyy-MM-dd'),
+      customExpirationTime: format(new Date(), 'HH:mm'),
     };
 
     this.handleChangeExpirationType = this.handleChangeExpirationType.bind(this);

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

@@ -3,10 +3,11 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
+import { UserPicture } from '@growi/ui';
+import { DevidedPagePath } from '@growi/core';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
 import loggerFactory from '~/utils/logger';
 
-import DevidedPagePath from '~/models/devided-page-path';
 import LinkedPagePath from '~/models/linked-page-path';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -14,7 +15,6 @@ import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
 
 import FormattedDistanceDate from '../FormattedDistanceDate';
-import UserPicture from '../User/UserPicture';
 
 const logger = loggerFactory('growi:History');
 class RecentChanges extends React.Component {

+ 1 - 1
packages/app/src/components/User/UserInfo.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import UserPicture from './UserPicture';
+import { UserPicture } from '@growi/ui';
 
 const UserInfo = (props) => {
   const { pageUser } = props;

+ 1 - 1
packages/app/src/components/User/UserPictureList.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import UserPicture from './UserPicture';
+import { UserPicture } from '@growi/ui';
 
 export default class UserPictureList extends React.Component {
 

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

@@ -1,5 +1,5 @@
 import mongoose from 'mongoose';
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 
 import config from '^/config/migrate';
 import loggerFactory from '~/utils/logger';

+ 3 - 6
packages/app/src/models/linked-page-path.js

@@ -1,12 +1,11 @@
-const { pathUtils } = require('growi-commons');
-const { isTrashPage } = require('~/utils/path-utils');
+import { pagePathUtils, DevidedPagePath, pathUtils } from '@growi/core';
 
-const DevidedPagePath = require('./devided-page-path');
+const { isTrashPage } = pagePathUtils;
 
 /**
  * Linked Array Structured PagePath Model
  */
-class LinkedPagePath {
+export default class LinkedPagePath {
 
   constructor(path, skipNormalize = false) {
 
@@ -34,5 +33,3 @@ class LinkedPagePath {
   }
 
 }
-
-module.exports = LinkedPagePath;

+ 6 - 1
packages/app/src/server/crowi/express-init.js

@@ -53,7 +53,12 @@ module.exports = function(crowi, app) {
       nsSeparator: '::',
     });
 
-  app.use(helmet());
+  app.use(helmet({
+    contentSecurityPolicy: false,
+    expectCt: false,
+    referrerPolicy: false,
+    permittedCrossDomainPolicies: false,
+  }));
 
   app.use((req, res, next) => {
     const now = new Date();

+ 1 - 1
packages/app/src/server/crowi/index.js

@@ -427,7 +427,7 @@ Crowi.prototype.start = async function() {
 
   // setup plugins
   this.pluginService = new PluginService(this, express);
-  this.pluginService.autoDetectAndLoadPlugins();
+  await this.pluginService.autoDetectAndLoadPlugins();
 
   const server = (this.node_env === 'development') ? this.crowiDev.setupServer(express) : express;
 

+ 3 - 4
packages/app/src/server/middlewares/admin-required.js

@@ -7,14 +7,13 @@ module.exports = (crowi, fallback = null) => {
   return async(req, res, next) => {
     if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
       if (req.user.admin) {
-        next();
-        return;
+        return next();
       }
 
       logger.warn('This user is not admin.');
 
       if (fallback != null) {
-        return fallback(req, res);
+        return fallback(req, res, next);
       }
       return res.redirect('/');
     }
@@ -22,7 +21,7 @@ module.exports = (crowi, fallback = null) => {
     logger.warn('This user has not logged in.');
 
     if (fallback != null) {
-      return fallback(req, res);
+      return fallback(req, res, next);
     }
     return res.redirect('/login');
   };

+ 5 - 3
packages/app/src/server/models/page.js

@@ -1,3 +1,4 @@
+import { templateChecker, pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 // disable no-return-await for model functions
@@ -15,8 +16,9 @@ const differenceInYears = require('date-fns/differenceInYears');
 
 const { pathUtils } = require('growi-commons');
 const escapeStringRegexp = require('escape-string-regexp');
-const templateChecker = require('~/utils/template-checker');
-const { isTopPage, isTrashPage } = require('~/utils/path-utils');
+
+const { isTopPage, isTrashPage } = pagePathUtils;
+const { checkTemplatePath } = templateChecker;
 
 const logger = loggerFactory('growi:models:page');
 
@@ -311,7 +313,7 @@ module.exports = function(crowi) {
   };
 
   pageSchema.methods.isTemplate = function() {
-    return templateChecker(this.path);
+    return checkTemplatePath(this.path);
   };
 
   pageSchema.methods.isLatestRevision = function() {

+ 2 - 0
packages/app/src/server/models/slack-app-integration.js

@@ -4,6 +4,8 @@ const mongoose = require('mongoose');
 const schema = new mongoose.Schema({
   tokenGtoP: { type: String, required: true, unique: true },
   tokenPtoG: { type: String, required: true, unique: true },
+  supportedCommandsForBroadcastUse: { type: [String], default: [] },
+  supportedCommandsForSingleUse: { type: [String], default: [] },
 });
 
 class SlackAppIntegration {

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

@@ -0,0 +1,22 @@
+/**
+ * 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;

+ 38 - 0
packages/app/src/server/plugins/plugin-utils-v4.ts

@@ -0,0 +1,38 @@
+import path from 'path';
+
+import { PluginMetaV4, PluginDefinitionV4 } from '@growi/core';
+
+export class PluginUtilsV4 {
+
+  /**
+   * return a definition objects that has following structure:
+   *
+   * {
+   *   name: 'crowi-plugin-X',
+   *   meta: require('crowi-plugin-X'),
+   *   entries: [
+   *     'crowi-plugin-X/lib/client-entry'
+   *   ]
+   * }
+   *
+   *
+   * @param {string} pluginName
+   * @return
+   * @memberOf PluginService
+   */
+  async generatePluginDefinition(name: string, isForClient = false): Promise<PluginDefinitionV4> {
+    const meta: PluginMetaV4 = await import(name);
+    let entries = (isForClient) ? meta.clientEntries : meta.serverEntries;
+
+    entries = entries.map((entryPath) => {
+      return path.join(name, entryPath);
+    });
+
+    return {
+      name,
+      meta,
+      entries,
+    };
+  }
+
+}

+ 15 - 7
packages/app/src/server/plugins/plugin-utils.js

@@ -1,13 +1,13 @@
 import loggerFactory from '~/utils/logger';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 
-const fs = require('graceful-fs');
+import { PluginUtilsV4 } from './plugin-utils-v4';
 
-const PluginUtilsV2 = require('./plugin-utils-v2');
+const fs = require('graceful-fs');
 
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
-const pluginUtilsV2 = new PluginUtilsV2();
+const pluginUtilsV4 = new PluginUtilsV4();
 
 class PluginUtils {
 
@@ -26,19 +26,27 @@ class PluginUtils {
    * @return
    * @memberOf PluginService
    */
-  generatePluginDefinition(name, isForClient = false) {
+  async generatePluginDefinition(name, isForClient = false) {
     const meta = require(name);
     let definition;
 
     switch (meta.pluginSchemaVersion) {
-      // v1 is deprecated
+      // v1, v2 and v3 is deprecated
       case 1:
         logger.debug('pluginSchemaVersion 1 is deprecated');
         break;
-      // v2 or above
       case 2:
+        logger.debug('pluginSchemaVersion 2 is deprecated');
+        break;
+      case 3:
+        logger.debug('pluginSchemaVersion 3 is deprecated');
+        break;
+      // v4 or above
+      case 4:
+        definition = await pluginUtilsV4.generatePluginDefinition(name, isForClient);
+        break;
       default:
-        definition = pluginUtilsV2.generatePluginDefinition(name, isForClient);
+        logger.warn('Unsupported schema version', meta.pluginSchemaVersion);
     }
 
     return definition;

+ 16 - 11
packages/app/src/server/plugins/plugin.service.js

@@ -12,13 +12,13 @@ class PluginService {
     this.pluginUtils = new PluginUtils();
   }
 
-  autoDetectAndLoadPlugins() {
+  async autoDetectAndLoadPlugins() {
     const isEnabledPlugins = this.crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins');
 
     // import plugins
     if (isEnabledPlugins) {
       logger.debug('Plugins are enabled');
-      this.loadPlugins(this.pluginUtils.listPluginNames(this.crowi.rootDir));
+      return this.loadPlugins(this.pluginUtils.listPluginNames(this.crowi.rootDir));
     }
 
   }
@@ -28,29 +28,34 @@ class PluginService {
    *
    * @memberOf PluginService
    */
-  loadPlugins(pluginNames) {
-    pluginNames
-      .map((name) => {
-        return this.pluginUtils.generatePluginDefinition(name);
-      })
-      .forEach((definition) => {
+  async loadPlugins(pluginNames) {
+    // get definitions
+    const definitions = [];
+    for (const pluginName of pluginNames) {
+      // eslint-disable-next-line no-await-in-loop
+      const definition = await this.pluginUtils.generatePluginDefinition(pluginName);
+      if (definition != null) {
         this.loadPlugin(definition);
-      });
+      }
+    }
   }
 
   loadPlugin(definition) {
     const meta = definition.meta;
 
     switch (meta.pluginSchemaVersion) {
-      // v1 is deprecated
+      // v1, v2 and v3 is deprecated
       case 1:
         logger.warn('pluginSchemaVersion 1 is deprecated', definition);
         break;
-      // v2 is deprecated
       case 2:
         logger.warn('pluginSchemaVersion 2 is deprecated', definition);
         break;
       case 3:
+        logger.warn('pluginSchemaVersion 3 is deprecated', definition);
+        break;
+      // v4 or above
+      case 4:
         logger.info(`load plugin '${definition.name}'`);
         definition.entries.forEach((entryPath) => {
           const entry = require(entryPath);

+ 2 - 1
packages/app/src/server/routes/apiv3/admin-home.js

@@ -1,6 +1,7 @@
+import ConfigLoader from '../../service/config-loader';
+
 const express = require('express');
 const PluginUtils = require('../../plugins/plugin-utils');
-const ConfigLoader = require('../../service/config-loader');
 
 const pluginUtils = new PluginUtils();
 

+ 2 - 2
packages/app/src/server/routes/apiv3/healthcheck.js

@@ -6,7 +6,7 @@ const express = require('express');
 
 const router = express.Router();
 
-const helmet = require('helmet');
+const noCache = require('nocache');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 /**
@@ -122,7 +122,7 @@ module.exports = (crowi) => {
    *                  info:
    *                    $ref: '#/components/schemas/HealthcheckInfo'
    */
-  router.get('/', helmet.noCache(), async(req, res) => {
+  router.get('/', noCache(), async(req, res) => {
     let checkServices = req.query.checkServices || [];
     let isStrictly = req.query.strictly != null;
 

+ 3 - 2
packages/app/src/server/routes/apiv3/page.js

@@ -1,13 +1,14 @@
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
+
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
 const { body, query } = require('express-validator');
 
 const router = express.Router();
-
-const { convertToNewAffiliationPath } = require('~/utils/path-utils');
+const { convertToNewAffiliationPath } = pagePathUtils;
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 

+ 7 - 2
packages/app/src/server/routes/apiv3/pages.js

@@ -1,3 +1,4 @@
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
@@ -6,9 +7,11 @@ const pathUtils = require('growi-commons').pathUtils;
 
 const { body } = require('express-validator');
 const { query } = require('express-validator');
-const { isCreatablePage } = require('~/utils/path-utils');
+
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
+const { isCreatablePage } = pagePathUtils;
+
 const router = express.Router();
 
 const LIMIT_FOR_LIST = 10;
@@ -458,7 +461,9 @@ module.exports = (crowi) => {
   ];
 
   router.get('/list', accessTokenParser, loginRequired, validator.displayList, apiV3FormValidator, async(req, res) => {
-    const { isTrashPage } = require('~/utils/path-utils');
+
+
+    const { isTrashPage } = pagePathUtils;
 
     const { path } = req.query;
     const limit = parseInt(req.query.limit) || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS') || 10;

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

@@ -7,7 +7,7 @@ const { body } = require('express-validator');
 
 const router = express.Router();
 
-const helmet = require('helmet');
+const noCache = require('nocache');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
@@ -41,7 +41,7 @@ module.exports = (crowi) => {
    *                  info:
    *                    type: object
    */
-  router.get('/indices', helmet.noCache(), accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.get('/indices', noCache(), accessTokenParser, loginRequired, adminRequired, async(req, res) => {
     const { searchService } = crowi;
 
     if (!searchService.isConfigured) {

+ 75 - 11
packages/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -2,11 +2,13 @@ import loggerFactory from '~/utils/logger';
 
 const mongoose = require('mongoose');
 const express = require('express');
-const { body, query } = require('express-validator');
+const { body, query, param } = require('express-validator');
 const axios = require('axios');
 const urljoin = require('url-join');
 
-const { getConnectionStatus, getConnectionStatuses, sendSuccessMessage } = require('@growi/slack');
+const {
+  getConnectionStatus, getConnectionStatuses, sendSuccessMessage, defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse,
+} = require('@growi/slack');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
@@ -60,6 +62,11 @@ module.exports = (crowi) => {
       body('proxyUri').if(value => value !== '').trim().matches(/^(https?:\/\/)/)
         .isURL({ require_tld: false }),
     ],
+    updateSupportedCommands: [
+      body('supportedCommandsForSingleUse').toArray(),
+      body('supportedCommandsForBroadcastUse').toArray(),
+      param('id').isMongoId().withMessage('id is required'),
+    ],
     RelationTest: [
       body('slackAppIntegrationId').isMongoId(),
       body('channel').trim().isString(),
@@ -106,17 +113,17 @@ module.exports = (crowi) => {
     return result.data;
   }
 
-  async function postRelationTest(token) {
+  async function requestToProxyServer(token, method, endpoint, body) {
     const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
     if (proxyUri == null) {
       throw new Error('Proxy URL is not registered');
     }
 
-    const result = await axios.get(urljoin(proxyUri, '/g2s/relation-test'), {
-      headers: {
-        'x-growi-gtop-tokens': token,
-      },
-    });
+    const headers = {
+      'x-growi-gtop-tokens': token,
+    };
+
+    const result = await axios[method](urljoin(proxyUri, endpoint), body, { headers });
 
     return result.data;
   }
@@ -401,9 +408,13 @@ module.exports = (crowi) => {
     }
 
     const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
-
     try {
-      const slackAppTokens = await SlackAppIntegration.create({ tokenGtoP, tokenPtoG });
+      const slackAppTokens = await SlackAppIntegration.create({
+        tokenGtoP,
+        tokenPtoG,
+        supportedCommandsForBroadcastUse: defaultSupportedCommandsNameForBroadcastUse,
+        supportedCommandsForSingleUse: defaultSupportedCommandsNameForSingleUse,
+      });
       return res.apiv3(slackAppTokens, 200);
     }
     catch (error) {
@@ -488,6 +499,49 @@ module.exports = (crowi) => {
 
   });
 
+  /**
+   * @swagger
+   *
+   *    /slack-integration-settings/:id/supported-commands:
+   *      put:
+   *        tags: [SlackIntegration]
+   *        operationId: putSupportedCommands
+   *        summary: /slack-integration-settings/:id/supported-commands
+   *        description: update supported commands
+   *        responses:
+   *          200:
+   *            description: Succeeded to update supported commands
+   */
+  router.put('/:id/supported-commands', loginRequiredStrictly, adminRequired, csrf, validator.updateSupportedCommands, apiV3FormValidator, async(req, res) => {
+    const { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse } = req.body;
+    const { id } = req.params;
+
+    try {
+      const slackAppIntegration = await SlackAppIntegration.findByIdAndUpdate(
+        id,
+        { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse },
+        { new: true },
+      );
+
+      await requestToProxyServer(
+        slackAppIntegration.tokenGtoP,
+        'put',
+        '/g2s/supported-commands',
+        {
+          supportedCommandsForBroadcastUse: slackAppIntegration.supportedCommandsForBroadcastUse,
+          supportedCommandsForSingleUse: slackAppIntegration.supportedCommandsForSingleUse,
+        },
+      );
+
+      return res.apiv3({ slackAppIntegration });
+    }
+    catch (error) {
+      const msg = 'Error occured in updating Custom bot setting';
+      logger.error('Error', error);
+      return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+    }
+  });
+
   /**
    * @swagger
    *
@@ -523,7 +577,17 @@ module.exports = (crowi) => {
         const msg = 'Could not find SlackAppIntegration by id';
         return res.apiv3Err(new ErrorV3(msg, 'find-slackAppIntegration-failed'), 400);
       }
-      const result = await postRelationTest(slackAppIntegration.tokenGtoP);
+
+      const result = await requestToProxyServer(
+        slackAppIntegration.tokenGtoP,
+        'post',
+        '/g2s/relation-test',
+        {
+          supportedCommandsForBroadcastUse: slackAppIntegration.supportedCommandsForBroadcastUse,
+          supportedCommandsForSingleUse: slackAppIntegration.supportedCommandsForSingleUse,
+        },
+      );
+
       slackBotToken = result.slackBotToken;
       if (slackBotToken == null) {
         const msg = 'Could not find slackBotToken by relation';

+ 76 - 63
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -4,11 +4,12 @@ const express = require('express');
 const mongoose = require('mongoose');
 const urljoin = require('url-join');
 
-const { verifySlackRequest, generateWebClient } = require('@growi/slack');
+const { verifySlackRequest, generateWebClient, getSupportedGrowiActionsRegExps } = 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');
 
 module.exports = (crowi) => {
   this.app = crowi.express;
@@ -43,6 +44,55 @@ module.exports = (crowi) => {
     next();
   }
 
+  async function checkCommandPermission(req, res, next) {
+    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+
+    const relation = await SlackAppIntegration.findOne({ tokenPtoG });
+    const { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse } = relation;
+    const supportedCommands = supportedCommandsForBroadcastUse.concat(supportedCommandsForSingleUse);
+    const supportedGrowiActionsRegExps = getSupportedGrowiActionsRegExps(supportedCommands);
+
+    // get command name from req.body
+    let command = '';
+    let actionId = '';
+    let callbackId = '';
+    let payload;
+    if (req.body.payload) {
+      payload = JSON.parse(req.body.payload);
+    }
+
+    if (req.body.text == null && !payload) { // when /relation-test
+      return next();
+    }
+
+    if (!payload) { // when request is to /commands
+      command = req.body.text.split(' ')[0];
+    }
+    else if (payload.actions) { // when request is to /interactions && block_actions
+      actionId = payload.actions[0].action_id;
+    }
+    else { // when request is to /interactions && view_submission
+      callbackId = payload.view.callback_id;
+    }
+
+    let isActionSupported = false;
+    supportedGrowiActionsRegExps.forEach((regexp) => {
+      if (regexp.test(actionId) || regexp.test(callbackId)) {
+        isActionSupported = true;
+      }
+    });
+
+    // validate
+    if (command && !supportedCommands.includes(command)) {
+      return res.status(403).send(`It is not allowed to run '${command}' command to this GROWI.`);
+    }
+    if ((actionId || callbackId) && !isActionSupported) {
+      return res.status(403).send(`It is not allowed to run '${command}' command to this GROWI.`);
+    }
+
+    next();
+  }
+
   const addSigningSecretToReq = (req, res, next) => {
     req.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:signingSecret');
     return next();
@@ -104,18 +154,19 @@ module.exports = (crowi) => {
     const command = args[0];
 
     try {
-      await crowi.slackBotService.handleCommand(command, client, body, args);
+      await crowi.slackBotService.handleCommandRequest(command, client, body, args);
     }
-    catch (error) {
-      logger.error(error);
+    catch (err) {
+      await respondIfSlackbotError(client, body, err);
     }
+
   }
 
   router.post('/commands', addSigningSecretToReq, verifySlackRequest, async(req, res) => {
     return handleCommands(req, res);
   });
 
-  router.post('/proxied/commands', verifyAccessTokenFromProxy, async(req, res) => {
+  router.post('/proxied/commands', verifyAccessTokenFromProxy, checkCommandPermission, async(req, res) => {
     const { body } = req;
 
     // eslint-disable-next-line max-len
@@ -127,61 +178,6 @@ module.exports = (crowi) => {
     return handleCommands(req, res);
   });
 
-
-  const handleBlockActions = async(client, payload) => {
-    const { action_id: actionId } = payload.actions[0];
-
-    switch (actionId) {
-      case 'shareSingleSearchResult': {
-        await crowi.slackBotService.shareSinglePage(client, payload);
-        break;
-      }
-      case 'dismissSearchResults': {
-        await crowi.slackBotService.dismissSearchResults(client, payload);
-        break;
-      }
-      case 'showNextResults': {
-        const parsedValue = JSON.parse(payload.actions[0].value);
-
-        const { body, args, offset } = parsedValue;
-        const newOffset = offset + 10;
-        await crowi.slackBotService.showEphemeralSearchResults(client, body, args, newOffset);
-        break;
-      }
-      case 'togetterShowMore': {
-        const parsedValue = JSON.parse(payload.actions[0].value);
-        const togetterHandler = require('../../service/slack-command-handler/togetter')(crowi);
-
-        const { body, args, limit } = parsedValue;
-        const newLimit = limit + 1;
-        await togetterHandler.handleCommand(client, body, args, newLimit);
-        break;
-      }
-      case 'togetter:createPage': {
-        await crowi.slackBotService.togetterCreatePageInGrowi(client, payload);
-        break;
-      }
-      case 'togetter:cancel': {
-        await crowi.slackBotService.togetterCancel(client, payload);
-        break;
-      }
-      default:
-        break;
-    }
-  };
-
-  const handleViewSubmission = async(client, payload) => {
-    const { callback_id: callbackId } = payload.view;
-
-    switch (callbackId) {
-      case 'createPage':
-        await crowi.slackBotService.createPageInGrowi(client, payload);
-        break;
-      default:
-        break;
-    }
-  };
-
   async function handleInteractions(req, res) {
 
     // Send response immediately to avoid opelation_timeout error
@@ -206,10 +202,20 @@ module.exports = (crowi) => {
     try {
       switch (type) {
         case 'block_actions':
-          await handleBlockActions(client, payload);
+          try {
+            await crowi.slackBotService.handleBlockActionsRequest(client, payload);
+          }
+          catch (err) {
+            await respondIfSlackbotError(client, req.body, err);
+          }
           break;
         case 'view_submission':
-          await handleViewSubmission(client, payload);
+          try {
+            await crowi.slackBotService.handleViewSubmissionRequest(client, payload);
+          }
+          catch (err) {
+            await respondIfSlackbotError(client, req.body, err);
+          }
           break;
         default:
           break;
@@ -225,9 +231,16 @@ module.exports = (crowi) => {
     return handleInteractions(req, res);
   });
 
-  router.post('/proxied/interactions', verifyAccessTokenFromProxy, async(req, res) => {
+  router.post('/proxied/interactions', verifyAccessTokenFromProxy, checkCommandPermission, async(req, res) => {
     return handleInteractions(req, res);
   });
 
+  router.get('/supported-commands', verifyAccessTokenFromProxy, async(req, res) => {
+    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+    const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
+
+    return res.send(slackAppIntegration);
+  });
+
   return router;
 };

+ 2 - 2
packages/app/src/server/routes/apiv3/statistics.js

@@ -6,7 +6,7 @@ const express = require('express');
 
 const router = express.Router();
 
-const helmet = require('helmet');
+const noCache = require('nocache');
 
 const USER_STATUS_MASTER = {
   1: 'registered',
@@ -97,7 +97,7 @@ module.exports = (crowi) => {
    *                    type: object
    *                    description: Statistics for all user
    */
-  router.get('/user', helmet.noCache(), async(req, res) => {
+  router.get('/user', noCache(), async(req, res) => {
     const data = req.user == null ? await getUserStatisticsForNotLoggedIn() : await getUserStatistics();
     res.status(200).send({ data });
   });

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

@@ -1,6 +1,7 @@
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
-const { isCreatablePage } = require('~/utils/path-utils');
+const { isCreatablePage } = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
 const { serializeUserSecurely } = require('../models/serializers/user-serializer');

+ 2 - 2
packages/app/src/server/service/app.ts

@@ -1,4 +1,4 @@
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 
 import loggerFactory from '~/utils/logger';
 
@@ -119,7 +119,7 @@ class AppService implements S2sMessageHandlable {
   }
 
   async setupAfterInstall() {
-    this.crowi.pluginService.autoDetectAndLoadPlugins();
+    await this.crowi.pluginService.autoDetectAndLoadPlugins();
     this.crowi.setupRoutesAtLast();
     this.crowi.setupGlobalErrorHandlers();
 

+ 1 - 1
packages/app/src/server/service/customize.ts

@@ -1,8 +1,8 @@
 // eslint-disable-next-line no-unused-vars
 import uglifycss from 'uglifycss';
 
+import { DevidedPagePath } from '@growi/core';
 import loggerFactory from '~/utils/logger';
-import DevidedPagePath from '~/models/devided-page-path';
 
 import S2sMessage from '../models/vo/s2s-message';
 import { S2sMessageHandlable } from './s2s-messaging/handlable';

+ 3 - 2
packages/app/src/server/service/export.js

@@ -1,6 +1,8 @@
 import loggerFactory from '~/utils/logger';
 import { toArrayIfNot } from '~/utils/array-utils';
 
+import ConfigLoader from './config-loader';
+
 const logger = loggerFactory('growi:services:ExportService'); // eslint-disable-line no-unused-vars
 
 const fs = require('fs');
@@ -9,7 +11,6 @@ const mongoose = require('mongoose');
 const { Transform } = require('stream');
 const streamToPromise = require('stream-to-promise');
 const archiver = require('archiver');
-const ConfigLoader = require('../service/config-loader');
 
 const CollectionProgressingStatus = require('../models/vo/collection-progressing-status');
 
@@ -91,7 +92,7 @@ class ExportService {
       url: this.appService.getSiteUrl(),
       passwordSeed,
       exportedAt: new Date(),
-      envVars: ConfigLoader.getEnvVarsForDisplay(),
+      envVars: await ConfigLoader.getEnvVarsForDisplay(),
     };
 
     writeStream.write(JSON.stringify(metaData));

+ 3 - 1
packages/app/src/server/service/global-notification/global-notification-slack.js

@@ -1,9 +1,11 @@
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
+
 const logger = loggerFactory('growi:service:GlobalNotificationSlackService'); // eslint-disable-line no-unused-vars
 const urljoin = require('url-join');
 
-const { encodeSpaces } = require('~/utils/path-utils');
+const { encodeSpaces } = pagePathUtils;
 
 /**
  * sub service class of GlobalNotificationSetting

+ 3 - 1
packages/app/src/server/service/page.js

@@ -1,3 +1,4 @@
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 const mongoose = require('mongoose');
@@ -7,7 +8,8 @@ const logger = loggerFactory('growi:models:page');
 const debug = require('debug')('growi:models:page');
 const { Writable } = require('stream');
 const { createBatchStream } = require('~/server/util/batch-stream');
-const { isTrashPage } = require('~/utils/path-utils');
+
+const { isTrashPage } = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 
 const BULK_REINDEX_SIZE = 100;

+ 48 - 0
packages/app/src/server/service/slack-command-handler/create-page-service.js

@@ -0,0 +1,48 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:CreatePageService');
+const { reshapeContentsBody } = require('@growi/slack');
+const mongoose = require('mongoose');
+const pathUtils = require('growi-commons').pathUtils;
+const SlackbotError = require('../../models/vo/slackbot-error');
+
+class CreatePageService {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  async createPageInGrowi(client, payload, path, channelId, 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}`,
+      });
+    }
+  }
+
+}
+
+module.exports = CreatePageService;

+ 37 - 38
packages/app/src/server/service/slack-command-handler/create.js

@@ -4,51 +4,50 @@ const { markdownSectionBlock, inputSectionBlock } = require('@growi/slack');
 
 const logger = loggerFactory('growi:service:SlackCommandHandler:create');
 
-module.exports = () => {
+module.exports = (crowi) => {
+  const CreatePageService = require('./create-page-service');
+  const createPageService = new CreatePageService(crowi);
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
 
   handler.handleCommand = async(client, body) => {
-    try {
-      await client.views.open({
-        trigger_id: body.trigger_id,
-
-        view: {
-          type: 'modal',
-          callback_id: 'createPage',
-          title: {
-            type: 'plain_text',
-            text: 'Create Page',
-          },
-          submit: {
-            type: 'plain_text',
-            text: 'Submit',
-          },
-          close: {
-            type: 'plain_text',
-            text: 'Cancel',
-          },
-          blocks: [
-            markdownSectionBlock('Create new page.'),
-            inputSectionBlock('path', 'Path', 'path_input', false, '/path'),
-            inputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
-          ],
-          private_metadata: JSON.stringify({ channelId: body.channel_id }),
+    await client.views.open({
+      trigger_id: body.trigger_id,
+
+      view: {
+        type: 'modal',
+        callback_id: 'create:createPage',
+        title: {
+          type: 'plain_text',
+          text: 'Create Page',
+        },
+        submit: {
+          type: 'plain_text',
+          text: 'Submit',
+        },
+        close: {
+          type: 'plain_text',
+          text: 'Cancel',
         },
-      });
-    }
-    catch (err) {
-      logger.error('Failed to create a page.');
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Failed To Create',
         blocks: [
-          markdownSectionBlock(`*Failed to create new page.*\n ${err}`),
+          markdownSectionBlock('Create new page.'),
+          inputSectionBlock('path', 'Path', 'path_input', false, '/path'),
+          inputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
         ],
-      });
-      throw err;
-    }
+        private_metadata: JSON.stringify({ channelId: body.channel_id }),
+      },
+    });
+  };
+
+  handler.handleBlockActions = async function(client, payload, handlerMethodName) {
+    await this[handlerMethodName](client, payload);
+  };
+
+  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);
   };
 
   return handler;

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

@@ -0,0 +1,66 @@
+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 };

+ 199 - 30
packages/app/src/server/service/slack-command-handler/search.js

@@ -4,6 +4,8 @@ const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 
 const { markdownSectionBlock, divider } = require('@growi/slack');
 const { formatDistanceStrict } = require('date-fns');
+const axios = require('axios');
+const SlackbotError = require('../../models/vo/slackbot-error');
 
 const PAGINGLIMIT = 10;
 
@@ -18,19 +20,16 @@ module.exports = (crowi) => {
     }
     catch (err) {
       logger.error('Failed to get search results.', err);
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Failed To Search',
-        blocks: [
-          markdownSectionBlock('*Failed to search.*\n Hint\n `/growi search [keyword]`'),
-        ],
+      throw new SlackbotError({
+        method: 'postEphemeral',
+        to: 'channel',
+        popupMessage: 'Failed To Search',
+        mainMessage: '*Failed to search.*\n Hint\n `/growi search [keyword]`',
       });
-      throw new Error('/growi command:search: Failed to search');
     }
 
-    const appUrl = this.crowi.appService.getSiteUrl();
-    const appTitle = this.crowi.appService.getAppTitle();
+    const appUrl = crowi.appService.getSiteUrl();
+    const appTitle = crowi.appService.getAppTitle();
 
     const {
       pages, offset, resultsTotal,
@@ -83,7 +82,7 @@ module.exports = (crowi) => {
           },
           accessory: {
             type: 'button',
-            action_id: 'shareSingleSearchResult',
+            action_id: 'search:shareSinglePageResult',
             text: {
               type: 'plain_text',
               text: 'Share',
@@ -121,7 +120,7 @@ module.exports = (crowi) => {
             text: 'Dismiss',
           },
           style: 'danger',
-          action_id: 'dismissSearchResults',
+          action_id: 'search:dismissSearchResults',
         },
       ],
     };
@@ -134,33 +133,203 @@ module.exports = (crowi) => {
             type: 'plain_text',
             text: 'Next',
           },
-          action_id: 'showNextResults',
+          action_id: 'search:showNextResults',
           value: JSON.stringify({ offset, body, args }),
         },
       );
     }
     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();
+
+    const channelId = channel.id;
+    const action = actions[0]; // shareSinglePage action must have button action
+
+    // restore page data from value
+    const { page, href, pathname } = JSON.parse(action.value);
+    const { updatedAt, commentCount } = page;
+
+    // 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}*`,
+            },
+          ],
+        },
+      ],
+    });
+  };
+
+  handler.showNextResults = async function(client, payload) {
+    const parsedValue = JSON.parse(payload.actions[0].value);
+
+    const { body, args, offsetNum } = parsedValue;
+    const newOffsetNum = offsetNum + 10;
+    let searchResult;
     try {
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Successed To Search',
-        blocks,
-      });
+      searchResult = await this.retrieveSearchResults(client, body, args, newOffsetNum);
     }
     catch (err) {
-      logger.error('Failed to post ephemeral message.', err);
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Failed to post ephemeral message.',
-        blocks: [
-          markdownSectionBlock(err.toString()),
-        ],
+      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]`',
       });
-      throw new Error(err);
     }
+
+    const appUrl = crowi.appService.getSiteUrl();
+    const appTitle = crowi.appService.getAppTitle();
+
+    const {
+      pages, offset, resultsTotal,
+    } = searchResult;
+
+    const keywords = this.getKeywords(args);
+
+
+    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`,
+        },
+      ],
+    };
+
+    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;
+
+        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: 'shareSingleSearchResult',
+            text: {
+              type: 'plain_text',
+              text: 'Share',
+            },
+            value: JSON.stringify({ page, href, pathname }),
+          },
+        };
+      }),
+      { type: 'divider' },
+      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: [
+        {
+          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 }),
+        },
+      );
+    }
+    blocks.push(actionBlocks);
+
+    await client.chat.postEphemeral({
+      channel: body.channel_id,
+      user: body.user_id,
+      text: 'Successed To Search',
+      blocks,
+    });
+  };
+
+  handler.dismissSearchResults = async function(client, payload) {
+    const { response_url: responseUrl } = payload;
+
+    return axios.post(responseUrl, {
+      delete_original: true,
+    });
   };
 
   handler.retrieveSearchResults = async function(client, body, args, offset = 0) {
@@ -174,12 +343,12 @@ module.exports = (crowi) => {
           markdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
         ],
       });
-      return;
+      return { pages: [] };
     }
 
     const keywords = this.getKeywords(args);
 
-    const { searchService } = this.crowi;
+    const { searchService } = crowi;
     const options = { limit: 10, offset };
     const results = await searchService.searchKeyword(keywords, null, {}, options);
     const resultsTotal = results.meta.total;

+ 10 - 0
packages/app/src/server/service/slack-command-handler/slack-command-handler.js

@@ -10,6 +10,16 @@ class BaseSlackCommandHandler {
    */
   handleCommand(client, body, ...opt) { throw new Error('Implement this') }
 
+  /**
+   * Handle /interactions endpoint 'block_actions'
+   */
+  handleBlockActions(client, payload, handlerMethodName) { throw new Error('Implement this') }
+
+  /**
+   * Handle /interactions endpoint 'view_submission'
+   */
+  handleViewSubmission(client, payload, handlerMethodName) { throw new Error('Implement this') }
+
 }
 
 module.exports = BaseSlackCommandHandler;

+ 173 - 2
packages/app/src/server/service/slack-command-handler/togetter.js

@@ -1,9 +1,16 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:SlackBotService:togetter');
 const {
-  inputBlock, actionsBlock, buttonElement, markdownSectionBlock,
+  inputBlock, actionsBlock, buttonElement, markdownSectionBlock, divider,
 } = require('@growi/slack');
-const { format } = require('date-fns');
+const { parse, format } = require('date-fns');
+const axios = require('axios');
+const SlackbotError = require('../../models/vo/slackbot-error');
 
 module.exports = (crowi) => {
+  const CreatePageService = require('./create-page-service');
+  const createPageService = new CreatePageService(crowi);
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
 
@@ -23,6 +30,170 @@ module.exports = (crowi) => {
     return;
   };
 
+  handler.handleBlockActions = async function(client, payload, handlerMethodName) {
+    await this[handlerMethodName](client, payload);
+  };
+
+  handler.cancel = async function(client, payload) {
+    const responseUrl = payload.response_url;
+    axios.post(responseUrl, {
+      delete_original: true,
+    });
+  };
+
+  handler.createPage = async function(client, payload) {
+    let result = [];
+    const channel = payload.channel.id;
+    try {
+      // validate form
+      const { path, oldest, latest } = await this.togetterValidateForm(client, payload);
+      // get messages
+      result = await this.togetterGetMessages(client, payload, channel, path, latest, oldest);
+      // clean messages
+      const cleanedContents = await this.togetterCleanMessages(result.messages);
+
+      const contentsBody = cleanedContents.join('');
+      // create and send url message
+      await this.togetterCreatePageAndSendPreview(client, payload, path, channel, contentsBody);
+    }
+    catch (err) {
+      logger.error('Error occured by togetter.');
+      throw err;
+    }
+  };
+
+  handler.togetterValidateForm = async function(client, payload) {
+    const grwTzoffset = crowi.appService.getTzoffset() * 60;
+    const path = payload.state.values.page_path.page_path.value;
+    let oldest = payload.state.values.oldest.oldest.value;
+    let latest = payload.state.values.latest.latest.value;
+    oldest = oldest.trim();
+    latest = latest.trim();
+    if (!path) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Page path is required.',
+        mainMessage: 'Page path is required.',
+      });
+    }
+    /**
+     * RegExp for datetime yyyy/MM/dd-HH:mm
+     * @see https://regex101.com/r/XbxdNo/1
+     */
+    const regexpDatetime = new RegExp(/^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/);
+
+    if (!regexpDatetime.test(oldest)) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Datetime format for oldest must be yyyy/MM/dd-HH:mm',
+        mainMessage: 'Datetime format for oldest must be yyyy/MM/dd-HH:mm',
+      });
+    }
+    if (!regexpDatetime.test(latest)) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Datetime format for latest must be yyyy/MM/dd-HH:mm',
+        mainMessage: 'Datetime format for latest must be yyyy/MM/dd-HH:mm',
+      });
+    }
+    oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset;
+    // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
+    latest = parse(latest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60;
+
+    if (oldest > latest) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Oldest datetime must be older than the latest date time.',
+        mainMessage: 'Oldest datetime must be older than the latest date time.',
+      });
+    }
+
+    return { path, oldest, latest };
+  };
+
+  handler.togetterGetMessages = async function(client, payload, channel, path, latest, oldest) {
+    const result = await client.conversations.history({
+      channel,
+      latest,
+      oldest,
+      limit: 100,
+      inclusive: true,
+    });
+
+    // return if no message found
+    if (!result.messages.length) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'No message found from togetter command. Try different datetime.',
+        mainMessage: 'No message found from togetter command. Try different datetime.',
+      });
+    }
+    return result;
+  };
+
+  handler.togetterCleanMessages = async function(messages) {
+    const cleanedContents = [];
+    let lastMessage = {};
+    const grwTzoffset = crowi.appService.getTzoffset() * 60;
+    messages
+      .sort((a, b) => {
+        return a.ts - b.ts;
+      })
+      .forEach((message) => {
+        // increment contentsBody while removing the same headers
+        // exclude header
+        const lastMessageTs = Math.floor(lastMessage.ts / 60);
+        const messageTs = Math.floor(message.ts / 60);
+        if (lastMessage.user === message.user && lastMessageTs === messageTs) {
+          cleanedContents.push(`${message.text}\n`);
+        }
+        // include header
+        else {
+          const ts = (parseInt(message.ts) - grwTzoffset) * 1000;
+          const time = format(new Date(ts), 'h:mm a');
+          cleanedContents.push(`${message.user}  ${time}\n${message.text}\n`);
+          lastMessage = message;
+        }
+      });
+    return cleanedContents;
+  };
+
+  handler.togetterCreatePageAndSendPreview = async function(client, payload, path, channel, contentsBody) {
+    try {
+      await createPageService.createPageInGrowi(client, payload, path, channel, contentsBody);
+      // send preview to dm
+      await client.chat.postMessage({
+        channel: payload.user.id,
+        text: 'Preview from togetter command',
+        blocks: [
+          markdownSectionBlock('*Preview*'),
+          divider(),
+          markdownSectionBlock(contentsBody),
+          divider(),
+        ],
+      });
+      // dismiss message
+      const responseUrl = payload.response_url;
+      axios.post(responseUrl, {
+        delete_original: true,
+      });
+    }
+    catch (err) {
+      logger.error('Error occurred while creating a page.', err);
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Error occurred while creating a page.',
+        mainMessage: 'Error occurred while creating a page.',
+      });
+    }
+  };
+
   handler.togetterMessageBlocks = function(messages, body, args, limit) {
     return [
       markdownSectionBlock('Select the oldest and latest datetime of the messages to use.'),

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