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

Merge branch 'feat/page-rename-v5' into feat/page-redirect-route

Taichi Masuyama 4 лет назад
Родитель
Сommit
aa9feda5fd
100 измененных файлов с 3304 добавлено и 1097 удалено
  1. 3 1
      .devcontainer/Dockerfile
  2. 10 0
      .eslintrc.js
  3. 44 0
      .github/workflows/ci-app-prod.yml
  4. 37 95
      .github/workflows/ci-app.yml
  5. 22 12
      .github/workflows/ci-slackbot-proxy.yml
  6. 275 0
      .github/workflows/reusable-app-prod.yml
  7. 91 0
      .github/workflows/reusable-app-reg-suit.yml
  8. 44 1
      CHANGELOG.md
  9. 3 1
      README.md
  10. 3 1
      README_JP.md
  11. 53 0
      bin/github-actions/generate-cypress-spec-arg.js
  12. 9 1
      package.json
  13. 2 0
      packages/app/.env.development
  14. 12 0
      packages/app/.eslintrc.js
  15. 5 0
      packages/app/.gitignore
  16. 7 0
      packages/app/config/ci/.env.local.for-auto-install
  17. 17 0
      packages/app/cypress.json
  18. 6 6
      packages/app/docker/Dockerfile
  19. 2 2
      packages/app/docker/README.md
  20. 7 12
      packages/app/jest.config.js
  21. 13 7
      packages/app/package.json
  22. 25 0
      packages/app/regconfig.json
  23. 2 1
      packages/app/resource/locales/en_US/translation.json
  24. 1 0
      packages/app/resource/locales/ja_JP/translation.json
  25. 1 0
      packages/app/resource/locales/zh_CN/translation.json
  26. 0 0
      packages/app/resource/search/mappings-es6.json
  27. 115 0
      packages/app/resource/search/mappings-es7.json
  28. 2 5
      packages/app/src/client/app.jsx
  29. 7 7
      packages/app/src/client/nologin.jsx
  30. 11 6
      packages/app/src/client/services/ContextExtractor.tsx
  31. 0 81
      packages/app/src/client/services/PageContainer.js
  32. 3 3
      packages/app/src/components/Admin/Security/SecuritySetting.jsx
  33. 2 2
      packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.jsx
  34. 1 1
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  35. 56 0
      packages/app/src/components/DescendantsPageList.tsx
  36. 0 26
      packages/app/src/components/DuplicatePage.tsx
  37. 20 17
      packages/app/src/components/ForbiddenPage.tsx
  38. 92 6
      packages/app/src/components/IdenticalPathPage.tsx
  39. 14 4
      packages/app/src/components/InstallerForm.jsx
  40. 0 1
      packages/app/src/components/LikeButtons.tsx
  41. 14 14
      packages/app/src/components/LoginForm.jsx
  42. 4 3
      packages/app/src/components/Navbar/GlobalSearch.tsx
  43. 36 35
      packages/app/src/components/Navbar/GrowiSubNavigation.jsx
  44. 14 17
      packages/app/src/components/Navbar/SubNavButtons.tsx
  45. 8 12
      packages/app/src/components/NotFoundPage.tsx
  46. 12 10
      packages/app/src/components/Page/DisplaySwitcher.jsx
  47. 12 12
      packages/app/src/components/Page/NotFoundAlert.tsx
  48. 13 17
      packages/app/src/components/Page/RenderTagLabels.tsx
  49. 2 1
      packages/app/src/components/Page/RevisionRenderer.jsx
  50. 0 113
      packages/app/src/components/Page/TagLabels.jsx
  51. 58 0
      packages/app/src/components/Page/TagLabels.tsx
  52. 1 5
      packages/app/src/components/PageAccessories.jsx
  53. 10 9
      packages/app/src/components/PageAccessoriesModal.jsx
  54. 10 8
      packages/app/src/components/PageAccessoriesModalControl.jsx
  55. 1 1
      packages/app/src/components/PageHistory/RevisionDiff.jsx
  56. 0 102
      packages/app/src/components/PageList.jsx
  57. 2 2
      packages/app/src/components/PageList/BookmarkList.jsx
  58. 49 0
      packages/app/src/components/PageList/PageList.tsx
  59. 36 31
      packages/app/src/components/PageList/PageListItemL.tsx
  60. 3 3
      packages/app/src/components/PageList/PageListItemS.jsx
  61. 2 2
      packages/app/src/components/RecentCreated/RecentCreated.jsx
  62. 3 2
      packages/app/src/components/SearchForm.tsx
  63. 1 1
      packages/app/src/components/SearchPage.jsx
  64. 3 3
      packages/app/src/components/SearchPage/SearchPageLayout.tsx
  65. 3 5
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  66. 12 1
      packages/app/src/components/SearchPage/SearchResultContentSubNavigation.tsx
  67. 11 9
      packages/app/src/components/SearchPage/SearchResultList.tsx
  68. 5 4
      packages/app/src/components/SearchTypeahead.tsx
  69. 33 18
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  70. 7 10
      packages/app/src/components/Sidebar/RecentChanges.tsx
  71. 2 2
      packages/app/src/components/TrashPageList.jsx
  72. 0 51
      packages/app/src/components/User/SeenUserInfo.jsx
  73. 49 0
      packages/app/src/components/User/SeenUserInfo.tsx
  74. 7 0
      packages/app/src/interfaces/lang.ts
  75. 15 0
      packages/app/src/interfaces/page.ts
  76. 11 11
      packages/app/src/interfaces/search.ts
  77. 2 0
      packages/app/src/interfaces/user.ts
  78. 31 41
      packages/app/src/server/crowi/index.js
  79. 0 7
      packages/app/src/server/form/admin/userGroupCreate.js
  80. 0 8
      packages/app/src/server/form/index.js
  81. 0 9
      packages/app/src/server/form/invited.js
  82. 0 8
      packages/app/src/server/form/login.js
  83. 0 11
      packages/app/src/server/form/register.js
  84. 85 0
      packages/app/src/server/middlewares/login-form-validator.ts
  85. 51 0
      packages/app/src/server/middlewares/register-form-validator.ts
  86. 1 0
      packages/app/src/server/models/page.ts
  87. 0 15
      packages/app/src/server/models/user.js
  88. 4 4
      packages/app/src/server/routes/apiv3/forgot-password.js
  89. 3 1
      packages/app/src/server/routes/apiv3/pages.js
  90. 2 2
      packages/app/src/server/routes/apiv3/personal-setting.js
  91. 64 1
      packages/app/src/server/routes/apiv3/users.js
  92. 9 8
      packages/app/src/server/routes/index.js
  93. 14 66
      packages/app/src/server/routes/installer.js
  94. 28 8
      packages/app/src/server/routes/page.js
  95. 0 71
      packages/app/src/server/routes/user.js
  96. 7 18
      packages/app/src/server/service/app.ts
  97. 61 1
      packages/app/src/server/service/config-loader.ts
  98. 127 0
      packages/app/src/server/service/installer.ts
  99. 1319 0
      packages/app/src/server/service/page.js
  100. 30 5
      packages/app/src/server/service/passport.ts

+ 3 - 1
.devcontainer/Dockerfile

@@ -3,7 +3,7 @@
 # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
 #-------------------------------------------------------------------------------------------------------------
 
-FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-14
+FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-16
 
 # The node image includes a non-root user with sudo access. Use the
 # "remoteUser" property in devcontainer.json to use it. On Linux, update
@@ -32,6 +32,8 @@ RUN chown -R $USER_UID:$USER_GID /home/$USERNAME /workspace;
 ENV DEBIAN_FRONTEND=noninteractive
 RUN apt-get update \
    && apt-get -y install --no-install-recommends git-lfs \
+      # for Cypress
+      libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb fonts-noto-cjk \
 
    # Clean up
    && apt-get autoremove -y \

+ 10 - 0
.eslintrc.js

@@ -12,6 +12,7 @@ module.exports = {
   },
   plugins: [
     'jest',
+    'regex',
   ],
   rules: {
     'import/prefer-default-export': 'off',
@@ -30,5 +31,14 @@ module.exports = {
       'error',
       { additionalTestBlockFunctions: ['each.test'] },
     ],
+    'regex/invalid': ['error', [
+      {
+        regex: '\\?\\<\\!',
+        message: 'Do not use any negative lookbehind',
+      }, {
+        regex: '\\?\\<\\=',
+        message: 'Do not use any Positive lookbehind',
+      },
+    ]],
   },
 };

+ 44 - 0
.github/workflows/ci-app-prod.yml

@@ -0,0 +1,44 @@
+name: Node CI for app production
+
+on:
+  push:
+    branches:
+      - master
+  pull_request:
+    types: [opened, reopened, synchronize]
+
+jobs:
+
+  test-prod-node14:
+    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
+    with:
+      node-version: 14.x
+      skip-cypress: true
+    secrets:
+      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
+  test-prod-node16:
+    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
+    with:
+      node-version: 16.x
+      cypress-report-artifact-name: Cypress report
+    secrets:
+      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
+  run-reg-suit-node16:
+    needs: [test-prod-node16]
+
+    uses: weseek/growi/.github/workflows/reusable-app-reg-suit.yml@master
+
+    if: always()
+
+    with:
+      node-version: 16.x
+      cypress-report-artifact-name: Cypress report
+    secrets:
+      REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}
+      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
+      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 37 - 95
.github/workflows/ci.yml → .github/workflows/ci-app.yml

@@ -1,4 +1,4 @@
-name: Node CI for growi
+name: Node CI for app development
 
 on:
   push:
@@ -26,14 +26,19 @@ jobs:
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
+    - name: Cache/Restore node_modules
+      id: cache-dependencies
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+
     - name: lerna bootstrap
       run: |
-        npx lerna bootstrap
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --depth=0
+        npx lerna bootstrap -- --frozen-lockfile
 
     - name: lerna run lint for plugins
       run: |
@@ -53,6 +58,7 @@ jobs:
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
+
   test:
     runs-on: ubuntu-latest
 
@@ -75,14 +81,19 @@ jobs:
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
+    - name: Cache/Restore node_modules
+      id: cache-dependencies
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+
     - name: lerna bootstrap
       run: |
-        npx lerna bootstrap
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --depth=0
+        npx lerna bootstrap -- --frozen-lockfile
 
     - name: yarn test
       working-directory: ./packages/app
@@ -108,6 +119,7 @@ jobs:
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
+
   launch-dev:
     runs-on: ubuntu-latest
 
@@ -130,14 +142,19 @@ jobs:
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
+    - name: Cache/Restore node_modules
+      id: cache-dependencies
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+
     - name: lerna bootstrap
       run: |
-        npx lerna bootstrap
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --depth=0
+        npx lerna bootstrap -- --frozen-lockfile
 
     - name: yarn dev:ci
       working-directory: ./packages/app
@@ -152,82 +169,7 @@ jobs:
       if: failure()
       with:
         type: ${{ job.status }}
-        job_name: '*Node CI for growi - build-dev (${{ matrix.node-version }})*'
-        channel: '#ci'
-        isCompactMode: true
-        url: ${{ secrets.SLACK_WEBHOOK_URL }}
-
-
-  launch-prod:
-    runs-on: ubuntu-latest
-
-    strategy:
-      matrix:
-        node-version: [14.x, 16.x]
-
-    services:
-      mongodb:
-        image: mongo:4.4
-        ports:
-        - 27017/tcp
-
-    steps:
-    - uses: actions/checkout@v2
-
-    - uses: actions/setup-node@v2
-      with:
-        node-version: ${{ matrix.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: Remove unnecessary packages
-      run: |
-        rm -rf packages/slackbot-proxy
-    - name: lerna bootstrap
-      run: |
-        npx lerna bootstrap
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --depth=0
-    - name: Build
-      run: |
-        yarn lerna run build
-      env:
-        ANALYZE_BUNDLE_SIZE: ${{ matrix.node-version == '16.x' }}
-    - name: lerna bootstrap --production
-      run: |
-        npx lerna bootstrap -- --production
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --production --depth=0
-    - name: Get DB name
-      id: getdbname
-      run: |
-        echo ::set-output name=suffix::$(echo '${{ matrix.node-version }}' | sed s/\\.//)
-    - 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: Upload report as artifact
-      uses: actions/upload-artifact@v2
-      with:
-        name: Bundle Analyzing Report
-        path: packages/app/report/bundle-analyzer.html
-
-    - name: Slack Notification
-      uses: weseek/ghaction-slack-notification@master
-      if: failure()
-      with:
-        type: ${{ job.status }}
-        job_name: '*Node CI for growi - build-prod (${{ matrix.node-version }})*'
+        job_name: '*Node CI for growi - launch-dev (${{ matrix.node-version }})*'
         channel: '#ci'
         isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 22 - 12
.github/workflows/ci-slackbot-proxy.yml

@@ -26,14 +26,19 @@ jobs:
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
+    - name: Cache/Restore node_modules
+      id: cache-dependencies
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+
     - name: lerna bootstrap
       run: |
-        npx lerna bootstrap
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --depth=0
+        npx lerna bootstrap -- --frozen-lockfile
 
     - name: yarn lint
       run: |
@@ -79,14 +84,19 @@ jobs:
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
+    - name: Cache/Restore node_modules
+      id: cache-dependencies
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+
     - name: lerna bootstrap
       run: |
-        npx lerna bootstrap
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --depth=0
+        npx lerna bootstrap -- --frozen-lockfile
 
     - name: yarn dev:ci
       working-directory: ./packages/slackbot-proxy

+ 275 - 0
.github/workflows/reusable-app-prod.yml

@@ -0,0 +1,275 @@
+name: Reusable build app workflow for production
+
+on:
+  workflow_call:
+    inputs:
+      node-version:
+        required: true
+        type: string
+      skip-cypress:
+        type: boolean
+      cypress-report-artifact-name:
+        type: string
+    secrets:
+      SLACK_WEBHOOK_URL:
+        required: true
+
+jobs:
+
+  build-prod:
+    runs-on: ubuntu-latest
+
+    outputs:
+      PROD_FILES: ${{ steps.archive-prod-files.outputs.file }}
+
+    steps:
+    - uses: actions/checkout@v2
+
+    - uses: actions/setup-node@v2
+      with:
+        node-version: ${{ inputs.node-version }}
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: Cache/Restore node_modules
+      id: cache-dependencies
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
+
+    - name: lerna bootstrap
+      run: |
+        npx lerna bootstrap -- --frozen-lockfile
+
+    - name: Remove unnecessary packages
+      run: |
+        rm -rf packages/slackbot-proxy
+
+    - name: Build
+      run: |
+        yarn lerna run build
+      env:
+        ANALYZE_BUNDLE_SIZE: 1
+
+    - name: Archive production files
+      id: archive-prod-files
+      run: |
+        tar -cf production.tar packages/**/dist packages/app/public
+        echo ::set-output name=file::production.tar
+
+    - name: Upload production files as artifact
+      uses: actions/upload-artifact@v2
+      with:
+        name: Production Files
+        path: ${{ steps.archive-prod-files.outputs.file }}
+
+    - name: Upload report as artifact
+      uses: actions/upload-artifact@v2
+      with:
+        name: Bundle Analyzing Report
+        path: packages/app/report/bundle-analyzer.html
+
+    - name: Slack Notification
+      uses: weseek/ghaction-slack-notification@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi - build-prod (${{ inputs.node-version }})*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
+
+  launch-prod:
+    needs: [build-prod]
+    runs-on: ubuntu-latest
+
+    services:
+      mongodb:
+        image: mongo:4.4
+        ports:
+        - 27017/tcp
+      mongodb36:
+        image: mongo:3.6
+        ports:
+        - 27017/tcp
+
+    steps:
+    - uses: actions/checkout@v2
+
+    - uses: actions/setup-node@v2
+      with:
+        node-version: ${{ inputs.node-version }}
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: Get Date
+      id: get-date
+      run: |
+        echo "::set-output name=dateYmdHM::$(/bin/date -u "+%Y%m%d%H%M")"
+        echo "::set-output name=dateYm::$(/bin/date -u "+%Y%m")"
+
+    - name: Cache/Restore node_modules (not reused)
+      id: cache-dependencies
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ steps.get-date.outputs.dateYmdHM }}
+        restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
+          node_modules-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ steps.get-date.outputs.dateYm }}
+          node_modules-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
+
+    - name: Remove unnecessary packages
+      run: |
+        rm -rf packages/slackbot-proxy
+
+    - name: lerna bootstrap --production
+      run: |
+        npx lerna bootstrap -- --production
+
+    - name: Download production files artifact
+      uses: actions/download-artifact@v2
+      with:
+        name: Production Files
+
+    - name: Extract procution files artifact
+      run: |
+        tar -xf ${{ needs.build-prod.outputs.PROD_FILES }}
+
+    - 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
+    - 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
+
+    - name: Slack Notification
+      uses: weseek/ghaction-slack-notification@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi - build-prod (${{ inputs.node-version }})*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
+
+  run-cypress:
+    needs: [build-prod]
+
+    if: ${{ !inputs.skip-cypress }}
+
+    runs-on: ubuntu-latest
+    container:
+      image: cypress/base:16.13.0
+      # solve permissions issue
+      # see: https://github.com/cypress-io/github-action/issues/446#issuecomment-987015822
+      options: --user 1001
+
+    strategy:
+      fail-fast: false
+      matrix:
+        # List string expressions that is comma separated ids of tests in "test/cypress/integration"
+        spec-group: ['1', '2']
+
+    services:
+      mongodb:
+        image: mongo:4.4
+        ports:
+        - 27017/tcp
+
+    steps:
+    - uses: actions/checkout@v2
+
+    - name: Get yarn cache dir
+      id: yarn-cache-dir
+      run: |
+        echo "::set-output name=value::`yarn cache dir --silent`"
+
+    - name: Cache/Restore dependencies
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+          ~/.cache/Cypress
+          ${{ steps.yarn-cache-dir.outputs.value }}
+        key: deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}
+
+    - name: lerna bootstrap
+      run: |
+        npx lerna bootstrap -- --frozen-lockfile
+
+    - name: Download production files artifact
+      uses: actions/download-artifact@v2
+      with:
+        name: Production Files
+
+    - name: Extract procution files artifact
+      run: |
+        tar -xf ${{ needs.build-prod.outputs.PROD_FILES }}
+
+    - name: Determine spec expression
+      id: determine-spec-exp
+      run: |
+        SPEC=`node bin/github-actions/generate-cypress-spec-arg.js --prefix="test/cypress/integration/" --suffix="-*/**" "${{ matrix.spec-group }}"`
+        echo "::set-output name=value::$SPEC"
+
+    - name: Copy dotenv file for ci
+      working-directory: ./packages/app
+      run: |
+        cat config/ci/.env.local.for-ci >> .env.production.local
+
+    - name: Copy dotenv file for automatic installation
+      if: ${{ matrix.spec-group != '1' }}
+      working-directory: ./packages/app
+      run: |
+        cat config/ci/.env.local.for-auto-install >> .env.production.local
+
+    - name: Cypress Run
+      uses: cypress-io/github-action@v2
+      with:
+        working-directory: ./packages/app
+        install: false
+        spec: '${{ steps.determine-spec-exp.outputs.value }}'
+        start: yarn server
+        wait-on: 'http://localhost:3000'
+      env:
+        MONGO_URI: mongodb://mongodb:27017/growi-vrt
+
+    - name: Upload results
+      if: always()
+      uses: actions/upload-artifact@v2
+      with:
+        name: ${{ inputs.cypress-report-artifact-name }}
+        path: |
+          packages/app/test/cypress/screenshots
+          packages/app/test/cypress/videos
+
+    - name: Slack Notification
+      uses: weseek/ghaction-slack-notification@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi - run-cypress (${{ inputs.node-version }})*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 91 - 0
.github/workflows/reusable-app-reg-suit.yml

@@ -0,0 +1,91 @@
+name: Reusable build app workflow for production
+
+on:
+  workflow_call:
+    inputs:
+      node-version:
+        required: true
+        type: string
+      checkout-ref:
+        type: string
+        default: ${{ github.head_ref }}
+      cypress-report-artifact-name:
+        required: true
+        type: string
+    secrets:
+      REG_NOTIFY_GITHUB_PLUGIN_CLIENTID:
+        required: true
+      AWS_ACCESS_KEY_ID:
+        required: true
+      AWS_SECRET_ACCESS_KEY:
+        required: true
+      SLACK_WEBHOOK_URL:
+        required: true
+    outputs:
+      EXPECTED_IMAGES_EXIST:
+        value: ${{ jobs.run-reg-suit.outputs.EXPECTED_IMAGES_EXIST }}
+
+
+jobs:
+
+  run-reg-suit:
+    # use secrets for "VRT" environment
+    # https://github.com/weseek/growi/settings/environments/376165508/edit
+    environment: VRT
+
+    env:
+      REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}
+      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
+      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+    runs-on: ubuntu-latest
+
+    outputs:
+      EXPECTED_IMAGES_EXIST: ${{ steps.check-expected-images.outputs.EXPECTED_IMAGES_EXIST }}
+
+    steps:
+    - uses: actions/checkout@v2
+      with:
+        ref: ${{ inputs.checkout-ref }}
+        fetch-depth: 0
+
+    - uses: actions/setup-node@v2
+      with:
+        node-version: ${{ inputs.node-version }}
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: Cache/Restore node_modules
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
+
+    - name: lerna bootstrap
+      run: |
+        npx lerna bootstrap -- --frozen-lockfile
+
+    - name: Download screenshots taken by cypress
+      uses: actions/download-artifact@v2
+      with:
+        name: ${{ inputs.cypress-report-artifact-name }}
+        path: packages/app/test/cypress
+
+    - name: Run reg-suit
+      working-directory: ./packages/app
+      run: |
+        yarn reg:run
+
+    - name: Slack Notification
+      uses: weseek/ghaction-slack-notification@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi - run-reg-suit (${{ inputs.node-version }})*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 44 - 1
CHANGELOG.md

@@ -1,9 +1,52 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.5.8...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.5.11...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v4.5.11](https://github.com/weseek/growi/compare/v4.5.10...v4.5.11) - 2022-01-26
+
+### 🐛 Bug Fixes
+
+- fix: Internal server error occured when "Restrict complete deletion of pages" option's value is "Admin and author" (#5175 ) @yuki-takei
+
+## [v4.5.10](https://github.com/weseek/growi/compare/v4.5.9...v4.5.10) - 2022-01-26
+
+### 💎 Features
+
+- feat: Automatic installation (#5141) @yuki-takei
+
+### 🚀 Improvement
+
+- imprv: Migrate like states to swr (#5137) @miya
+
+### 🐛 Bug Fixes
+
+- fix: 86631-cannot-reset-password-in-case-that-register-limitation-is-Closed (#5155) @kaoritokashiki
+
+### 🧰 Maintenance
+
+- support: VRT with Cypress (#5030) @yuki-takei
+
+## [v4.5.9](https://github.com/weseek/growi/compare/v4.5.8...v4.5.9) - 2022-01-21
+
+### 🚀 Improvement
+
+- imprv: 79291 make password min length 8 charactors (#5116) @kaoritokashiki
+
+### 🐛 Bug Fixes
+
+- fix: OIDC reconnection bug fix (#5104) @mudana-grune
+- fix: /_api/v3/page is broken and dump 500 error "get-page-failed TypeError: user.canDeleteCompletely is not a function" (#5103) @yuki-takei
+- fix: Default completely deletion settings label mismatched against to actual (#5102) @yuki-takei
+- fix: OIDC issuer host availability check (#5099) @mudana-grune
+
+### 🧰 Maintenance
+
+- support: Improve multistage build (#5090) @yuki-takei
+- support: Omit node-re2 (#5089) @yuki-takei
+- ci(deps-dev): bump swr from 1.0.1 to 1.1.2 (#5018) @dependabot
+
 ## [v4.5.8](https://github.com/weseek/growi/compare/v4.5.7...v4.5.8) - 2022-01-12
 
 ### 💎 Features

+ 3 - 1
README.md

@@ -83,12 +83,14 @@ See [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/ad
 ## Dependencies
 
 - Node.js v14.x or v16.x
+- npm 6.x
+- yarn
 - MongoDB 4.x
 
 ### Optional Dependencies
 
 - Redis 3.x
-- ElasticSearch 6.x (needed when using Full-text search)
+- ElasticSearch 6.x or 7.x (needed when using Full-text search)
   - **CAUTION: Following plugins are required**
     - [Japanese (kuromoji) Analysis plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html)
     - [ICU Analysis Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-icu.html)

+ 3 - 1
README_JP.md

@@ -82,12 +82,14 @@ Crowi からの移行は **[こちら](https://docs.growi.org/en/admin-guide/mig
 ## 依存関係
 
 - Node.js v14.x or v16.x
+- npm 6.x
+- yarn
 - MongoDB 4.x
 
 ### オプションの依存関係
 
 - Redis 3.x
-- ElasticSearch 6.x (needed when using Full-text search)
+- ElasticSearch 6.x or 7.x (needed when using Full-text search)
   - **注意: 次のプラグインが必要です**
     - [Japanese (kuromoji) Analysis plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html)
     - [ICU Analysis Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-icu.html)

+ 53 - 0
bin/github-actions/generate-cypress-spec-arg.js

@@ -0,0 +1,53 @@
+/* eslint-disable no-console */
+
+/*
+ * USAGE:
+ *  node generate-cypress-spec-arg --prefix=${prefix} --suffix=${suffix} ${value}
+ *
+ * OPTIONS:
+ *  --prefix : prefix string for each items
+ *  --suffix : suffix string for each items
+ *
+ * EXAMPLE:
+ *  node generate-cypress-spec-arg --prefix=${prefix}"A" --suffix="Z" "1,3,5"
+ *  => A1Z,A3Z,A5Z
+ */
+
+const yargs = require('yargs/yargs');
+const { hideBin } = require('yargs/helpers');
+
+const argv = yargs(hideBin(process.argv)).argv;
+
+
+const printExample = () => {
+  console.group('** Usage **');
+  // eslint-disable-next-line no-template-curly-in-string
+  console.log('$ node generate-cypress-spec-arg --prefix=${prefix}"A" --suffix="Z" "1,3,5"');
+  console.log('  ==> A1Z,A3Z,A5Z');
+  console.groupEnd();
+  console.log('\n');
+};
+
+
+const { prefix, suffix, _: value } = argv;
+
+if (prefix == null) {
+  printExample();
+  throw new Error('The option "prefix" must be specified');
+}
+if (suffix == null) {
+  printExample();
+  throw new Error('The option "suffix" must be specified');
+}
+if (value.length === 0) {
+  printExample();
+  throw new Error('A value string must be specified');
+}
+
+const result = value[0]
+  .toString().split(',')
+  .map(v => v.trim())
+  .map(v => `${prefix}${v}${suffix}`)
+  .join(',');
+
+console.log(result);

+ 9 - 1
package.json

@@ -51,11 +51,13 @@
     "tslib": "^2.3.1"
   },
   "devDependencies": {
+    "@testing-library/cypress": "^8.0.2",
     "@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",
+    "cypress": "^9.2.0",
     "eslint": "^7.31.0",
     "eslint-config-weseek": "^1.1.0",
     "eslint-import-resolver-typescript": "^2.4.0",
@@ -69,6 +71,11 @@
     "lerna": "^4.0.0",
     "postcss": "^8.4.5",
     "postcss-scss": "^4.0.3",
+    "reg-keygen-git-hash-plugin": "^0.11.1",
+    "reg-notify-github-plugin": "^0.11.1",
+    "reg-notify-slack-plugin": "^0.11.0",
+    "reg-publish-s3-plugin": "^0.11.0",
+    "reg-suit": "^0.11.1",
     "rewire": "^5.0.0",
     "shipjs": "^0.24.1",
     "stylelint": "^14.2.0",
@@ -76,7 +83,8 @@
     "ts-jest": "^27.0.4",
     "ts-node": "^9.1.1",
     "tsconfig-paths": "^3.9.0",
-    "typescript": "^4.2.3"
+    "typescript": "^4.2.3",
+    "yargs": "^17.3.1"
   },
   "engines": {
     "node": "^14 || ^16",

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

@@ -13,6 +13,8 @@ MONGO_URI="mongodb://mongo:27017/growi"
 # NCHAN_URI="http://nchan"
 ELASTICSEARCH_URI="http://elasticsearch:9200/growi"
 ELASTICSEARCH_REQUEST_TIMEOUT=15000
+ELASTICSEARCH_REJECT_UNAUTHORIZED=false
+USE_ELASTICSEARCH_V6=false
 HACKMD_URI="http://localhost:3010"
 HACKMD_URI_FOR_SERVER="http://hackmd:3000"
 # DRAWIO_URI="http://localhost:8080/?offline=1&https=0"

+ 12 - 0
packages/app/.eslintrc.js

@@ -3,6 +3,9 @@ module.exports = {
     'weseek/react',
     'weseek/typescript',
   ],
+  plugins: [
+    'regex',
+  ],
   env: {
     jquery: true,
   },
@@ -25,6 +28,15 @@ module.exports = {
       name: 'axios',
       message: 'Please use src/utils/axios instead.',
     }],
+    'regex/invalid': ['error', [
+      {
+        regex: '\\?\\<\\!',
+        message: 'Do not use any negative lookbehind',
+      }, {
+        regex: '\\?\\<\\=',
+        message: 'Do not use any Positive lookbehind',
+      },
+    ]],
     '@typescript-eslint/no-var-requires': 'off',
 
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei

+ 5 - 0
packages/app/.gitignore

@@ -2,6 +2,11 @@
 /.next/
 /out/
 
+# test
+test/cypress/screenshots
+test/cypress/videos
+.reg
+
 # dist
 /dist/
 /transpiled/

+ 7 - 0
packages/app/config/ci/.env.local.for-auto-install

@@ -0,0 +1,7 @@
+APP_SITE_URL=http://localhost:3000
+
+AUTO_INSTALL_ADMIN_USERNAME=admin
+AUTO_INSTALL_ADMIN_NAME=Admin
+AUTO_INSTALL_ADMIN_EMAIL=admin@example.com
+AUTO_INSTALL_ADMIN_PASSWORD=adminadmin
+AUTO_INSTALL_GLOBAL_LANG=zh_CN

+ 17 - 0
packages/app/cypress.json

@@ -0,0 +1,17 @@
+{
+  "baseUrl": "http://localhost:3000",
+
+  "fileServerFolder": "test/cypress",
+  "fixturesFolder": "test/cypress/fixtures",
+  "integrationFolder": "test/cypress/integration",
+  "screenshotsFolder": "test/cypress/screenshots",
+  "videosFolder": "test/cypress/videos",
+  "supportFile": "test/cypress/support/index.ts",
+  "pluginsFile": "test/cypress/plugins/index.ts",
+  "testFiles": "**/*.spec.ts",
+
+  "viewportWidth": 1440,
+  "viewportHeight": 1200,
+
+  "experimentalSessionSupport": true
+}

+ 6 - 6
packages/app/docker/Dockerfile

@@ -6,7 +6,7 @@ ARG flavor=default
 ##
 ## packages-json-picker
 ##
-FROM node:14-slim AS packages-json-picker
+FROM node:16-slim AS packages-json-picker
 
 ENV optDir /opt
 
@@ -20,7 +20,7 @@ RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs
 ##
 ## deps-resolver
 ##
-FROM node:14-slim AS deps-resolver
+FROM node:16-slim AS deps-resolver
 
 ENV optDir /opt
 
@@ -31,7 +31,7 @@ COPY --from=packages-json-picker ${optDir} .
 
 # setup
 RUN yarn config set network-timeout 300000
-RUN npx lerna bootstrap -- --frozen-lockfile
+RUN npx -y lerna bootstrap -- --frozen-lockfile
 
 # make artifacts
 RUN tar -cf node_modules.tar \
@@ -48,7 +48,7 @@ FROM deps-resolver AS deps-resolver-prod
 # remove unnecessary packages
 RUN rm -rf packages/slackbot-proxy
 
-RUN npx lerna bootstrap -- --production
+RUN npx -y lerna bootstrap -- --production
 # make artifacts
 RUN tar -cf node_modules.tar \
   node_modules \
@@ -59,7 +59,7 @@ RUN tar -cf node_modules.tar \
 ##
 ## prebuilder-default
 ##
-FROM node:14-slim AS prebuilder-default
+FROM node:16-slim AS prebuilder-default
 
 ENV optDir /opt
 
@@ -128,7 +128,7 @@ RUN tar -cf packages.tar \
 ##
 ## release
 ##
-FROM node:14-slim
+FROM node:16-slim
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV NODE_ENV production

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

@@ -12,8 +12,8 @@ Supported tags and respective Dockerfile links
 
 * [`5.0.0`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.0-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
-* [`4.5.8`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.8/docker/Dockerfile)
-* [`4.5.8-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.8/docker/Dockerfile)
+* [`4.5.11`, `4.5`, `4`, (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.11/docker/Dockerfile)
+* [`4.5.11-nocdn`, `4.5-nocdn`, `4-nocdn`, (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.11/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 

+ 7 - 12
packages/app/jest.config.js

@@ -21,8 +21,8 @@ module.exports = {
       preset: 'ts-jest/presets/js-with-ts',
 
       rootDir: '.',
-      roots: ['<rootDir>/src'],
-      testMatch: ['<rootDir>/src/test/unit/**/*.test.ts', '<rootDir>/src/test/unit/**/*.test.js'],
+      roots: ['<rootDir>'],
+      testMatch: ['<rootDir>/test/unit/**/*.test.ts', '<rootDir>/test/unit/**/*.test.js'],
 
       testEnvironment: 'node',
 
@@ -36,23 +36,18 @@ module.exports = {
       preset: 'ts-jest/presets/js-with-ts',
 
       rootDir: '.',
-      roots: ['<rootDir>/src'],
-      testMatch: ['<rootDir>/src/test/integration/**/*.test.ts', '<rootDir>/src/test/integration/**/*.test.js'],
+      roots: ['<rootDir>'],
+      testMatch: ['<rootDir>/test/integration/**/*.test.ts', '<rootDir>/test/integration/**/*.test.js'],
 
       testEnvironment: 'node',
-      globalSetup: '<rootDir>/src/test/integration/global-setup.js',
-      globalTeardown: '<rootDir>/src/test/integration/global-teardown.js',
-      setupFilesAfterEnv: ['<rootDir>/src/test/integration/setup.js'],
+      globalSetup: '<rootDir>/test/integration/global-setup.js',
+      globalTeardown: '<rootDir>/test/integration/global-teardown.js',
+      setupFilesAfterEnv: ['<rootDir>/test/integration/setup.js'],
 
       // Automatically clear mock calls and instances between every test
       clearMocks: true,
       moduleNameMapper: MODULE_NAME_MAPPING,
     },
-    // {
-    //   displayName: 'client',
-    //   rootDir: '.',
-    //   testMatch: ['<rootDir>/src/test/client/**/*.test.js'],
-    // },
   ],
 
   // Automatically clear mock calls and instances between every test

+ 13 - 7
packages/app/package.json

@@ -8,9 +8,9 @@
     "build": "run-p build:*",
     "build:client": "yarn cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
     "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
-    "clean": "npx shx rm -rf dist transpiled",
+    "clean": "npx -y shx rm -rf dist transpiled",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
-    "postbuild": "npx shx mv transpiled/src dist && npx shx cp -r src/server/views dist/server/ && npx shx rm -rf transpiled",
+    "postbuild": "npx -y shx mv transpiled/src dist && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
     "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config --expose_gc dist/server/app.js",
     "server:ci": "yarn server --ci",
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
@@ -28,10 +28,11 @@
     "dev:migrate:status": "yarn dev:migrate-mongo status",
     "dev:migrate:up": "yarn dev:migrate-mongo up",
     "dev:migrate:down": "yarn dev:migrate-mongo down",
+    "cy:run": "cypress run --headless",
     "//// for CI": "",
     "dev:ci": "yarn dev:client:nowatch && yarn dev:server --ci",
     "predev:ci": "run-p resources:*",
-    "lint:typecheck": "npx tsc",
+    "lint:typecheck": "npx -y tsc",
     "lint:eslint": "eslint --quiet \"**/*.{js,jsx,ts,tsx}\"",
     "lint:styles": "stylelint src/**/*.scss",
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
@@ -39,6 +40,7 @@
     "test": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
     "prelint:eslint": "yarn resources:plugin",
     "prelint:swagger2openapi": "yarn openapi:v3",
+    "reg:run": "reg-suit run",
     "//// misc": "",
     "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
@@ -73,6 +75,7 @@
     "async-canvas-to-blob": "^1.0.3",
     "aws-sdk": "^2.1044.0",
     "axios": "^0.24.0",
+    "axios-retry": "^3.2.4",
     "body-parser": "^1.18.2",
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",
@@ -86,20 +89,21 @@
     "date-fns": "^2.23.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
+    "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.7",
+    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.16.0",
     "diff_match_patch": "^0.1.1",
-    "elasticsearch": "^16.0.0",
     "entities": "^2.0.0",
     "esa-nodejs": "^0.0.7",
     "escape-string-regexp": "=4.0.0",
+    "eslint-plugin-regex": "^1.8.0",
     "express": "^4.16.1",
     "express-bunyan-logger": "^1.3.3",
-    "express-form": "~0.12.0",
     "express-mongo-sanitize": "^2.1.0",
     "express-rate-limit": "^5.3.0",
     "express-session": "^1.16.1",
     "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
-    "got": "^8.3.2",
+    "extensible-custom-error": "^0.0.7",
     "graceful-fs": "^4.1.11",
     "helmet": "^4.6.0",
     "http-errors": "~1.8.0",
@@ -175,13 +179,15 @@
     "bunyan-debug": "^2.0.0",
     "cli": "~1.0.1",
     "codemirror": "^5.63.0",
-    "colors": "^1.2.5",
+    "colors": "=1.4.0",
     "connect-browser-sync": "^2.1.0",
     "core-js": "=2.6.9",
     "css-loader": "^3.0.0",
     "csv-to-markdown-table": "^1.0.1",
     "diff2html": "^3.1.2",
     "eazy-logger": "^3.1.0",
+    "eslint-plugin-regex": "^1.8.0",
+    "eslint-plugin-cypress": "^2.12.1",
     "file-loader": "^5.0.2",
     "handsontable": "=6.2.2",
     "hard-source-webpack-plugin": "^0.13.1",

+ 25 - 0
packages/app/regconfig.json

@@ -0,0 +1,25 @@
+{
+  "core": {
+    "workingDir": ".reg",
+    "actualDir": "test/cypress/screenshots",
+    "thresholdRate": 0.001,
+    "addIgnore": true,
+    "ximgdiff": {
+      "invocationType": "client"
+    }
+  },
+  "plugins": {
+    "reg-keygen-git-hash-plugin": true,
+    "reg-notify-github-plugin": {
+      "prCommentBehavior": "new",
+      "setCommitStatus": false,
+      "clientId": "$REG_NOTIFY_GITHUB_PLUGIN_CLIENTID"
+    },
+    "reg-notify-slack-plugin": {
+      "webhookUrl": "$SLACK_WEBHOOK_URL"
+    },
+    "reg-publish-s3-plugin": {
+      "bucketName": "growi-vrt-snapshots"
+    }
+  }
+}

+ 2 - 1
packages/app/resource/locales/en_US/translation.json

@@ -60,6 +60,7 @@
   "Presentation Mode": "Presentation",
   "The end": "The end",
   "Not available for guest": "Not available for guest",
+  "No users have liked this yet": "No users have liked this yet",
   "No users have liked this yet.": "No users have liked this yet.",
   "No users have bookmarked yet": "No users have bookmarked yet",
   "Create Archive Page": "Create Archive Page",
@@ -208,7 +209,7 @@
     },
     "form_help": {
       "email": "You must have email address which listed below to sign up to this wiki.",
-      "password": "Your password must be at least 6 characters long.",
+      "password": "Your password must be at least 8 characters long.",
       "user_id": "The URL of pages you create will contain your User ID. Your User ID can consist of letters, numbers, and some symbols."
     }
   },

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

@@ -61,6 +61,7 @@
   "Presentation Mode": "プレゼンテーション",
   "The end": "おしまい",
   "Not available for guest": "ゲストユーザーは利用できません",
+  "No users have liked this yet": "いいねをしているユーザーはいません",
   "No users have bookmarked yet": "ブックマークしているユーザーはいません",
   "Create Archive Page": "アーカイブページの作成",
   "Target page": "対象ページ",

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

@@ -62,6 +62,7 @@
 	"Presentation Mode": "演示文稿",
   "The end": "结束",
   "Not available for guest": "Not available for guest",
+  "No users have liked this yet": "还没有用户喜欢这个",
   "No users have bookmarked yet": "还没有用户加入书签",
   "Create Archive Page": "创建归档页",
   "File type": "文件类型",

+ 0 - 0
packages/app/resource/search/mappings.json → packages/app/resource/search/mappings-es6.json


+ 115 - 0
packages/app/resource/search/mappings-es7.json

@@ -0,0 +1,115 @@
+{
+  "settings": {
+    "analysis": {
+      "filter": {
+        "english_stop": {
+          "type":       "stop",
+          "stopwords":  "_english_"
+        }
+      },
+      "tokenizer": {
+        "edge_ngram_tokenizer": {
+          "type": "edge_ngram",
+          "min_gram": 2,
+          "max_gram": 20,
+          "token_chars": ["letter", "digit"]
+        }
+      },
+      "analyzer": {
+        "japanese": {
+          "tokenizer": "kuromoji_tokenizer",
+          "char_filter" : ["icu_normalizer"]
+        },
+        "english_edge_ngram": {
+          "tokenizer": "edge_ngram_tokenizer",
+          "filter": [
+            "lowercase",
+            "english_stop"
+          ]
+        }
+      }
+    }
+  },
+  "mappings": {
+    "properties" : {
+      "path": {
+        "type": "text",
+        "fields": {
+          "raw": {
+            "type": "text",
+            "analyzer": "keyword"
+          },
+          "ja": {
+            "type": "text",
+            "analyzer": "japanese"
+          },
+          "en": {
+            "type": "text",
+            "analyzer": "english_edge_ngram",
+            "search_analyzer": "standard"
+          }
+        }
+      },
+      "body": {
+        "type": "text",
+        "fields": {
+          "ja": {
+            "type": "text",
+            "analyzer": "japanese"
+          },
+          "en": {
+            "type": "text",
+            "analyzer": "english_edge_ngram",
+            "search_analyzer": "standard"
+          }
+        }
+      },
+      "comments": {
+        "type": "text",
+        "fields": {
+          "ja": {
+            "type": "text",
+            "analyzer": "japanese"
+          },
+          "en": {
+            "type": "text",
+            "analyzer": "english_edge_ngram",
+            "search_analyzer": "standard"
+          }
+        }
+      },
+      "username": {
+        "type": "keyword"
+      },
+      "comment_count": {
+        "type": "integer"
+      },
+      "bookmark_count": {
+        "type": "integer"
+      },
+      "like_count": {
+        "type": "integer"
+      },
+      "grant": {
+        "type": "integer"
+      },
+      "granted_users": {
+        "type": "keyword"
+      },
+      "granted_group": {
+        "type": "keyword"
+      },
+      "created_at": {
+        "type": "date",
+        "format": "dateOptionalTime"
+      },
+      "updated_at": {
+        "type": "date",
+        "format": "dateOptionalTime"
+      },
+      "tag_names": {
+        "type": "keyword"
+      }
+    }
+  }
+}

+ 2 - 5
packages/app/src/client/app.jsx

@@ -37,7 +37,6 @@ import RecentCreated from '../components/RecentCreated/RecentCreated';
 import RecentlyCreatedIcon from '../components/Icons/RecentlyCreatedIcon';
 import MyDraftList from '../components/MyDraftList/MyDraftList';
 import BookmarkList from '../components/PageList/BookmarkList';
-import LikerList from '../components/User/LikerList';
 import Fab from '../components/Fab';
 import PersonalSettings from '../components/Me/PersonalSettings';
 import GrowiSubNavigation from '../components/Navbar/GrowiSubNavigation';
@@ -89,7 +88,7 @@ Object.assign(componentMappings, {
 
   'search-page': <SearchPage crowi={appContainer} />,
   'all-in-app-notifications': <InAppNotificationPage />,
-  'identical-path-page-list': <IdenticalPathPage />,
+  'identical-path-page': <IdenticalPathPage />,
 
   // 'revision-history': <PageHistory pageId={pageId} />,
   'tags-page': <TagsList crowi={appContainer} />,
@@ -102,7 +101,7 @@ Object.assign(componentMappings, {
 
   'not-found-page': <NotFoundPage />,
 
-  'forbidden-page': <ForbiddenPage />,
+  'forbidden-page': <ForbiddenPage isSharePage={appContainer.config.disableLinkSharing} />,
 
   'page-timeline': <PageTimeline />,
 
@@ -118,7 +117,6 @@ Object.assign(componentMappings, {
   'renamed-alert': <RenamedAlert />,
   'not-found-alert': <NotFoundAlert
     isGuestUserMode={appContainer.isGuestUser}
-    isHidden={pageContainer.state.pageId != null ? (pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage) : false} // !!DO NOT MOVE THIS!! https://github.com/weseek/growi/pull/4899
   />,
 });
 
@@ -128,7 +126,6 @@ if (pageContainer.state.pageId != null) {
     'page-comments-list': <PageComments />,
     'page-comment-write': <CommentEditorLazyRenderer />,
     'page-management': <PageManagement />,
-    'liker-list': <LikerList />,
     'page-content-footer': <PageContentFooter />,
 
     'recent-created-icon': <RecentlyCreatedIcon />,

+ 7 - 7
packages/app/src/client/nologin.jsx

@@ -16,17 +16,17 @@ import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationF
 const i18n = i18nFactory();
 
 // render InstallerForm
-const installerFormElem = document.getElementById('installer-form');
-if (installerFormElem) {
-  const userName = installerFormElem.dataset.userName;
-  const name = installerFormElem.dataset.name;
-  const email = installerFormElem.dataset.email;
-  const csrf = installerFormElem.dataset.csrf;
+const installerFormContainerElem = document.getElementById('installer-form-container');
+if (installerFormContainerElem) {
+  const userName = installerFormContainerElem.dataset.userName;
+  const name = installerFormContainerElem.dataset.name;
+  const email = installerFormContainerElem.dataset.email;
+  const csrf = installerFormContainerElem.dataset.csrf;
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
       <InstallerForm userName={userName} name={name} email={email} csrf={csrf} />
     </I18nextProvider>,
-    installerFormElem,
+    installerFormContainerElem,
   );
 }
 

+ 11 - 6
packages/app/src/client/services/ContextExtractor.tsx

@@ -6,7 +6,7 @@ import {
   useIsDeletable, useIsDeleted, useIsNotCreatable, useIsPageExist, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
-  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage,
+  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
 } from '../../stores/context';
 import {
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
@@ -23,6 +23,7 @@ const ContextExtractorOnce: FC = () => {
 
   const mainContent = document.querySelector('#content-main');
   const notFoundContent = document.getElementById('growi-pagetree-not-found-context');
+  const forbiddenContent = document.getElementById('forbidden-page');
 
   /*
    * App Context from DOM
@@ -50,13 +51,15 @@ const ContextExtractorOnce: FC = () => {
   const updatedAt: Date | null = (updatedAtAttribute != null) ? new Date(updatedAtAttribute) : null;
 
   const deletedAt = mainContent?.getAttribute('data-page-deleted-at') || null;
-  const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
+  const isIdenticalPath = JSON.parse(mainContent?.getAttribute('data-identical-path') || jsonNull) ?? false;
+  const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull) != null;
   const isTrashPage = _isTrashPage(path);
-  const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull);
-  const isDeletable = JSON.parse(mainContent?.getAttribute('data-page-is-deletable') || jsonNull);
-  const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull);
-  const isAbleToDeleteCompletely = JSON.parse(mainContent?.getAttribute('data-page-is-able-to-delete-completely') || jsonNull);
+  const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull) ?? false;
+  const isDeletable = JSON.parse(mainContent?.getAttribute('data-page-is-deletable') || jsonNull) ?? false;
+  const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull) ?? false;
+  const isAbleToDeleteCompletely = JSON.parse(mainContent?.getAttribute('data-page-is-able-to-delete-completely') || jsonNull) ?? false;
   const isPageExist = mainContent?.getAttribute('data-page-id') != null;
+  const isForbidden = forbiddenContent != null;
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
   const hasChildren = JSON.parse(mainContent?.getAttribute('data-page-has-children') || jsonNull);
   const templateTagData = mainContent?.getAttribute('data-template-tags') || null;
@@ -97,11 +100,13 @@ const ContextExtractorOnce: FC = () => {
   useDeletedAt(deletedAt);
   useHasChildren(hasChildren);
   useHasDraftOnHackmd(hasDraftOnHackmd);
+  useIsIdenticalPath(isIdenticalPath);
   useIsAbleToDeleteCompletely(isAbleToDeleteCompletely);
   useIsDeletable(isDeletable);
   useIsDeleted(isDeleted);
   useIsNotCreatable(isNotCreatable);
   useIsPageExist(isPageExist);
+  useIsForbidden(isForbidden);
   useIsTrashPage(isTrashPage);
   useIsUserPage(isUserPage);
   useLastUpdateUsername(lastUpdateUsername);

+ 0 - 81
packages/app/src/client/services/PageContainer.js

@@ -54,15 +54,6 @@ export default class PageContainer extends Container {
       path,
       tocHtml: '',
 
-      seenUsers: [],
-      seenUserIds: [],
-      sumOfSeenUsers: [],
-
-      isLiked: false,
-      likers: [],
-      likerIds: [],
-      sumOfLikers: 0,
-
       createdAt: mainContent.getAttribute('data-page-created-at'),
       // please use useCurrentUpdatedAt instead
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
@@ -117,23 +108,9 @@ export default class PageContainer extends Container {
     interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(appContainer), 900); // process as late as possible
 
     this.initStateMarkdown();
-    this.checkAndUpdateImageUrlCached(this.state.likers);
-
-    const { isSharedUser } = this.appContainer;
-
-    // see https://dev.growi.org/5fabddf8bbeb1a0048bcb9e9
-    const isAbleToGetAttachedInformationAboutPages = this.state.isPageExist && !isSharedUser;
-
-    if (isAbleToGetAttachedInformationAboutPages) {
-      // We don't retrieve bookmarks in the initial page load
-      // as it is stored in a separate collection to like and seen user
-      // data so it has a separate api endpoint.
-      this.initialPageLoad();
-    }
 
     this.setTocHtml = this.setTocHtml.bind(this);
     this.save = this.save.bind(this);
-    this.checkAndUpdateImageUrlCached = this.checkAndUpdateImageUrlCached.bind(this);
 
     this.emitJoinPageRoomRequest = this.emitJoinPageRoomRequest.bind(this);
     this.emitJoinPageRoomRequest();
@@ -266,64 +243,6 @@ export default class PageContainer extends Container {
     this.state.markdown = markdown;
   }
 
-
-  async initialPageLoad() {
-    {
-      const {
-        data: {
-          likerIds, sumOfLikers, isLiked, seenUserIds, sumOfSeenUsers, isSeen,
-        },
-      } = await this.appContainer.apiv3Get('/page/info', { pageId: this.state.pageId });
-
-      await this.setState({
-        sumOfLikers,
-        isLiked,
-        likerIds,
-        seenUserIds,
-        sumOfSeenUsers,
-        isSeen,
-      });
-    }
-
-    await this.retrieveLikersAndSeenUsers();
-  }
-
-
-  async retrieveLikersAndSeenUsers() {
-    const { users } = await this.appContainer.apiGet('/users.list', { user_ids: [...this.state.likerIds, ...this.state.seenUserIds].join(',') });
-
-    await this.setState({
-      likers: users.filter(({ id }) => this.state.likerIds.includes(id)).slice(0, 15),
-      seenUsers: users.filter(({ id }) => this.state.seenUserIds.includes(id)).slice(0, 15),
-    });
-
-    this.checkAndUpdateImageUrlCached(users);
-  }
-
-  async retrieveBookmarkInfo() {
-    const response = await this.appContainer.apiv3Get('/bookmarks/info', { pageId: this.state.pageId });
-    this.setState({
-      sumOfBookmarks: response.data.sumOfBookmarks,
-      isBookmarked: response.data.isBookmarked,
-    });
-  }
-
-  async checkAndUpdateImageUrlCached(users) {
-    const noImageCacheUsers = users.filter((user) => { return user.imageUrlCached == null });
-    if (noImageCacheUsers.length === 0) {
-      return;
-    }
-
-    const noImageCacheUserIds = noImageCacheUsers.map((user) => { return user.id });
-    try {
-      await this.appContainer.apiv3Put('/users/update.imageUrlCache', { userIds: noImageCacheUserIds });
-    }
-    catch (err) {
-      // Error alert doesn't apear, because user don't need to notice this error.
-      logger.error(err);
-    }
-  }
-
   setLatestRemotePageData(s2cMessagePageUpdated) {
     const newState = {
       remoteRevisionId: s2cMessagePageUpdated.revisionId,

+ 3 - 3
packages/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -157,10 +157,10 @@ class SecuritySetting extends React.Component {
                 aria-expanded="true"
               >
                 <span className="float-left">
-                  {currentPageCompleteDeletionAuthority === 'anyOne' && t('security_setting.anyone')}
+                  {(currentPageCompleteDeletionAuthority === 'anyOne' || currentPageCompleteDeletionAuthority == null)
+                      && t('security_setting.anyone')}
                   {currentPageCompleteDeletionAuthority === 'adminOnly' && t('security_setting.admin_only')}
-                  {(currentPageCompleteDeletionAuthority === 'adminAndAuthor' || currentPageCompleteDeletionAuthority == null)
-                      && t('security_setting.admin_and_author')}
+                  {currentPageCompleteDeletionAuthority === 'adminAndAuthor' && t('security_setting.admin_and_author')}
                 </span>
               </button>
               <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">

+ 2 - 2
packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.jsx

@@ -2,7 +2,7 @@ import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import Page from '../../PageList/Page';
+import PageListItemS from '../../PageList/PageListItemS';
 import PaginationWrapper from '../../PaginationWrapper';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
@@ -57,7 +57,7 @@ class UserGroupPageList extends React.Component {
     return (
       <Fragment>
         <ul className="page-list-ul page-list-ul-flat mb-3">
-          {this.state.currentPages.map(page => <li key={page._id}><Page page={page} /></li>)}
+          {this.state.currentPages.map(page => <li key={page._id}><PageListItemS page={page} /></li>)}
         </ul>
         {relatedPages.length === 0 ? <p>{t('admin:user_group_management.no_pages')}</p> : (
           <PaginationWrapper

+ 1 - 1
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -13,7 +13,7 @@ import { useSWRBookmarkInfo } from '~/stores/bookmark';
 
 type PageItemControlProps = {
   page: Partial<IPageHasId>
-  isEnableActions: boolean
+  isEnableActions?: boolean
   isDeletable: boolean
   onClickDeleteButtonHandler?: (pageId: string) => void
   onClickRenameButtonHandler?: (pageId: string) => void

+ 56 - 0
packages/app/src/components/DescendantsPageList.tsx

@@ -0,0 +1,56 @@
+import React, { useState } from 'react';
+
+import { useSWRxPageList } from '~/stores/page';
+
+import PageList from './PageList/PageList';
+import PaginationWrapper from './PaginationWrapper';
+
+type Props = {
+  path: string,
+}
+
+const DescendantsPageList = (props: Props): JSX.Element => {
+  const { path } = props;
+
+  const [activePage, setActivePage] = useState(1);
+
+  const { data, error } = useSWRxPageList(path, activePage);
+
+  function setPageNumber(selectedPageNumber) {
+    setActivePage(selectedPageNumber);
+  }
+
+  if (error != null) {
+    return (
+      <div className="my-5">
+        <div className="text-danger">{error.message}</div>
+      </div>
+    );
+  }
+
+  if (data === undefined) {
+    return (
+      <div className="wiki">
+        <div className="text-muted text-center">
+          <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <>
+      <PageList pages={data} />
+
+      <PaginationWrapper
+        activePage={activePage}
+        changePage={setPageNumber}
+        totalItemsCount={data.totalCount}
+        pagingLimit={data.limit}
+        align="center"
+      />
+    </>
+  );
+};
+
+export default DescendantsPageList;

+ 0 - 26
packages/app/src/components/DuplicatePage.tsx

@@ -1,26 +0,0 @@
-import React, { FC } from 'react';
-import { DevidedPagePath } from '@growi/core';
-import { useTranslation } from 'react-i18next';
-
-
-type DuplicatePageAlertProps = {
-  path : string,
-}
-
-const DuplicatePageAlert : FC<DuplicatePageAlertProps> = (props: DuplicatePageAlertProps) => {
-  const { path } = props;
-  const { t } = useTranslation();
-  const devidedPath = new DevidedPagePath(path);
-
-  return (
-    <div className="alert alert-warning py-3">
-      <h5 className="font-weight-bold mt-1">{t('duplicated_page_alert.same_page_name_exists', { pageName: devidedPath.latter })}</h5>
-      <p>
-        {t('duplicated_page_alert.same_page_name_exists_at_path',
-          { path: devidedPath.isFormerRoot ? '/' : devidedPath.former, pageName: devidedPath.latter })}<br />
-        <p dangerouslySetInnerHTML={{ __html: t('See_more_detail_on_new_schema', { url: t('GROWI.5.0_new_schema') }) }} />
-      </p>
-      <p className="mb-1">{t('duplicated_page_alert.select_page_to_see')}</p>
-    </div>
-  );
-};

+ 20 - 17
packages/app/src/components/ForbiddenPage.jsx → packages/app/src/components/ForbiddenPage.tsx

@@ -1,19 +1,23 @@
 import React, { useMemo } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
+
 import PageListIcon from './Icons/PageListIcon';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import PageList from './PageList';
+import DescendantsPageList from './DescendantsPageList';
+
 
+type Props = {
+  isSharePage?: boolean,
+}
 
-const ForbiddenPage = (props) => {
-  const { t } = props;
+const ForbiddenPage = React.memo((props: Props): JSX.Element => {
+  const { t } = useTranslation();
 
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
         Icon: PageListIcon,
-        Content: PageList,
+        Content: DescendantsPageList,
         i18n: t('page_list'),
         index: 0,
       },
@@ -31,24 +35,23 @@ const ForbiddenPage = (props) => {
         </div>
       </div>
 
-
       <div className="row row-alerts d-edit-none">
         <div className="col-sm-12">
           <p className="alert alert-primary py-3 px-4">
             <i className="icon-fw icon-lock" aria-hidden="true" />
-            {t('Browsing of this page is restricted')}
+            { props.isSharePage ? t('custom_navigation.link_sharing_is_disabled') : t('Browsing of this page is restricted')}
           </p>
         </div>
       </div>
-      <div className="mt-5">
-        <CustomNavAndContents navTabMapping={navTabMapping} />
-      </div>
+
+      { !props.isSharePage && (
+        <div className="mt-5">
+          <CustomNavAndContents navTabMapping={navTabMapping} />
+        </div>
+      ) }
+
     </>
   );
-};
-
-ForbiddenPage.propTypes = {
-  t: PropTypes.func.isRequired,
-};
+});
 
-export default withTranslation()(ForbiddenPage);
+export default ForbiddenPage;

+ 92 - 6
packages/app/src/components/IdenticalPathPage.tsx

@@ -1,14 +1,100 @@
-import React, { FC } from 'react';
+import React, {
+  FC,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { DevidedPagePath } from '@growi/core';
+
+import { useCurrentPagePath } from '~/stores/context';
+
+import { PageListItemL } from './PageList/PageListItemL';
+
+
+type IdenticalPathAlertProps = {
+  path? : string | null,
+}
+
+const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAlertProps) => {
+  const { path } = props;
+  const { t } = useTranslation();
+
+  let _path = '――';
+  let _pageName = '――';
+
+  if (path != null) {
+    const devidedPath = new DevidedPagePath(path);
+    _path = devidedPath.isFormerRoot ? '/' : devidedPath.former;
+    _pageName = devidedPath.latter;
+  }
+
+
+  return (
+    <div className="alert alert-warning py-3">
+      <h5 className="font-weight-bold mt-1">{t('duplicated_page_alert.same_page_name_exists', { pageName: _pageName })}</h5>
+      <p>
+        {t('duplicated_page_alert.same_page_name_exists_at_path',
+          { path: _path, pageName: _pageName })}<br />
+        <p
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: t('See_more_detail_on_new_schema', { url: t('GROWI.5.0_new_schema') }) }}
+        />
+      </p>
+      <p className="mb-1">{t('duplicated_page_alert.select_page_to_see')}</p>
+    </div>
+  );
+};
+
 
 type IdenticalPathPageProps= {
   // add props and types here
 }
-const IdenticalPathPage:FC<IdenticalPathPageProps> = (props:IdenticalPathPageProps) => {
+
+
+const jsonNull = 'null';
+
+const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPageProps) => {
+
+  const identicalPageDocument = document.getElementById('identical-path-page');
+  const pageDataList = JSON.parse(identicalPageDocument?.getAttribute('data-identical-page-data-list') || jsonNull);
+  const shortbodyMap = JSON.parse(identicalPageDocument?.getAttribute('data-shortody-map') || jsonNull);
+
+  const { data: currentPath } = useCurrentPagePath();
+
   return (
-    <div>
-      {/* Todo: show alert */}
-      {/* Todo: show identical path page list */}
-      IdenticalPathPageList
+    <div className="d-flex flex-column flex-lg-row-reverse">
+
+      <div className="grw-side-contents-container">
+        <div className="grw-side-contents-sticky-container">
+          <div className="border-bottom pb-1">
+            {/* <PageAccessories isNotFoundPage={!isPageExist} /> */}
+          </div>
+        </div>
+      </div>
+
+      <div className="flex-grow-1 flex-basis-0 mw-0">
+
+        <IdenticalPathAlert path={currentPath} />
+
+        <div className="page-list">
+          <ul className="page-list-ul list-group-flush border px-3">
+            {pageDataList.map((data) => {
+              return (
+                <PageListItemL
+                  key={data.pageData._id}
+                  page={data}
+                  isSelected={false}
+                  isChecked={false}
+                  isEnableActions
+                  shortBody={shortbodyMap[data.pageData._id]}
+                // Todo: add onClickDeleteButton when delete feature implemented
+                />
+              );
+            })}
+          </ul>
+        </div>
+
+      </div>
+
     </div>
   );
 };

+ 14 - 4
packages/app/src/components/InstallerForm.jsx

@@ -55,8 +55,6 @@ class InstallerForm extends React.Component {
     setTimeout(() => {
       this.setState({ isSubmittingDisabled: false });
     }, 3000);
-
-    document['register-form'].submit();
   }
 
   render() {
@@ -66,7 +64,7 @@ class InstallerForm extends React.Component {
       : <span><i className="icon-fw icon-ban" />{ this.props.t('installer.unavaliable_user_id') }</span>;
 
     return (
-      <div className={`login-dialog p-3 mx-auto${hasErrorClass}`}>
+      <div data-testid="installerForm" className={`login-dialog p-3 mx-auto${hasErrorClass}`}>
         <div className="row">
           <div className="col-md-12">
             <p className="alert alert-success">
@@ -84,6 +82,7 @@ class InstallerForm extends React.Component {
                   type="button"
                   className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
                   id="dropdownLanguage"
+                  data-testid="dropdownLanguage"
                   data-toggle="dropdown"
                   aria-haspopup="true"
                   aria-expanded="true"
@@ -100,7 +99,13 @@ class InstallerForm extends React.Component {
                 <div className="dropdown-menu" aria-labelledby="dropdownLanguage">
                   {
                     localeMetadatas.map(meta => (
-                      <button key={meta.id} className="dropdown-item" type="button" onClick={() => { this.changeLanguage(meta) }}>
+                      <button
+                        key={meta.id}
+                        data-testid={`dropdownLanguageMenu-${meta.id}`}
+                        className="dropdown-item"
+                        type="button"
+                        onClick={() => { this.changeLanguage(meta) }}
+                      >
                         {meta.displayName}
                       </button>
                     ))
@@ -114,6 +119,7 @@ class InstallerForm extends React.Component {
                 <span className="input-group-text"><i className="icon-user" /></span>
               </div>
               <input
+                data-testid="tiUsername"
                 type="text"
                 className="form-control"
                 placeholder={this.props.t('User ID')}
@@ -130,6 +136,7 @@ class InstallerForm extends React.Component {
                 <span className="input-group-text"><i className="icon-tag" /></span>
               </div>
               <input
+                data-testid="tiName"
                 type="text"
                 className="form-control"
                 placeholder={this.props.t('Name')}
@@ -144,6 +151,7 @@ class InstallerForm extends React.Component {
                 <span className="input-group-text"><i className="icon-envelope" /></span>
               </div>
               <input
+                data-testid="tiEmail"
                 type="email"
                 className="form-control"
                 placeholder={this.props.t('Email')}
@@ -158,6 +166,7 @@ class InstallerForm extends React.Component {
                 <span className="input-group-text"><i className="icon-lock" /></span>
               </div>
               <input
+                data-testid="tiPassword"
                 type="password"
                 className="form-control"
                 placeholder={this.props.t('Password')}
@@ -170,6 +179,7 @@ class InstallerForm extends React.Component {
 
             <div className="input-group mt-4 mb-3 d-flex justify-content-center">
               <button
+                data-testid="btnSubmit"
                 type="submit"
                 className="btn-fill btn btn-register"
                 id="register"

+ 0 - 1
packages/app/src/components/LikeButtons.tsx

@@ -27,7 +27,6 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
     setIsPopoverOpen(!isPopoverOpen);
   };
 
-
   const handleClick = () => {
     if (props.onLikeClicked == null) {
       return;

+ 14 - 14
packages/app/src/components/LoginForm.jsx

@@ -50,7 +50,7 @@ class LoginForm extends React.Component {
               <i className="icon-user"></i>
             </span>
           </div>
-          <input type="text" className="form-control rounded-0" placeholder="Username or E-mail" name="loginForm[username]" />
+          <input type="text" className="form-control rounded-0" data-testid="tiUsernameForLogin" placeholder="Username or E-mail" name="loginForm[username]" />
           {isLdapStrategySetup && (
             <div className="input-group-append">
               <small className="input-group-text text-success">
@@ -66,12 +66,12 @@ class LoginForm extends React.Component {
               <i className="icon-lock"></i>
             </span>
           </div>
-          <input type="password" className="form-control rounded-0" placeholder="Password" name="loginForm[password]" />
+          <input type="password" className="form-control rounded-0" data-testid="tiPasswordForLogin" placeholder="Password" name="loginForm[password]" />
         </div>
 
         <div className="input-group my-4">
           <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
-          <button type="submit" id="login" className="btn btn-fill rounded-0 login mx-auto">
+          <button type="submit" id="login" className="btn btn-fill rounded-0 login mx-auto" data-testid="btnSubmitForLogin">
             <div className="eff"></div>
             <span className="btn-label">
               <i className="icon-login"></i>
@@ -297,18 +297,18 @@ class LoginForm extends React.Component {
               <div className="front">
                 {isLocalOrLdapStrategiesEnabled && this.renderLocalOrLdapLoginForm()}
                 {isSomeExternalAuthEnabled && this.renderExternalAuthLoginForm()}
+                {isPasswordResetEnabled && (
+                  <div className="text-right mb-2">
+                    <a href="/forgot-password" className="d-block link-switch">
+                      <i className="icon-key"></i> {t('forgot_password.forgot_password')}
+                    </a>
+                  </div>
+                )}
                 {isRegistrationEnabled && (
-                  <div className="row">
-                    <div className="col-12 text-right py-2">
-                      {isPasswordResetEnabled && (
-                        <a href="/forgot-password" className="d-block link-switch mb-1">
-                          <i className="icon-key"></i> {t('forgot_password.forgot_password')}
-                        </a>
-                      )}
-                      <a href="#register" id="register" className="link-switch" onClick={this.switchForm}>
-                        <i className="ti-check-box"></i> {t('Sign up is here')}
-                      </a>
-                    </div>
+                  <div className="text-right mb-2">
+                    <a href="#register" id="register" className="link-switch" onClick={this.switchForm}>
+                      <i className="ti-check-box"></i> {t('Sign up is here')}
+                    </a>
                   </div>
                 )}
               </div>

+ 4 - 3
packages/app/src/components/Navbar/GlobalSearch.tsx

@@ -5,13 +5,14 @@ import { useTranslation } from 'react-i18next';
 import assert from 'assert';
 
 import AppContainer from '~/client/services/AppContainer';
-import { IPageSearchResultData } from '~/interfaces/search';
 import { IFocusable } from '~/client/interfaces/focusable';
+import { useGlobalSearchFormRef } from '~/stores/ui';
+import { IPageSearchMeta } from '~/interfaces/search';
+import { IPageWithMeta } from '~/interfaces/page';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import SearchForm from '../SearchForm';
-import { useGlobalSearchFormRef } from '~/stores/ui';
 
 
 type Props = {
@@ -32,7 +33,7 @@ const GlobalSearch: FC<Props> = (props: Props) => {
   const [isScopeChildren, setScopeChildren] = useState<boolean>(appContainer.getConfig().isSearchScopeChildrenAsDefault);
   const [isFocused, setFocused] = useState<boolean>(false);
 
-  const gotoPage = useCallback((data: IPageSearchResultData[]) => {
+  const gotoPage = useCallback((data: IPageWithMeta<IPageSearchMeta>[]) => {
     assert(data.length > 0);
 
     const page = data[0].pageData; // should be single page selected

+ 36 - 35
packages/app/src/components/Navbar/GrowiSubNavigation.jsx

@@ -2,13 +2,16 @@ import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import {
-  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd,
+  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
+  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
 } from '~/stores/ui';
-import { useCurrentCreatedAt, useCurrentUpdatedAt } from '~/stores/context';
+import {
+  useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath, useIsDeletable,
+  useIsAbleToDeleteCompletely, useCreator, useRevisionAuthor, useIsPageExist, useIsGuestUser,
+} from '~/stores/context';
+import { useSWRTagsInfo } from '~/stores/page';
 
 import TagLabels from '../Page/TagLabels';
 import SubNavButtons from './SubNavButtons';
@@ -28,30 +31,29 @@ const GrowiSubNavigation = (props) => {
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: createdAt } = useCurrentCreatedAt();
   const { data: updatedAt } = useCurrentUpdatedAt();
+  const { data: pageId } = useCurrentPageId();
+  const { data: revisionId } = useRevisionId();
+  const { data: path } = useCurrentPagePath();
+  const { data: isDeletable } = useIsDeletable();
+  const { data: isAbleToDeleteCompletely } = useIsAbleToDeleteCompletely();
+  const { data: creator } = useCreator();
+  const { data: revisionAuthor } = useRevisionAuthor();
+  const { data: isGuestUser } = useIsGuestUser();
+
+  const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
+  const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
+  const { data: isAbleToShowPageEditorModeManager } = useIsAbleToShowPageEditorModeManager();
+  const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
+
+  const { mutate: mutateSWRTagsInfo, data: TagsInfoData } = useSWRTagsInfo(pageId);
 
   const {
-    appContainer, pageContainer, editorContainer, isCompactMode,
+    editorContainer, isCompactMode,
   } = props;
 
-  const {
-    pageId,
-    revisionId,
-    path,
-    isDeletable,
-    isAbleToDeleteCompletely,
-    creator,
-    revisionAuthor,
-    isPageExist,
-    isTrashPage,
-    tags,
-  } = pageContainer.state;
-
-  const { isGuestUser, isSharedUser } = appContainer;
-  const isEditorMode = editorMode !== EditorMode.View;
-  // Tags cannot be edited while the new page and editorMode is view
-  const isTagLabelHidden = (editorMode !== EditorMode.Editor && !isPageExist);
-
-  const isAbleToShowPageManagement = isPageExist && !isTrashPage && !isSharedUser && !isEditorMode;
+  const isViewMode = editorMode === EditorMode.View;
+  const isEditorMode = !isViewMode;
+
   function onPageEditorModeButtonClicked(viewType) {
     mutateEditorMode(viewType);
   }
@@ -63,10 +65,10 @@ const GrowiSubNavigation = (props) => {
     }
 
     try {
-      const { tags } = await apiPost('/tags.update', { pageId, tags: newTags });
+      const { tags } = await apiPost('/tags.update', { pageId, revisionId, tags: newTags });
 
-      // update pageContainer.state
-      pageContainer.setState({ tags });
+      // revalidate SWRTagsInfo
+      mutateSWRTagsInfo();
       // update editorContainer.state
       editorContainer.setState({ tags });
 
@@ -90,9 +92,9 @@ const GrowiSubNavigation = (props) => {
         ) }
 
         <div className="grw-path-nav-container">
-          { pageContainer.isAbleToShowTagLabel && !isCompactMode && !isTagLabelHidden && (
+          { isAbleToShowTagLabel && !isCompactMode && (
             <div className="grw-taglabels-container">
-              <TagLabels tags={tags} tagsUpdateInvoked={tagsUpdatedHandler} />
+              <TagLabels tags={TagsInfoData?.tags || []} tagsUpdateInvoked={tagsUpdatedHandler} />
             </div>
           ) }
           <PagePathNav pageId={pageId} pagePath={path} isSingleLineMode={isEditorMode} isCompactMode={isCompactMode} />
@@ -110,10 +112,11 @@ const GrowiSubNavigation = (props) => {
             path={path}
             isDeletable={isDeletable}
             isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-            willShowPageManagement={isAbleToShowPageManagement}
+            isViewMode={isViewMode}
+            isAbleToShowPageManagement={isAbleToShowPageManagement}
           />
           <div className="mt-2">
-            {pageContainer.isAbleToShowPageEditorModeManager && (
+            {isAbleToShowPageEditorModeManager && (
               <PageEditorModeManager
                 onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
                 isBtnDisabled={isGuestUser}
@@ -125,7 +128,7 @@ const GrowiSubNavigation = (props) => {
         </div>
 
         {/* Page Authors */}
-        { (pageContainer.isAbleToShowPageAuthors && !isCompactMode) && (
+        { (isAbleToShowPageAuthors && !isCompactMode) && (
           <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3">
             <li className="pb-1">
               <AuthorInfo user={creator} date={createdAt} locate="subnav" />
@@ -143,12 +146,10 @@ const GrowiSubNavigation = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, PageContainer, EditorContainer]);
+const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [EditorContainer]);
 
 
 GrowiSubNavigation.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
   isCompactMode: PropTypes.bool,

+ 14 - 17
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -10,7 +10,6 @@ import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { toastError } from '../../client/util/apiNotification';
 import { apiv3Put } from '../../client/util/apiv3-client';
 import { useSWRxLikerList } from '../../stores/user';
-import { useEditorMode } from '~/stores/ui';
 import { useIsGuestUser } from '~/stores/context';
 
 type SubNavButtonsProps= {
@@ -18,18 +17,16 @@ type SubNavButtonsProps= {
   pageId: string,
   revisionId: string,
   path: string,
-  willShowPageManagement: boolean,
+  isViewMode: boolean
+  isAbleToShowPageManagement: boolean,
   isDeletable: boolean,
   isAbleToDeleteCompletely: boolean,
 }
 const SubNavButtons: FC<SubNavButtonsProps> = (props: SubNavButtonsProps) => {
   const {
-    isCompactMode, pageId, revisionId, path, willShowPageManagement, isDeletable, isAbleToDeleteCompletely,
+    isCompactMode, pageId, revisionId, path, isViewMode, isAbleToShowPageManagement, isDeletable, isAbleToDeleteCompletely,
   } = props;
 
-  const { data: editorMode } = useEditorMode();
-  const isViewMode = editorMode === 'view';
-
   const { data: isGuestUser } = useIsGuestUser();
 
   const { data: pageInfo, error: pageInfoError, mutate: mutatePageInfo } = useSWRPageInfo(pageId);
@@ -95,19 +92,19 @@ const SubNavButtons: FC<SubNavButtonsProps> = (props: SubNavButtonsProps) => {
             onBookMarkClicked={bookmarkClickHandler}
           >
           </PageReactionButtons>
+          { isAbleToShowPageManagement && (
+            <PageManagement
+              pageId={pageId}
+              revisionId={revisionId}
+              path={path}
+              isCompactMode={isCompactMode}
+              isDeletable={isDeletable}
+              isAbleToDeleteCompletely={isAbleToDeleteCompletely}
+            >
+            </PageManagement>
+          )}
         </>
       )}
-      {willShowPageManagement && (
-        <PageManagement
-          pageId={pageId}
-          revisionId={revisionId}
-          path={path}
-          isCompactMode={isCompactMode}
-          isDeletable={isDeletable}
-          isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-        >
-        </PageManagement>
-      )}
     </div>
   );
 };

+ 8 - 12
packages/app/src/components/NotFoundPage.jsx → packages/app/src/components/NotFoundPage.tsx

@@ -1,20 +1,20 @@
 import React, { useMemo } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
+
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import PageList from './PageList';
+import DescendantsPageList from './DescendantsPageList';
 import PageTimeline from './PageTimeline';
 
-const NotFoundPage = (props) => {
-  const { t } = props;
+const NotFoundPage = (): JSX.Element => {
+  const { t } = useTranslation();
 
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
         Icon: PageListIcon,
-        Content: PageList,
+        Content: DescendantsPageList,
         i18n: t('page_list'),
         index: 0,
       },
@@ -29,14 +29,10 @@ const NotFoundPage = (props) => {
 
 
   return (
-    <div className="mt-5 d-edit-none">
+    <div className="d-edit-none">
       <CustomNavAndContents navTabMapping={navTabMapping} />
     </div>
   );
 };
 
-NotFoundPage.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-};
-
-export default withTranslation()(NotFoundPage);
+export default NotFoundPage;

+ 12 - 10
packages/app/src/components/Page/DisplaySwitcher.jsx

@@ -35,20 +35,22 @@ const DisplaySwitcher = (props) => {
         <TabPane tabId={EditorMode.View}>
           <div className="d-flex flex-column flex-lg-row-reverse">
 
-            <div className="grw-side-contents-container">
-              <div className="grw-side-contents-sticky-container">
-                <div className="border-bottom pb-1">
-                  <PageAccessories isNotFoundPage={!isPageExist} />
-                </div>
+            { isPageExist && (
+              <div className="grw-side-contents-container">
+                <div className="grw-side-contents-sticky-container">
+                  <div className="border-bottom pb-1">
+                    <PageAccessories />
+                  </div>
 
-                <div className="d-none d-lg-block">
-                  <div id="revision-toc" className="revision-toc">
-                    <TableOfContents />
+                  <div className="d-none d-lg-block">
+                    <div id="revision-toc" className="revision-toc">
+                      <TableOfContents />
+                    </div>
+                    <ContentLinkButtons />
                   </div>
-                  <ContentLinkButtons />
                 </div>
               </div>
-            </div>
+            ) }
 
             <div className="flex-grow-1 flex-basis-0 mw-0">
               {pageUser && <UserInfo pageUser={pageUser} />}

+ 12 - 12
packages/app/src/components/Page/NotFoundAlert.jsx → packages/app/src/components/Page/NotFoundAlert.tsx

@@ -1,15 +1,21 @@
 import React, { useCallback } from 'react';
-import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
+
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
 
-const NotFoundAlert = (props) => {
+type Props = {
+  isGuestUserMode?: boolean,
+}
+
+const NotFoundAlert = (props: Props): JSX.Element => {
   const { t } = useTranslation();
-  const { isHidden, isGuestUserMode } = props;
+  const { isGuestUserMode } = props;
+
+  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
 
-  const { mutate: mutateEditorMode } = useEditorMode();
+  const isEditorMode = editorMode !== EditorMode.View;
 
   const clickHandler = useCallback(() => {
     // check guest user,
@@ -22,11 +28,10 @@ const NotFoundAlert = (props) => {
 
   }, [isGuestUserMode, mutateEditorMode]);
 
-  if (isHidden) {
-    return null;
+  if (isEditorMode) {
+    return <></>;
   }
 
-
   return (
     <div className="border border-info p-3">
       <div
@@ -59,9 +64,4 @@ const NotFoundAlert = (props) => {
 };
 
 
-NotFoundAlert.propTypes = {
-  isHidden: PropTypes.bool.isRequired,
-  isGuestUserMode: PropTypes.bool.isRequired,
-};
-
 export default NotFoundAlert;

+ 13 - 17
packages/app/src/components/Page/RenderTagLabels.jsx → packages/app/src/components/Page/RenderTagLabels.tsx

@@ -1,19 +1,23 @@
 import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import { UncontrolledTooltip } from 'reactstrap';
 
-const RenderTagLabels = React.memo((props) => {
-  const {
-    t, tags, isGuestUser,
-  } = props;
+type RenderTagLabelsProps = {
+  tags: string[],
+  isGuestUser: boolean,
+  openEditorModal?: () => void,
+}
+
+const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
+  const { tags, isGuestUser, openEditorModal } = props;
+  const { t } = useTranslation();
 
   function openEditorHandler() {
-    if (props.openEditorModal == null) {
+    if (openEditorModal == null) {
       return;
     }
-    props.openEditorModal();
+    openEditorModal();
   }
 
   // activate suspense
@@ -22,7 +26,6 @@ const RenderTagLabels = React.memo((props) => {
   }
 
   const isTagsEmpty = tags.length === 0;
-
   const tagElements = tags.map((tag) => {
     return (
       <a key={tag} href={`/_search?q=tag:${tag}`} className="grw-tag-label badge badge-secondary mr-2">
@@ -54,12 +57,5 @@ const RenderTagLabels = React.memo((props) => {
 
 });
 
-RenderTagLabels.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  tags: PropTypes.array,
-  openEditorModal: PropTypes.func,
-  isGuestUser: PropTypes.bool.isRequired,
-};
 
-export default withTranslation()(RenderTagLabels);
+export default RenderTagLabels;

+ 2 - 1
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -64,7 +64,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
    */
   getHighlightedBody(body, keywords) {
     const normalizedKeywordsArray = [];
-    // !!TODO!!: add test code
+    // !!TODO!!: add test code refs: https://redmine.weseek.co.jp/issues/86841
     // Separate keywords
     // - Surrounded by double quotation
     // - Split by both full-width and half-width spaces
@@ -84,6 +84,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
 
     // for non-chrome browsers compatibility
     try {
+      // eslint-disable-next-line regex/invalid
       keywordRegexp2 = new RegExp(`(?<!<)${normalizedKeywords}(?!(.*?("|>)))`, 'ig'); // inferior (this doesn't work well when html tags exist a lot) https://regex101.com/r/Dfi61F/1
     }
     catch (err) {

+ 0 - 113
packages/app/src/components/Page/TagLabels.jsx

@@ -1,113 +0,0 @@
-import React, { Suspense } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-import PageContainer from '~/client/services/PageContainer';
-import { EditorMode } from '~/stores/ui';
-import { toastError, toastSuccess } from '~/client/util/apiNotification';
-
-import RenderTagLabels from './RenderTagLabels';
-import TagEditModal from './TagEditModal';
-
-class TagLabels extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isTagEditModalShown: false,
-    };
-
-    this.openEditorModal = this.openEditorModal.bind(this);
-    this.closeEditorModal = this.closeEditorModal.bind(this);
-  }
-
-
-  openEditorModal() {
-    this.setState({ isTagEditModalShown: true });
-  }
-
-  closeEditorModal() {
-    this.setState({ isTagEditModalShown: false });
-  }
-
-  async tagsUpdatedHandler(newTags) {
-    const {
-      appContainer, editorContainer, pageContainer, editorMode,
-    } = this.props;
-
-    const { pageId, revisionId } = pageContainer.state;
-    // It will not be reflected in the DB until the page is refreshed
-    if (editorMode === EditorMode.Editor) {
-      return editorContainer.setState({ tags: newTags });
-    }
-    try {
-      const { tags, savedPage } = await appContainer.apiPost('/tags.update', {
-        pageId, tags: newTags, revisionId,
-      });
-      editorContainer.setState({ tags });
-      pageContainer.updatePageMetaData(savedPage, savedPage.revision, tags);
-      toastSuccess('updated tags successfully');
-    }
-    catch (err) {
-      toastError(err, 'fail to update tags');
-    }
-  }
-
-
-  render() {
-    const { appContainer, tagsUpdateInvoked, tags } = this.props;
-
-    return (
-      <>
-
-        <form className="grw-tag-labels form-inline">
-          <i className="tag-icon icon-tag mr-2"></i>
-          <Suspense fallback={<span className="grw-tag-label badge badge-secondary">―</span>}>
-            <RenderTagLabels
-              tags={tags}
-              openEditorModal={this.openEditorModal}
-              isGuestUser={appContainer.isGuestUser}
-            />
-          </Suspense>
-        </form>
-
-        <TagEditModal
-          tags={tags}
-          isOpen={this.state.isTagEditModalShown}
-          onClose={this.closeEditorModal}
-          appContainer={this.props.appContainer}
-          onTagsUpdated={tagsUpdateInvoked}
-        />
-
-      </>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const TagLabelsUnstatedWrapper = withUnstatedContainers(TagLabels, [AppContainer, EditorContainer, PageContainer]);
-
-TagLabels.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  editorMode: PropTypes.string.isRequired,
-  tags: PropTypes.arrayOf(String),
-  tagsUpdateInvoked: PropTypes.func,
-};
-
-// wrapping tsx component returned by withUnstatedContainers to avoid type error when this component used in other tsx components.
-const TagLabelsWrapper = (props) => {
-  return <TagLabelsUnstatedWrapper {...props}></TagLabelsUnstatedWrapper>;
-};
-export default withTranslation()(TagLabelsWrapper);

+ 58 - 0
packages/app/src/components/Page/TagLabels.tsx

@@ -0,0 +1,58 @@
+import React, { FC, Suspense, useState } from 'react';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+import AppContainer from '~/client/services/AppContainer';
+
+import RenderTagLabels from './RenderTagLabels';
+import TagEditModal from './TagEditModal';
+
+type TagLabels = {
+  tags: string[],
+  appContainer: AppContainer,
+  tagsUpdateInvoked?: () => Promise<void>,
+}
+
+
+const TagLabels:FC<TagLabels> = (props:TagLabels) => {
+  const { tags, appContainer, tagsUpdateInvoked } = props;
+
+  const [isTagEditModalShown, setIsTagEditModalShown] = useState(false);
+
+  const openEditorModal = () => {
+    setIsTagEditModalShown(true);
+  };
+
+  const closeEditorModal = () => {
+    setIsTagEditModalShown(false);
+  };
+
+  return (
+    <>
+      <form className="grw-tag-labels form-inline">
+        <i className="tag-icon icon-tag mr-2"></i>
+        <Suspense fallback={<span className="grw-tag-label badge badge-secondary">―</span>}>
+          <RenderTagLabels
+            tags={tags}
+            openEditorModal={openEditorModal}
+            isGuestUser={appContainer.isGuestUser}
+          />
+        </Suspense>
+      </form>
+
+      <TagEditModal
+        tags={tags}
+        isOpen={isTagEditModalShown}
+        onClose={closeEditorModal}
+        onTagsUpdated={tagsUpdateInvoked}
+      />
+
+    </>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const TagLabelsUnstatedWrapper = withUnstatedContainers(TagLabels, [AppContainer]);
+
+export default TagLabelsUnstatedWrapper;

+ 1 - 5
packages/app/src/components/PageAccessories.jsx

@@ -9,7 +9,7 @@ import AppContainer from '~/client/services/AppContainer';
 import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
 
 const PageAccessories = (props) => {
-  const { appContainer, pageAccessoriesContainer, isNotFoundPage } = props;
+  const { appContainer, pageAccessoriesContainer } = props;
   const { isGuestUser, isSharedUser } = appContainer;
 
   return (
@@ -17,12 +17,10 @@ const PageAccessories = (props) => {
       <PageAccessoriesModalControl
         isGuestUser={isGuestUser}
         isSharedUser={isSharedUser}
-        isNotFoundPage={isNotFoundPage}
       />
       <PageAccessoriesModal
         isGuestUser={isGuestUser}
         isSharedUser={isSharedUser}
-        isNotFoundPage={isNotFoundPage}
         isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
         onClose={pageAccessoriesContainer.closePageAccessoriesModal}
       />
@@ -37,8 +35,6 @@ const PageAccessoriesWrapper = withUnstatedContainers(PageAccessories, [AppConta
 PageAccessories.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
-
-  isNotFoundPage: PropTypes.bool.isRequired,
 };
 
 export default PageAccessoriesWrapper;

+ 10 - 9
packages/app/src/components/PageAccessoriesModal.jsx

@@ -13,10 +13,11 @@ import AttachmentIcon from './Icons/AttachmentIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
 
 import { withUnstatedContainers } from './UnstatedUtils';
+import PageContainer from '~/client/services/PageContainer';
 import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
 import PageAttachment from './PageAttachment';
 import PageTimeline from './PageTimeline';
-import PageList from './PageList';
+import DescendantsPageList from './DescendantsPageList';
 import PageHistory from './PageHistory';
 import ShareLink from './ShareLink/ShareLink';
 import { CustomNavTab } from './CustomNavigation/CustomNav';
@@ -24,7 +25,7 @@ import ExpandOrContractButton from './ExpandOrContractButton';
 
 const PageAccessoriesModal = (props) => {
   const {
-    t, pageAccessoriesContainer, onClose, isGuestUser, isSharedUser, isNotFoundPage,
+    t, pageContainer, pageAccessoriesContainer, onClose, isGuestUser, isSharedUser,
   } = props;
   const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
   const { switchActiveTab } = pageAccessoriesContainer;
@@ -49,22 +50,21 @@ const PageAccessoriesModal = (props) => {
         Icon: HistoryIcon,
         i18n: t('History'),
         index: 2,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isNotFoundPage,
+        isLinkEnabled: v => !isGuestUser && !isSharedUser,
       },
       attachment: {
         Icon: AttachmentIcon,
         i18n: t('attachment_data'),
         index: 3,
-        isLinkEnabled: v => !isNotFoundPage,
       },
       shareLink: {
         Icon: ShareLinkIcon,
         i18n: t('share_links.share_link_management'),
         index: 4,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isNotFoundPage && !isLinkSharingDisabled,
+        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isLinkSharingDisabled,
       },
     };
-  }, [t, isGuestUser, isSharedUser, isNotFoundPage, isLinkSharingDisabled]);
+  }, [t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
 
   const closeModalHandler = useCallback(() => {
     if (onClose == null) {
@@ -116,7 +116,7 @@ const PageAccessoriesModal = (props) => {
               the 'navTabMapping[tabId].Content' for PageAccessoriesModal depends on activeComponents */}
           <TabContent activeTab={activeTab}>
             <TabPane tabId="pagelist">
-              {activeComponents.has('pagelist') && <PageList />}
+              {activeComponents.has('pagelist') && <DescendantsPageList path={pageContainer.state.path} />}
             </TabPane>
             <TabPane tabId="timeline">
               {activeComponents.has('timeline') && <PageTimeline /> }
@@ -144,14 +144,15 @@ const PageAccessoriesModal = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal, [PageAccessoriesContainer]);
+const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal, [PageContainer, PageAccessoriesContainer]);
 
 PageAccessoriesModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+
   isGuestUser: PropTypes.bool.isRequired,
   isSharedUser: PropTypes.bool.isRequired,
-  isNotFoundPage: PropTypes.bool.isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func,
 };

+ 10 - 8
packages/app/src/components/PageAccessoriesModalControl.jsx

@@ -15,12 +15,16 @@ import SeenUserInfo from './User/SeenUserInfo';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 
+import { useCurrentPageId } from '~/stores/context';
+
 const PageAccessoriesModalControl = (props) => {
   const {
-    t, pageAccessoriesContainer, isGuestUser, isSharedUser, isNotFoundPage,
+    t, pageAccessoriesContainer, isGuestUser, isSharedUser,
   } = props;
   const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
 
+  const { data: pageId } = useCurrentPageId();
+
   const accessoriesBtnList = useMemo(() => {
     return [
       {
@@ -38,23 +42,22 @@ const PageAccessoriesModalControl = (props) => {
       {
         name: 'pageHistory',
         Icon: <HistoryIcon />,
-        disabled: isGuestUser || isSharedUser || isNotFoundPage,
+        disabled: isGuestUser || isSharedUser,
         i18n: t('History'),
       },
       {
         name: 'attachment',
         Icon: <AttachmentIcon />,
-        disabled: isNotFoundPage,
         i18n: t('attachment_data'),
       },
       {
         name: 'shareLink',
         Icon: <ShareLinkIcon />,
-        disabled: isGuestUser || isSharedUser || isNotFoundPage || isLinkSharingDisabled,
+        disabled: isGuestUser || isSharedUser || isLinkSharingDisabled,
         i18n: t('share_links.share_link_management'),
       },
     ];
-  }, [t, isGuestUser, isSharedUser, isNotFoundPage, isLinkSharingDisabled]);
+  }, [t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
 
   return (
     <div className="grw-page-accessories-control d-flex flex-nowrap align-items-center justify-content-end justify-content-lg-between">
@@ -62,7 +65,7 @@ const PageAccessoriesModalControl = (props) => {
 
         let tooltipMessage;
         if (accessory.disabled) {
-          tooltipMessage = isNotFoundPage ? t('not_found_page.page_not_exist') : t('Not available for guest');
+          tooltipMessage = t('Not available for guest');
           if (accessory.name === 'shareLink' && isLinkSharingDisabled) {
             tooltipMessage = t('Link sharing is disabled');
           }
@@ -90,7 +93,7 @@ const PageAccessoriesModalControl = (props) => {
       })}
       <div className="d-flex align-items-center">
         <span className="border-left grw-border-vr">&nbsp;</span>
-        <SeenUserInfo disabled={isSharedUser} />
+        <SeenUserInfo disabled={isSharedUser} pageId={pageId} />
       </div>
     </div>
   );
@@ -107,7 +110,6 @@ PageAccessoriesModalControl.propTypes = {
 
   isGuestUser: PropTypes.bool.isRequired,
   isSharedUser: PropTypes.bool.isRequired,
-  isNotFoundPage: PropTypes.bool.isRequired,
 };
 
 export default withTranslation()(PageAccessoriesModalControlWrapper);

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

@@ -29,7 +29,7 @@ class RevisionDiff extends React.Component {
       }
 
       const patch = createPatch(
-        '', // currentRevision.path is DEPRECATED
+        currentRevision.pageId, // currentRevision.path is DEPRECATED
         previousText,
         currentRevision.body,
       );

+ 0 - 102
packages/app/src/components/PageList.jsx

@@ -1,102 +0,0 @@
-import React, { useState } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import Page from './PageList/Page';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-
-import { useSWRxPageList } from '~/stores/page';
-
-import PaginationWrapper from './PaginationWrapper';
-
-
-const PageList = (props) => {
-  const { appContainer, pageContainer, t } = props;
-  const { path } = pageContainer.state;
-
-  const [activePage, setActivePage] = useState(1);
-
-  const { data: pagesListData, error: errors } = useSWRxPageList(path, activePage);
-
-  function setPageNumber(selectedPageNumber) {
-    setActivePage(selectedPageNumber);
-  }
-
-  if (errors != null) {
-    return (
-      <div className="my-5">
-        {/* eslint-disable-next-line react/no-array-index-key */}
-        {errors.map((error, index) => <div key={index} className="text-danger">{error.message}</div>)}
-      </div>
-    );
-  }
-
-  if (pagesListData == null) {
-    return (
-      <div className="wiki">
-        <div className="text-muted text-center">
-          <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
-        </div>
-      </div>
-    );
-  }
-
-  const liClasses = props.liClasses.join(' ');
-  const pageList = pagesListData.items.map(page => (
-    <li key={page._id} className={liClasses}>
-      <Page page={page} />
-    </li>
-  ));
-  if (pageList.length === 0) {
-    return (
-      <div className="mt-2">
-        {/* eslint-disable-next-line react/no-danger */}
-        <p>{t('custom_navigation.no_page_list')}</p>
-      </div>
-    );
-  }
-  if (appContainer.config.disableLinkSharing) {
-    return (
-      <div className="mt-2">
-        {/* eslint-disable-next-line react/no-danger */}
-        <p>{t('custom_navigation.link_sharing_is_disabled')}</p>
-      </div>
-    );
-  }
-
-  return (
-    <div className="page-list">
-      <ul className="page-list-ul page-list-ul-flat">
-        {pageList}
-      </ul>
-      <PaginationWrapper
-        activePage={activePage}
-        changePage={setPageNumber}
-        totalItemsCount={pagesListData.totalCount}
-        pagingLimit={pagesListData.limit}
-        align="center"
-      />
-    </div>
-  );
-};
-
-const PageListWrapper = withUnstatedContainers(PageList, [AppContainer, PageContainer]);
-
-const PageListTranslation = withTranslation()(PageListWrapper);
-
-
-PageList.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer),
-  pageContainer: PropTypes.instanceOf(PageContainer),
-
-  liClasses: PropTypes.arrayOf(PropTypes.string),
-};
-PageList.defaultProps = {
-  liClasses: ['mb-3'],
-};
-
-export default PageListTranslation;

+ 2 - 2
packages/app/src/components/PageList/BookmarkList.jsx

@@ -10,7 +10,7 @@ import { toastError } from '~/client/util/apiNotification';
 
 import PaginationWrapper from '../PaginationWrapper';
 
-import Page from './Page';
+import PageListItemS from './PageListItemS';
 
 const logger = loggerFactory('growi:BookmarkList');
 
@@ -56,7 +56,7 @@ const BookmarkList = (props) => {
    */
   const generatePageList = pages.map(page => (
     <li key={`my-bookmarks:${page._id}`} className="mt-4">
-      <Page page={page.page} />
+      <PageListItemS page={page.page} />
     </li>
   ));
 

+ 49 - 0
packages/app/src/components/PageList/PageList.tsx

@@ -0,0 +1,49 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { IPageHasId } from '~/interfaces/page';
+import { IPagingResult } from '~/interfaces/paging-result';
+
+import { PageListItemL } from './PageListItemL';
+
+
+type Props = {
+  pages: IPagingResult<IPageHasId>,
+}
+
+const PageList = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+  const { pages } = props;
+
+  if (pages == null) {
+    return (
+      <div className="wiki">
+        <div className="text-muted text-center">
+          <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+        </div>
+      </div>
+    );
+  }
+
+  const pageList = pages.items.map(page => (
+    <PageListItemL page={{ pageData: page }} />
+  ));
+
+  if (pageList.length === 0) {
+    return (
+      <div className="mt-2">
+        <p>{t('custom_navigation.no_page_list')}</p>
+      </div>
+    );
+  }
+
+  return (
+    <div className="page-list">
+      <ul className="page-list-ul page-list-ul-flat">
+        {pageList}
+      </ul>
+    </div>
+  );
+};
+
+export default PageList;

+ 36 - 31
packages/app/src/components/Page/PageListItem.tsx → packages/app/src/components/PageList/PageListItemL.tsx

@@ -5,28 +5,29 @@ import Clamp from 'react-multiline-clamp';
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
 import { pagePathUtils, DevidedPagePath } from '@growi/core';
 import { useIsDeviceSmallerThanLg } from '~/stores/ui';
+import { IPageWithMeta } from '~/interfaces/page';
+import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
 
-import { IPageSearchResultData } from '../../interfaces/search';
 import PageItemControl from '../Common/Dropdown/PageItemControl';
 
-const { isTopPage } = pagePathUtils;
+const { isTopPage, isUserNamePage } = pagePathUtils;
 
 type Props = {
-  page: IPageSearchResultData,
-  isSelected: boolean, // is item selected(focused)
-  isChecked: boolean, // is checkbox of item checked
-  isEnableActions: boolean,
+  page: IPageWithMeta | IPageWithMeta<IPageSearchMeta>,
+  isSelected?: boolean, // is item selected(focused)
+  isChecked?: boolean, // is checkbox of item checked
+  isEnableActions?: boolean,
   shortBody?: string
   showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
   onClickCheckbox?: (pageId: string) => void,
-  onClickSearchResultItem?: (pageId: string) => void,
+  onClickItem?: (pageId: string) => void,
   onClickDeleteButton?: (pageId: string) => void,
 }
 
-const PageListItem: FC<Props> = memo((props:Props) => {
+export const PageListItemL: FC<Props> = memo((props:Props) => {
   const {
     // todo: refactoring variable name to clear what changed
-    page: { pageData, pageMeta }, isSelected, onClickSearchResultItem, onClickCheckbox, isChecked, isEnableActions, shortBody,
+    page: { pageData, pageMeta }, isSelected, onClickItem, onClickCheckbox, isChecked, isEnableActions, shortBody,
     showPageUpdatedTime,
   } = props;
 
@@ -34,19 +35,21 @@ const PageListItem: FC<Props> = memo((props:Props) => {
 
   const pagePath: DevidedPagePath = new DevidedPagePath(pageData.path, true);
 
+  const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
+
   const pageTitle = (
     <PagePathLabel
-      path={pageMeta.elasticSearchResult?.highlightedPath || pageData.path}
+      path={elasticSearchResult?.highlightedPath || pageData.path}
       isLatterOnly
-      isPathIncludedHtml={pageMeta.elasticSearchResult?.isHtmlInPath}
+      isPathIncludedHtml={elasticSearchResult?.isHtmlInPath}
     >
     </PagePathLabel>
   );
   const pagePathElem = (
     <PagePathLabel
-      path={pageMeta.elasticSearchResult?.highlightedPath || pageData.path}
+      path={elasticSearchResult?.highlightedPath || pageData.path}
       isFormerOnly
-      isPathIncludedHtml={pageMeta.elasticSearchResult?.isHtmlInPath}
+      isPathIncludedHtml={elasticSearchResult?.isHtmlInPath}
     />
   );
 
@@ -57,23 +60,27 @@ const PageListItem: FC<Props> = memo((props:Props) => {
       return;
     }
 
-    if (onClickSearchResultItem != null) {
-      onClickSearchResultItem(pageData._id);
+    if (onClickItem != null) {
+      onClickItem(pageData._id);
     }
-  }, [isDeviceSmallerThanLg, onClickSearchResultItem, pageData._id]);
+  }, [isDeviceSmallerThanLg, onClickItem, pageData._id]);
+
+  const styleListGroupItem = (!isDeviceSmallerThanLg && onClickCheckbox != null) ? 'list-group-item-action' : '';
+  // background color of list item changes when class "active" exists under 'list-group-item'
+  const styleActive = !isDeviceSmallerThanLg && isSelected ? 'active' : '';
+  const styleBorder = onClickCheckbox != null ? 'border-bottom' : 'list-group-item p-0';
 
-  // background color of list item changes when class "active" exists under 'grw-search-result-item'
-  const responsiveListStyleClass = `${isDeviceSmallerThanLg ? '' : `list-group-item-action ${isSelected ? 'active' : ''}`}`;
   return (
     <li
       key={pageData._id}
-      className={`w-100 grw-search-result-item border-bottom ${responsiveListStyleClass}`}
+      className={`list-group-item p-0 ${styleListGroupItem} ${styleActive} ${styleBorder}}`
+      }
     >
       <div
-        className="h-100 text-break"
+        className="text-break"
         onClick={clickHandler}
       >
-        <div className="d-flex h-100">
+        <div className="d-flex">
           {/* checkbox */}
           {onClickCheckbox != null && (
             <div className="form-check d-flex align-items-center justify-content-center px-md-2 pl-3 pr-2 search-item-checkbox">
@@ -86,7 +93,7 @@ const PageListItem: FC<Props> = memo((props:Props) => {
               />
             </div>
           )}
-          <div className="search-item-text p-md-3 pl-2 py-3 pr-3 flex-grow-1">
+          <div className="flex-grow-1 p-md-3 pl-2 py-3 pr-3">
             {/* page path */}
             <h6 className="mb-1 py-1 d-flex">
               <a className="d-inline-block" href={pagePath.isRoot ? pagePath.latter : pagePath.former}>
@@ -98,7 +105,7 @@ const PageListItem: FC<Props> = memo((props:Props) => {
             <div className="d-flex align-items-center mb-2">
               {/* Picture */}
               <span className="mr-2 d-none d-md-block">
-                <UserPicture user={pageData.lastUpdateUser} size="sm" />
+                <UserPicture user={pageData.lastUpdateUser} size="md" />
               </span>
               {/* page title */}
               <Clamp lines={1}>
@@ -108,8 +115,8 @@ const PageListItem: FC<Props> = memo((props:Props) => {
               </Clamp>
 
               {/* page meta */}
-              <div className="d-none d-md-flex item-meta py-0 px-1">
-                <PageListMeta page={pageData} bookmarkCount={pageMeta.bookmarkCount} />
+              <div className="d-none d-md-flex py-0 px-1">
+                <PageListMeta page={pageData} bookmarkCount={pageMeta?.bookmarkCount} shouldSpaceOutIcon />
               </div>
               {/* doropdown icon includes page control buttons */}
               <div className="item-control ml-auto">
@@ -117,15 +124,15 @@ const PageListItem: FC<Props> = memo((props:Props) => {
                   page={pageData}
                   onClickDeleteButtonHandler={props.onClickDeleteButton}
                   isEnableActions={isEnableActions}
-                  isDeletable={!isTopPage(pageData.path)}
+                  isDeletable={!isTopPage(pageData.path) && !isUserNamePage(pageData.path)}
                 />
               </div>
             </div>
-            <div className="grw-search-result-list-snippet py-1">
+            <div className="page-list-snippet py-1">
               <Clamp lines={2}>
                 {
-                  pageMeta.elasticSearchResult != null && pageMeta.elasticSearchResult?.snippet.length !== 0 ? (
-                    <div dangerouslySetInnerHTML={{ __html: pageMeta.elasticSearchResult.snippet }}></div>
+                  elasticSearchResult != null && elasticSearchResult?.snippet.length !== 0 ? (
+                    <div dangerouslySetInnerHTML={{ __html: elasticSearchResult.snippet }}></div>
                   ) : (
                     <div>{ shortBody != null ? shortBody : 'Loading ...' }</div> // TODO: improve indicator
                   )
@@ -139,5 +146,3 @@ const PageListItem: FC<Props> = memo((props:Props) => {
     </li>
   );
 });
-
-export default PageListItem;

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

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
 
 
-export default class Page extends React.Component {
+export default class PageListItemS extends React.Component {
 
   render() {
     const {
@@ -27,11 +27,11 @@ export default class Page extends React.Component {
 
 }
 
-Page.propTypes = {
+PageListItemS.propTypes = {
   page: PropTypes.object.isRequired,
   noLink: PropTypes.bool,
 };
 
-Page.defaultProps = {
+PageListItemS.defaultProps = {
   noLink: false,
 };

+ 2 - 2
packages/app/src/components/RecentCreated/RecentCreated.jsx

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 
-import Page from '../PageList/Page';
+import PageListItemS from '../PageList/PageListItemS';
 import PaginationWrapper from '../PaginationWrapper';
 
 class RecentCreated extends React.Component {
@@ -57,7 +57,7 @@ class RecentCreated extends React.Component {
   generatePageList(pages) {
     return pages.map(page => (
       <li key={`recent-created:list-view:${page._id}`} className="mt-4">
-        <Page page={page} />
+        <PageListItemS page={page} />
       </li>
     ));
   }

+ 3 - 2
packages/app/src/components/SearchForm.tsx

@@ -4,8 +4,9 @@ import React, {
 } from 'react';
 import { useTranslation } from 'react-i18next';
 
-import { IPageSearchResultData } from '~/interfaces/search';
 import { IFocusable } from '~/client/interfaces/focusable';
+import { IPageWithMeta } from '~/interfaces/page';
+import { IPageSearchMeta } from '~/interfaces/search';
 
 import SearchTypeahead from './SearchTypeahead';
 
@@ -84,7 +85,7 @@ type Props = {
 
   dropup?: boolean,
   keyword?: string,
-  onChange?: (data: IPageSearchResultData[]) => void,
+  onChange?: (data: IPageWithMeta<IPageSearchMeta>[]) => void,
   onBlur?: () => void,
   onFocus?: () => void,
   onSubmit?: (input: string) => void,

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

@@ -327,7 +327,7 @@ class SearchPage extends React.Component {
         shortBodiesMap={this.state.shortBodiesMap}
         activePage={this.state.activePage}
         pagingLimit={this.state.pagingLimit}
-        onClickSearchResultItem={this.selectPage}
+        onClickItem={this.selectPage}
         onClickCheckbox={this.toggleCheckBox}
         onPagingNumberChanged={this.onPagingNumberChanged}
         onClickDeleteButton={this.deleteSinglePageButtonHandler}

+ 3 - 3
packages/app/src/components/SearchPage/SearchPageLayout.tsx

@@ -34,7 +34,7 @@ const SearchPageLayout: FC<Props> = (props: Props) => {
   return (
     <div className="content-main">
       <div className="search-result d-flex" id="search-result">
-        <div className="mw-0 flex-grow-1 flex-basis-0 page-list border boder-gray search-result-list" id="search-result-list">
+        <div className="mw-0 flex-grow-1 flex-basis-0 border boder-gray search-result-list" id="search-result-list">
 
           <SearchControl></SearchControl>
           <div className="search-result-list-scroll">
@@ -62,8 +62,8 @@ const SearchPageLayout: FC<Props> = (props: Props) => {
               </div>
             </div>
 
-            <div className="page-list">
-              <ul className="page-list-ul page-list-ul-flat px-md-4 nav nav-pills"><SearchResultList></SearchResultList></ul>
+            <div className="page-list px-md-4">
+              <SearchResultList></SearchResultList>
             </div>
           </div>
         </div>

+ 3 - 5
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -1,18 +1,16 @@
 import React, { FC } from 'react';
 
-import { IPageSearchResultData } from '../../interfaces/search';
+import { IPageWithMeta } from '~/interfaces/page';
+import { IPageSearchMeta } from '~/interfaces/search';
 
 import RevisionLoader from '../Page/RevisionLoader';
 import AppContainer from '../../client/services/AppContainer';
 import SearchResultContentSubNavigation from './SearchResultContentSubNavigation';
 
-// TODO : set focusedPage type to ?IPageSearchResultData once #80214 is merged
-// PR: https://github.com/weseek/growi/pull/4649
-
 type Props ={
   appContainer: AppContainer,
   searchingKeyword:string,
-  focusedSearchResultData : IPageSearchResultData,
+  focusedSearchResultData : IPageWithMeta<IPageSearchMeta>,
 }
 
 

+ 12 - 1
packages/app/src/components/SearchPage/SearchResultContentSubNavigation.tsx

@@ -1,11 +1,16 @@
 import React, { FC } from 'react';
+
 import { pagePathUtils } from '@growi/core';
+
+import { EditorMode, useEditorMode } from '~/stores/ui';
+
 import PagePathNav from '../PagePathNav';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../client/services/AppContainer';
 import { useSWRTagsInfo } from '../../stores/page';
 import SubNavButtons from '../Navbar/SubNavButtons';
 
+
 type Props = {
   appContainer:AppContainer
   pageId: string,
@@ -23,11 +28,16 @@ const SearchResultContentSubNavigation: FC<Props> = (props : Props) => {
 
   const { isTrashPage, isDeletablePage } = pagePathUtils;
 
+  const { data: editorMode } = useEditorMode();
+
   const { data: tagInfoData, error: tagInfoError } = useSWRTagsInfo(pageId);
 
   if (tagInfoError != null || tagInfoData == null) {
     return <></>;
   }
+
+  const isViewMode = editorMode === EditorMode.View;
+
   const isPageDeletable = isDeletablePage(path);
   const { isSharedUser } = appContainer;
   const isAbleToShowPageManagement = !(isTrashPage(path)) && !isSharedUser;
@@ -50,9 +60,10 @@ const SearchResultContentSubNavigation: FC<Props> = (props : Props) => {
             pageId={pageId}
             revisionId={revisionId}
             path={path}
+            isViewMode={isViewMode}
             isDeletable={isPageDeletable}
             isAbleToDeleteCompletely={false}
-            willShowPageManagement={isAbleToShowPageManagement}
+            isAbleToShowPageManagement={isAbleToShowPageManagement}
           >
           </SubNavButtons>
         </div>

+ 11 - 9
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -1,20 +1,22 @@
 import React, { FC } from 'react';
-import PageListItem from '../Page/PageListItem';
+import { IPageWithMeta } from '~/interfaces/page';
+import { IPageSearchMeta } from '~/interfaces/search';
+
+import { PageListItemL } from '../PageList/PageListItemL';
 import PaginationWrapper from '../PaginationWrapper';
-import { IPageSearchResultData } from '../../interfaces/search';
 
 
 type Props = {
-  pages: IPageSearchResultData[],
+  pages: IPageWithMeta<IPageSearchMeta>[],
   selectedPagesIdList: Set<string>
   isEnableActions: boolean,
   searchResultCount?: number,
   activePage?: number,
   pagingLimit?: number,
   shortBodiesMap?: Record<string, string>
-  focusedSearchResultData?: IPageSearchResultData,
+  focusedSearchResultData?: IPageWithMeta<IPageSearchMeta>,
   onPagingNumberChanged?: (activePage: number) => void,
-  onClickSearchResultItem?: (pageId: string) => void,
+  onClickItem?: (pageId: string) => void,
   onClickCheckbox?: (pageId: string) => void,
   onClickInvoked?: (pageId: string) => void,
   onClickDeleteButton?: (pageId: string) => void,
@@ -27,17 +29,17 @@ const SearchResultList: FC<Props> = (props:Props) => {
 
   const focusedPageId = (focusedSearchResultData != null && focusedSearchResultData.pageData != null) ? focusedSearchResultData.pageData._id : '';
   return (
-    <>
+    <ul className="page-list-ul list-group list-group-flush">
       {Array.isArray(props.pages) && props.pages.map((page) => {
         const isChecked = selectedPagesIdList.has(page.pageData._id);
 
         return (
-          <PageListItem
+          <PageListItemL
             key={page.pageData._id}
             page={page}
             isEnableActions={isEnableActions}
             shortBody={shortBodiesMap?.[page.pageData._id]}
-            onClickSearchResultItem={props.onClickSearchResultItem}
+            onClickItem={props.onClickItem}
             onClickCheckbox={props.onClickCheckbox}
             isChecked={isChecked}
             isSelected={page.pageData._id === focusedPageId || false}
@@ -56,7 +58,7 @@ const SearchResultList: FC<Props> = (props:Props) => {
         </div>
       )}
 
-    </>
+    </ul>
   );
 
 };

+ 5 - 4
packages/app/src/components/SearchTypeahead.tsx

@@ -10,7 +10,8 @@ import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
 import { IFocusable } from '~/client/interfaces/focusable';
 import { TypeaheadProps } from '~/client/interfaces/react-bootstrap-typeahead';
 import { apiGet } from '~/client/util/apiv1-client';
-import { IPageSearchResultData, IFormattedSearchResult } from '~/interfaces/search';
+import { IFormattedSearchResult, IPageSearchMeta } from '~/interfaces/search';
+import { IPageWithMeta } from '~/interfaces/page';
 
 
 type ResetFormButtonProps = {
@@ -33,7 +34,7 @@ const ResetFormButton: FC<ResetFormButtonProps> = (props: ResetFormButtonProps)
 
 
 type Props = TypeaheadProps & {
-  onSearchSuccess?: (res: IPageSearchResultData[]) => void,
+  onSearchSuccess?: (res: IPageWithMeta<IPageSearchMeta>[]) => void,
   onSearchError?: (err: Error) => void,
   onSubmit?: (input: string) => void,
   inputName?: string,
@@ -60,7 +61,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
 
   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
   const [input, setInput] = useState(props.keywordOnInit!);
-  const [pages, setPages] = useState<IPageSearchResultData[]>();
+  const [pages, setPages] = useState<IPageWithMeta<IPageSearchMeta>[]>();
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const [searchError, setSearchError] = useState<Error | null>(null);
   const [isLoading, setLoading] = useState(false);
@@ -187,7 +188,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
     inputProps.name = props.inputName;
   }
 
-  const renderMenuItemChildren = (option: IPageSearchResultData) => {
+  const renderMenuItemChildren = (option: IPageWithMeta<IPageSearchMeta>) => {
     const { pageData } = option;
     return (
       <span>

+ 33 - 18
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -17,7 +17,7 @@ import { apiv3Put } from '~/client/util/apiv3-client';
 
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 
-const { isTopPage } = pagePathUtils;
+const { isTopPage, isUserNamePage } = pagePathUtils;
 
 
 interface ItemProps {
@@ -51,6 +51,7 @@ type ItemControlProps = {
   onClickRenameButton?(): void
 }
 
+
 const ItemControl: FC<ItemControlProps> = memo((props: ItemControlProps) => {
   const onClickPlusButton = () => {
     if (props.onClickPlusButton == null) {
@@ -100,12 +101,16 @@ const ItemControl: FC<ItemControlProps> = memo((props: ItemControlProps) => {
   );
 });
 
-const ItemCount: FC = () => {
+
+type ItemCountProps = {
+  descendantCount: number
+}
+
+const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
   return (
     <>
       <span className="grw-pagetree-count badge badge-pill badge-light text-muted">
-        {/* TODO: consider to show the number of children pages */}
-        00
+        {props.descendantCount}
       </span>
     </>
   );
@@ -127,6 +132,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
   const { data, error } = useSWRxPageChildren(isOpen ? page._id : null);
 
+  const hasDescendants = (page.descendantCount != null && page?.descendantCount > 0);
+
+  const isDeletable = !page.isEmpty && !isTopPage(page.path as string) && !isUserNamePage(page.path as string);
+
   const [{ isDragging }, drag] = useDrag(() => ({
     type: 'PAGE_TREE',
     item: { page },
@@ -272,17 +281,21 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     <div className={`grw-pagetree-item-container ${isOver ? 'grw-pagetree-is-over' : ''}`}>
       <li
         ref={(c) => { drag(c); drop(c) }}
-        className={`list-group-item list-group-item-action border-0 py-1 d-flex align-items-center  ${page.isTarget ? 'grw-pagetree-is-target' : ''}`}
+        className={`list-group-item list-group-item-action border-0 py-1 d-flex align-items-center ${page.isTarget ? 'grw-pagetree-is-target' : ''}`}
       >
-        <button
-          type="button"
-          className={`grw-pagetree-button btn ${isOpen ? 'grw-pagetree-open' : ''}`}
-          onClick={onClickLoadChildren}
-        >
-          <div className="grw-triangle-icon">
-            <TriangleIcon />
-          </div>
-        </button>
+        <div className="grw-triangle-container d-flex justify-content-center">
+          {hasDescendants && (
+            <button
+              type="button"
+              className={`grw-pagetree-button btn ${isOpen ? 'grw-pagetree-open' : ''}`}
+              onClick={onClickLoadChildren}
+            >
+              <div className="grw-triangle-icon d-flex justify-content-center">
+                <TriangleIcon />
+              </div>
+            </button>
+          )}
+        </div>
         { isRenameInputShown && (
           <ClosableTextInput
             isShown
@@ -298,9 +311,11 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(pageTitle as string) || '/'}</p>
           </a>
         )}
-        <div className="grw-pagetree-count-wrapper">
-          <ItemCount />
-        </div>
+        {(page.descendantCount != null && page.descendantCount > 0) && (
+          <div className="grw-pagetree-count-wrapper">
+            <ItemCount descendantCount={page.descendantCount} />
+          </div>
+        )}
         <div className="grw-pagetree-control d-none">
           <ItemControl
             page={page}
@@ -308,7 +323,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             onClickDeleteButton={onClickDeleteButton}
             onClickRenameButton={onClickRenameButton}
             isEnableActions={isEnableActions}
-            isDeletable={!page.isEmpty && !isTopPage(page.path as string)}
+            isDeletable={isDeletable}
           />
         </div>
       </li>

+ 7 - 10
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -54,16 +54,13 @@ function LargePageItem({ page }) {
   }
 
   const tags = page.tags;
-  // when tag document is deleted from database directly tags includes null
-  const tagElements = tags.includes(null)
-    ? <></>
-    : tags.map((tag) => {
-      return (
-        <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="grw-tag-label badge badge-secondary mr-2 small">
-          {tag.name}
-        </a>
-      );
-    });
+  const tagElements = tags.map((tag) => {
+    return (
+      <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="grw-tag-label badge badge-secondary mr-2 small">
+        {tag.name}
+      </a>
+    );
+  });
 
   return (
     <li className="list-group-item py-3 px-0">

+ 2 - 2
packages/app/src/components/TrashPageList.jsx

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import PageListIcon from './Icons/PageListIcon';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import PageList from './PageList';
+import DescendantsPageList from './DescendantsPageList';
 
 
 const TrashPageList = (props) => {
@@ -13,7 +13,7 @@ const TrashPageList = (props) => {
     return {
       pagelist: {
         Icon: PageListIcon,
-        Content: PageList,
+        Content: DescendantsPageList,
         i18n: t('page_list'),
         index: 0,
       },

+ 0 - 51
packages/app/src/components/User/SeenUserInfo.jsx

@@ -1,51 +0,0 @@
-// import React from 'react';
-import PropTypes from 'prop-types';
-
-import React, { useState } from 'react';
-import {
-  Button, Popover, PopoverBody,
-} from 'reactstrap';
-import { FootstampIcon } from '@growi/ui';
-import UserPictureList from './UserPictureList';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import PageContainer from '~/client/services/PageContainer';
-
-
-/* eslint react/no-multi-comp: 0, react/prop-types: 0 */
-
-const SeenUserInfo = (props) => {
-  const [popoverOpen, setPopoverOpen] = useState(false);
-  const toggle = () => setPopoverOpen(!popoverOpen);
-  const { pageContainer, disabled } = props;
-  return (
-    <div className="grw-seen-user-info">
-      <Button id="po-seen-user" color="link" className="px-2">
-        <span className="mr-1 footstamp-icon">
-          <FootstampIcon />
-        </span>
-        <span className="seen-user-count">{pageContainer.state.sumOfSeenUsers}</span>
-      </Button>
-      <Popover placement="bottom" isOpen={popoverOpen} target="po-seen-user" toggle={toggle} trigger="legacy" disabled={disabled}>
-        <PopoverBody className="seen-user-popover">
-          <div className="px-2 text-right user-list-content text-truncate text-muted">
-            <UserPictureList users={pageContainer.state.seenUsers} />
-          </div>
-        </PopoverBody>
-      </Popover>
-    </div>
-  );
-};
-
-SeenUserInfo.propTypes = {
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  disabled: PropTypes.bool,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const SeenUserInfoWrapper = withUnstatedContainers(SeenUserInfo, [PageContainer]);
-
-export default (SeenUserInfoWrapper);

+ 49 - 0
packages/app/src/components/User/SeenUserInfo.tsx

@@ -0,0 +1,49 @@
+import React, { FC, useState } from 'react';
+
+import { Button, Popover, PopoverBody } from 'reactstrap';
+import { FootstampIcon } from '@growi/ui';
+
+import UserPictureList from './UserPictureList';
+import { useSWRxPageInfo } from '~/stores/page';
+import { useSWRxUsersList } from '~/stores/user';
+
+interface Props {
+  pageId: string,
+  disabled: boolean
+}
+
+const SeenUserInfo: FC<Props> = (props: Props) => {
+  const { pageId, disabled } = props;
+
+  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+
+  const { data: pageInfo } = useSWRxPageInfo(pageId);
+  const likerIds = pageInfo?.likerIds != null ? pageInfo.likerIds.slice(0, 15) : [];
+  const seenUserIds = pageInfo?.seenUserIds != null ? pageInfo.seenUserIds.slice(0, 15) : [];
+
+  // Put in a mixture of seenUserIds and likerIds data to make the cache work
+  const { data: usersList } = useSWRxUsersList([...likerIds, ...seenUserIds]);
+  const seenUsers = usersList != null ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15) : [];
+
+  const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
+
+  return (
+    <div className="grw-seen-user-info">
+      <Button id="po-seen-user" color="link" className="px-2">
+        <span className="mr-1 footstamp-icon">
+          <FootstampIcon />
+        </span>
+        <span className="seen-user-count">{seenUsers.length}</span>
+      </Button>
+      <Popover placement="bottom" isOpen={isPopoverOpen} target="po-seen-user" toggle={togglePopover} trigger="legacy" disabled={disabled}>
+        <PopoverBody className="seen-user-popover">
+          <div className="px-2 text-right user-list-content text-truncate text-muted">
+            <UserPictureList users={seenUsers} />
+          </div>
+        </PopoverBody>
+      </Popover>
+    </div>
+  );
+};
+
+export default SeenUserInfo;

+ 7 - 0
packages/app/src/interfaces/lang.ts

@@ -0,0 +1,7 @@
+export const Lang = {
+  en_US: 'en_US',
+  ja_JP: 'ja_JP',
+  zh_CN: 'zh_CN',
+} as const;
+export const AllLang = Object.values(Lang);
+export type Lang = typeof Lang[keyof typeof Lang];

+ 15 - 0
packages/app/src/interfaces/page.ts

@@ -34,3 +34,18 @@ export interface IPage {
 export type IPageHasId = IPage & HasObjectId;
 
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;
+
+export type IPageInfo = {
+  bookmarkCount: number,
+  sumOfLikers: number,
+  likerIds: string[],
+  sumOfSeenUsers: number,
+  seenUserIds: string[],
+  isSeen?: boolean,
+  isLiked?: boolean,
+}
+
+export type IPageWithMeta<M = Record<string, unknown>> = {
+  pageData: IPageHasId,
+  pageMeta?: Partial<IPageInfo> & M,
+};

+ 11 - 11
packages/app/src/interfaces/search.ts

@@ -1,4 +1,4 @@
-import { IPageHasId } from './page';
+import { IPageWithMeta } from './page';
 
 export enum CheckboxType {
   NONE_CHECKED = 'noneChecked',
@@ -6,20 +6,20 @@ export enum CheckboxType {
   ALL_CHECKED = 'allChecked',
 }
 
-export type IPageSearchResultData = {
-  pageData: IPageHasId;
-  pageMeta: {
-    bookmarkCount?: number;
-    elasticSearchResult?: {
-      snippet: string;
-      highlightedPath: string;
-      isHtmlInPath: boolean;
-    };
+export type IPageSearchMeta = {
+  elasticSearchResult?: {
+    snippet: string;
+    highlightedPath: string;
+    isHtmlInPath: boolean;
   };
+}
+
+export const isIPageSearchMeta = (meta: any): meta is IPageSearchMeta => {
+  return !!(meta as IPageSearchMeta)?.elasticSearchResult;
 };
 
 export type IFormattedSearchResult = {
-  data: IPageSearchResultData[]
+  data: IPageWithMeta<IPageSearchMeta>[]
 
   totalCount: number
 

+ 2 - 0
packages/app/src/interfaces/user.ts

@@ -4,6 +4,8 @@ import { HasObjectId } from './has-object-id';
 export type IUser = {
   name: string;
   username: string;
+  email: string;
+  password: string;
   imageUrlCached: string;
   admin: boolean;
 }

+ 31 - 41
packages/app/src/server/crowi/index.js

@@ -24,7 +24,7 @@ import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
-
+import { InstallerService } from '../service/installer';
 import Activity from '../models/activity';
 import UserGroup from '../models/user-group';
 import PageRedirect from '../models/page-redirect';
@@ -144,47 +144,8 @@ Crowi.prototype.init = async function() {
     this.setUpGlobalNotification(),
     this.setUpUserNotification(),
   ]);
-};
-
-Crowi.prototype.initForTest = async function() {
-  await this.setupModels();
-  await this.setupConfigManager();
-
-  // setup messaging services
-  await this.setupSocketIoService();
-
-  // // customizeService depends on AppService and XssService
-  // // passportService depends on appService
-  await Promise.all([
-    this.setUpApp(),
-    this.setUpXss(),
-    // this.setUpGrowiBridge(),
-  ]);
-
-  await Promise.all([
-    // this.scanRuntimeVersions(),
-    this.setupPassport(),
-    // this.setupSearcher(),
-    // this.setupMailer(),
-    // this.setupSlackIntegrationService(),
-    // this.setupCsrf(),
-    // this.setUpFileUpload(),
-    this.setupAttachmentService(),
-    this.setUpAcl(),
-    // this.setUpCustomize(),
-    // this.setUpRestQiitaAPI(),
-    // this.setupUserGroup(),
-    // this.setupExport(),
-    // this.setupImport(),
-    this.setupPageService(),
-    this.setupInAppNotificationService(),
-    this.setupActivityService(),
-  ]);
 
-  // globalNotification depends on slack and mailer
-  // await Promise.all([
-  //   this.setUpGlobalNotification(),
-  // ]);
+  await this.autoInstall();
 };
 
 Crowi.prototype.isPageId = function(pageId) {
@@ -419,6 +380,35 @@ Crowi.prototype.setupCsrf = async function() {
   return Promise.resolve();
 };
 
+Crowi.prototype.autoInstall = function() {
+  const isInstalled = this.configManager.getConfig('crowi', 'app:installed');
+  const username = this.configManager.getConfig('crowi', 'autoInstall:adminUsername');
+
+  if (isInstalled || username == null) {
+    return;
+  }
+
+  logger.info('Start automatic installation');
+
+  const firstAdminUserToSave = {
+    username,
+    name: this.configManager.getConfig('crowi', 'autoInstall:adminName'),
+    email: this.configManager.getConfig('crowi', 'autoInstall:adminEmail'),
+    password: this.configManager.getConfig('crowi', 'autoInstall:adminPassword'),
+    admin: true,
+  };
+  const globalLang = this.configManager.getConfig('crowi', 'autoInstall:globalLang');
+
+  const installerService = new InstallerService(this);
+
+  try {
+    installerService.install(firstAdminUserToSave, globalLang ?? 'en_US');
+  }
+  catch (err) {
+    logger.warn('Automatic installation failed.', err);
+  }
+};
+
 Crowi.prototype.getTokens = function() {
   return this.tokens;
 };

+ 0 - 7
packages/app/src/server/form/admin/userGroupCreate.js

@@ -1,7 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('createGroupForm[userGroupName]', 'Group name').trim().required(),
-);

+ 0 - 8
packages/app/src/server/form/index.js

@@ -1,8 +0,0 @@
-module.exports = {
-  login: require('./login'),
-  register: require('./register'),
-  invited: require('./invited'),
-  admin: {
-    userGroupCreate: require('./admin/userGroupCreate'),
-  },
-};

+ 0 - 9
packages/app/src/server/form/invited.js

@@ -1,9 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('invitedForm.username').required().is(/^[\da-zA-Z\-_.]+$/),
-  field('invitedForm.name').required(),
-  field('invitedForm.password').required().is(/^[\x20-\x7F]*$/).minLength(6),
-);

+ 0 - 8
packages/app/src/server/form/login.js

@@ -1,8 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('loginForm.username').required(),
-  field('loginForm.password').required(),
-);

+ 0 - 11
packages/app/src/server/form/register.js

@@ -1,11 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('registerForm.username').required().is(/^[\da-zA-Z\-_.]+$/),
-  field('registerForm.name').required(),
-  field('registerForm.email').required(),
-  field('registerForm.password').required().is(/^[\x20-\x7F]*$/).minLength(6),
-  field('registerForm[app:globalLang]'),
-);

+ 85 - 0
packages/app/src/server/middlewares/login-form-validator.ts

@@ -0,0 +1,85 @@
+import { body, validationResult } from 'express-validator';
+
+// form rules
+export const inviteRules = () => {
+  return [
+    body('invitedForm.username')
+      .matches(/^[\da-zA-Z\-_.]+$/)
+      .withMessage('Username has invalid characters')
+      .not()
+      .isEmpty()
+      .withMessage('Username field is required'),
+    body('invitedForm.name').not().isEmpty().withMessage('Name field is required'),
+    body('invitedForm.password')
+      .matches(/^[\x20-\x7F]*$/)
+      .withMessage('Password has invalid character')
+      .isLength({ min: 6 })
+      .withMessage('Password minimum character should be more than 6 characters')
+      .not()
+      .isEmpty()
+      .withMessage('Password field is required'),
+  ];
+};
+
+// validation action
+export const inviteValidation = (req, res, next) => {
+  const form = req.body;
+
+  const errors = validationResult(req);
+  if (errors.isEmpty()) {
+    Object.assign(form, { isValid: true });
+    req.form = form;
+    return next();
+  }
+
+  const extractedErrors: string[] = [];
+  errors.array().map(err => extractedErrors.push(err.msg));
+
+  req.flash('errorMessages', extractedErrors);
+
+  Object.assign(form, { isValid: false });
+  req.form = form;
+
+  return next();
+};
+
+// form rules
+export const loginRules = () => {
+  return [
+    body('loginForm.username')
+      .matches(/^[\da-zA-Z\-_.@]+$/)
+      .withMessage('Username has invalid characters')
+      .not()
+      .isEmpty()
+      .withMessage('Username field is required'),
+    body('loginForm.password')
+      .matches(/^[\x20-\x7F]*$/)
+      .withMessage('Password has invalid character')
+      .isLength({ min: 6 })
+      .withMessage('Password minimum character should be more than 6 characters')
+      .not()
+      .isEmpty()
+      .withMessage('Password field is required'),
+  ];
+};
+
+// validation action
+export const loginValidation = (req, res, next) => {
+  const form = req.body;
+
+  const errors = validationResult(req);
+  if (errors.isEmpty()) {
+    Object.assign(form, { isValid: true });
+    req.form = form;
+    return next();
+  }
+
+  const extractedErrors: string[] = [];
+  errors.array().map(err => extractedErrors.push(err.msg));
+
+  req.flash('errorMessages', extractedErrors);
+  Object.assign(form, { isValid: false });
+  req.form = form;
+
+  return next();
+};

+ 51 - 0
packages/app/src/server/middlewares/register-form-validator.ts

@@ -0,0 +1,51 @@
+import { body, validationResult } from 'express-validator';
+
+// form rules
+export const registerRules = () => {
+  return [
+    body('registerForm.username')
+      .matches(/^[\da-zA-Z\-_.]+$/)
+      .withMessage('Username has invalid characters')
+      .not()
+      .isEmpty()
+      .withMessage('Username field is required'),
+    body('registerForm.name').not().isEmpty().withMessage('Name field is required'),
+    body('registerForm.email')
+      .isEmail()
+      .withMessage('Email format is invalid.')
+      .exists()
+      .withMessage('Email field is required.'),
+    body('registerForm.password')
+      .matches(/^[\x20-\x7F]*$/)
+      .withMessage('Password has invalid character')
+      .isLength({ min: 6 })
+      .withMessage('Password minimum character should be more than 6 characters')
+      .not()
+      .isEmpty()
+      .withMessage('Password field is required'),
+    body('registerForm[app:globalLang]'),
+  ];
+};
+
+// validation action
+export const registerValidation = (req, res, next) => {
+  const form = req.body;
+
+  const errors = validationResult(req);
+  if (errors.isEmpty()) {
+    Object.assign(form, { isValid: true });
+    req.form = form;
+    return next();
+  }
+
+  const extractedErrors: string[] = [];
+  errors.array().map(err => extractedErrors.push(err.msg));
+
+  Object.assign(form, {
+    isValid: false,
+    errors: extractedErrors,
+  });
+  req.form = form;
+
+  return next();
+};

+ 1 - 0
packages/app/src/server/models/page.ts

@@ -278,6 +278,7 @@ schema.statics.findByPathAndViewer = async function(
 
   const baseQuery = useFindOne ? this.findOne({ path }) : this.find({ path });
   const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+
   await addViewerCondition(queryBuilder, user, userGroups);
 
   return queryBuilder.query.exec();

+ 0 - 15
packages/app/src/server/models/user.js

@@ -187,21 +187,6 @@ module.exports = function(crowi) {
     return userData;
   };
 
-  userSchema.methods.canDeleteCompletely = function(creatorId) {
-    const pageCompleteDeletionAuthority = crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
-    if (this.admin) {
-      return true;
-    }
-    if (pageCompleteDeletionAuthority === 'anyOne' || pageCompleteDeletionAuthority == null) {
-      return true;
-    }
-    if (pageCompleteDeletionAuthority === 'adminAndAuthor') {
-      return (this._id.equals(creatorId));
-    }
-
-    return false;
-  };
-
   userSchema.methods.updateApiToken = async function() {
     const self = this;
 

+ 4 - 4
packages/app/src/server/routes/apiv3/forgot-password.js

@@ -23,8 +23,8 @@ module.exports = (crowi) => {
   const validator = {
     password: [
       body('newPassword').isString().not().isEmpty()
-        .isLength({ min: 6 })
-        .withMessage('password must be at least 6 characters long'),
+        .isLength({ min: 8 })
+        .withMessage('password must be at least 8 characters long'),
       // checking if password confirmation matches password
       body('newPasswordConfirm').isString().not().isEmpty()
         .custom((value, { req }) => {
@@ -35,7 +35,7 @@ module.exports = (crowi) => {
 
   const apiLimiter = rateLimit({
     windowMs: 15 * 60 * 1000, // 15 minutes
-    max: 5, // limit each IP to 5 requests per windowMs
+    max: 10, // limit each IP to 10 requests per windowMs
     message:
       'Too many requests were sent from this IP. Please try a password reset request again on the password reset request form',
   });
@@ -81,7 +81,7 @@ module.exports = (crowi) => {
     }
   });
 
-  router.put('/', injectResetOrderByTokenMiddleware, async(req, res) => {
+  router.put('/', apiLimiter, injectResetOrderByTokenMiddleware, csrf, validator.password, apiV3FormValidator, async(req, res) => {
     const { passwordResetOrder } = req;
     const { email } = passwordResetOrder;
     const grobalLang = configManager.getConfig('crowi', 'app:globalLang');

+ 3 - 1
packages/app/src/server/routes/apiv3/pages.js

@@ -380,7 +380,9 @@ module.exports = (crowi) => {
         if (!relationsMap.has(pageId)) {
           relationsMap.set(pageId, []);
         }
-        relationsMap.get(pageId).push(relation.relatedTag);
+        if (relation.relatedTag != null) {
+          relationsMap.get(pageId).push(relation.relatedTag);
+        }
       });
       // add tags to each page
       result.pages.forEach((page) => {

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

@@ -86,8 +86,8 @@ module.exports = (crowi) => {
     password: [
       body('oldPassword').isString(),
       body('newPassword').isString().not().isEmpty()
-        .isLength({ min: 6 })
-        .withMessage('password must be at least 6 characters long'),
+        .isLength({ min: 8 })
+        .withMessage('password must be at least 8 characters long'),
       body('newPasswordConfirm').isString().not().isEmpty()
         .custom((value, { req }) => {
           return (value === req.body.newPassword);

+ 64 - 1
packages/app/src/server/routes/apiv3/users.js

@@ -743,7 +743,7 @@ module.exports = (crowi) => {
   router.put('/update.imageUrlCache', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
     try {
       const userIds = req.body.userIds;
-      const users = await User.find({ _id: { $in: userIds } });
+      const users = await User.find({ _id: { $in: userIds }, imageUrlCached: null });
       const requests = await Promise.all(users.map(async(user) => {
         return {
           updateOne: {
@@ -862,5 +862,68 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *    paths:
+   *      /users/list:
+   *        get:
+   *          tags: [Users]
+   *          summary: /users/list
+   *          operationId: getUsersList
+   *          description: Get list of users
+   *          parameters:
+   *            - in: query
+   *              name: userIds
+   *              schema:
+   *                type: string
+   *                description: user IDs
+   *                example: 5e06fcc7516d64004dbf4da6,5e098d53baa2ac004e7d24ad
+   *          responses:
+   *            200:
+   *              description: Succeeded to get list of users.
+   *              content:
+   *                application/json:
+   *                  schema:
+   *                    properties:
+   *                      users:
+   *                        type: array
+   *                        items:
+   *                          $ref: '#/components/schemas/User'
+   *                        description: user list
+   *            403:
+   *              $ref: '#/components/responses/403'
+   *            500:
+   *              $ref: '#/components/responses/500'
+   */
+  router.get('/list', accessTokenParser, loginRequired, async(req, res) => {
+    const userIds = req.query.userIds || null;
+
+    let userFetcher;
+    if (!userIds || userIds.split(',').length <= 0) {
+      userFetcher = User.findAllUsers();
+    }
+    else {
+      userFetcher = User.findUsersByIds(userIds.split(','));
+    }
+
+    const data = {};
+    try {
+      const users = await userFetcher;
+      data.users = users.map((user) => {
+        // omit email
+        if (user.isEmailPublished !== true) { // compare to 'true' because Crowi original data doesn't have 'isEmailPublished'
+          user.email = undefined;
+        }
+        return user.toObject({ virtuals: true });
+      });
+    }
+    catch (err) {
+      return res.apiv3Err(new ErrorV3(err));
+    }
+
+    return res.apiv3(data);
+  });
+
   return router;
 };

+ 9 - 8
packages/app/src/server/routes/index.js

@@ -4,6 +4,9 @@ import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
 import apiV1FormValidator from '../middlewares/apiv1-form-validator';
 
+import * as loginFormValidator from '../middlewares/login-form-validator';
+import * as registerFormValidator from '../middlewares/register-form-validator';
+
 import * as forgotPassword from './forgot-password';
 import * as privateLegacyPages from './private-legacy-pages';
 import * as allInAppNotifications from './all-in-app-notifications';
@@ -15,7 +18,7 @@ const rateLimit = require('express-rate-limit');
 
 const apiLimiter = rateLimit({
   windowMs: 15 * 60 * 1000, // 15 minutes
-  max: 5, // limit each IP to 5 requests per windowMs
+  max: 10, // limit each IP to 10 requests per windowMs
   message:
     'Too many requests sent from this IP, please try again after 15 minutes',
 });
@@ -35,7 +38,6 @@ module.exports = function(crowi, app) {
   const injectUserUISettings = require('../middlewares/inject-user-ui-settings-to-localvars')();
 
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
-  const form = require('../form');
   const page = require('./page')(crowi, app);
   const login = require('./login')(crowi, app);
   const loginPassport = require('./login-passport')(crowi, app);
@@ -62,10 +64,10 @@ module.exports = function(crowi, app) {
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
   app.get('/login'                    , applicationInstalled, login.preLogin, login.login);
   app.get('/login/invited'            , applicationInstalled, login.invited);
-  app.post('/login/activateInvited'   , applicationInstalled, form.invited                         , csrf, login.invited);
-  app.post('/login'                   , applicationInstalled, form.login                           , csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
+  app.post('/login/activateInvited'   , apiLimiter , applicationInstalled, loginFormValidator.inviteRules(), loginFormValidator.inviteValidation, csrf, login.invited);
+  app.post('/login'                   , apiLimiter , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
 
-  app.post('/register'                , applicationInstalled, form.register                        , csrf, login.register);
+  app.post('/register'                , apiLimiter , applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, csrf, login.register);
   app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
   app.get('/logout'                   , applicationInstalled, logout.logout);
 
@@ -76,7 +78,7 @@ module.exports = function(crowi, app) {
   if (!isInstalled) {
     const installer = require('./installer')(crowi);
     app.get('/installer'              , applicationNotInstalled , installer.index);
-    app.post('/installer'             , applicationNotInstalled , form.register , csrf, installer.install);
+    app.post('/installer'             , apiLimiter , applicationNotInstalled , registerFormValidator.registerRules(), registerFormValidator.registerValidation, csrf, installer.install);
     return;
   }
 
@@ -93,7 +95,7 @@ module.exports = function(crowi, app) {
   app.get('/passport/oidc/callback'               , loginPassport.loginPassportOidcCallback     , loginPassport.loginFailure);
   app.post('/passport/saml/callback'              , loginPassport.loginPassportSamlCallback     , loginPassport.loginFailure);
 
-  app.post('/_api/login/testLdap'    , loginRequiredStrictly , form.login , loginPassport.testLdapCredentials);
+  app.post('/_api/login/testLdap'    , apiLimiter , loginRequiredStrictly , loginFormValidator.loginRules() , loginFormValidator.loginValidation , loginPassport.testLdapCredentials);
 
   // security admin
   app.get('/admin/security'          , loginRequiredStrictly , adminRequired , admin.security.index);
@@ -159,7 +161,6 @@ module.exports = function(crowi, app) {
   app.get('/_api/me/user-group-relations'  , accessTokenParser , loginRequiredStrictly , me.api.userGroupRelations);
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
-  app.get('/_api/users.list'          , accessTokenParser , loginRequired , user.api.list);
   app.get('/_api/pages.list'          , accessTokenParser , loginRequired , page.api.list);
   app.post('/_api/pages.update'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.update);
   app.get('/_api/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);

+ 14 - 66
packages/app/src/server/routes/installer.js

@@ -1,56 +1,13 @@
 import loggerFactory from '~/utils/logger';
 
-module.exports = function(crowi) {
-  const logger = loggerFactory('growi:routes:installer');
-  const path = require('path');
-  const fs = require('graceful-fs');
+import { InstallerService, FailedToCreateAdminUserError } from '../service/installer';
 
-  const models = crowi.models;
-  const { appService } = crowi;
+const logger = loggerFactory('growi:routes:installer');
 
-  const User = models.User;
-  const Page = models.Page;
+module.exports = function(crowi) {
 
   const actions = {};
 
-  async function initSearchIndex() {
-    const { searchService } = crowi;
-    if (!searchService.isReachable) {
-      return;
-    }
-
-    await searchService.rebuildIndex();
-  }
-
-  async function createPage(filePath, pagePath, owner, lang) {
-    try {
-      const markdown = fs.readFileSync(filePath);
-      return Page.create(pagePath, markdown, owner, {});
-    }
-    catch (err) {
-      logger.error(`Failed to create ${pagePath}`, err);
-    }
-  }
-
-  async function createInitialPages(owner, lang) {
-    /*
-     * Keep in this order to avoid creating the same pages
-     */
-    await createPage(path.join(crowi.localeDir, lang, 'sandbox.md'), '/Sandbox', owner, lang);
-    await Promise.all([
-      createPage(path.join(crowi.localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams', owner, lang),
-      createPage(path.join(crowi.localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4', owner, lang),
-      createPage(path.join(crowi.localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math', owner, lang),
-    ]);
-
-    try {
-      await initSearchIndex();
-    }
-    catch (err) {
-      logger.error('Failed to build Elasticsearch Indices', err);
-    }
-  }
-
   actions.index = function(req, res) {
     return res.render('installer');
   };
@@ -68,35 +25,26 @@ module.exports = function(crowi) {
     const password = registerForm.password;
     const language = registerForm['app:globalLang'] || 'en_US';
 
-    await appService.initDB(language);
-
-    // create the root page before creating admin user
-    await createPage(path.join(crowi.localeDir, language, 'welcome.md'), '/', { _id: '000000000000000000000000' }, language); // use 0 as a mock user id
+    const installerService = new InstallerService(crowi);
 
-    // create first admin user
-    // TODO: with transaction
     let adminUser;
     try {
-      adminUser = await User.createUser(name, username, email, password, language);
-      await adminUser.asyncMakeAdmin();
+      adminUser = await installerService.install({
+        name,
+        username,
+        email,
+        password,
+      }, language);
     }
     catch (err) {
-      req.form.errors.push(req.t('message.failed_to_create_admin_user', { errMessage: err.message }));
+      if (err instanceof FailedToCreateAdminUserError) {
+        req.form.errors.push(req.t('message.failed_to_create_admin_user', { errMessage: err.message }));
+      }
       return res.render('installer');
     }
-    // add owner after creating admin user
-    const Revision = crowi.model('Revision');
-    const rootPage = await Page.findOne({ path: '/' });
-    const rootRevision = await Revision.findOne({ pageId: rootPage._id });
-    rootPage.creator = adminUser;
-    rootRevision.creator = adminUser;
-    await Promise.all([rootPage.save(), rootRevision.save()]);
-
-    // create initial pages
-    await createInitialPages(adminUser, language);
 
+    const appService = crowi.appService;
     appService.setupAfterInstall();
-    appService.publishPostInstallationMessage();
 
     // login with passport
     req.logIn(adminUser, (err) => {

+ 28 - 8
packages/app/src/server/routes/page.js

@@ -141,6 +141,7 @@ module.exports = function(crowi, app) {
 
   const Page = crowi.model('Page');
   const User = crowi.model('User');
+  const Bookmark = crowi.model('Bookmark');
   const PageTagRelation = crowi.model('PageTagRelation');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const ShareLink = crowi.model('ShareLink');
@@ -282,8 +283,23 @@ module.exports = function(crowi, app) {
     renderVars.notFoundTargetPathOrId = pathOrId;
   }
 
-  function addRenderVarsWhenNotCreatableOrForbidden(renderVars) {
-    renderVars.isAlertHidden = true;
+  async function addRenderVarsForIdenticalPage(renderVars, pages) {
+    const pageIds = pages.map(p => p._id);
+    const shortBodyMap = await crowi.pageService.shortBodiesMapByPageIds(pageIds);
+
+    const identicalPageDataList = await Promise.all(pages.map(async(page) => {
+      const bookmarkCount = await Bookmark.countByPageId(page._id);
+      page._doc.seenUserCount = (page.seenUsers && page.seenUsers.length) || 0;
+      return {
+        pageData: page,
+        pageMeta: {
+          bookmarkCount,
+        },
+      };
+    }));
+
+    renderVars.identicalPageDataList = identicalPageDataList;
+    renderVars.shortBodyMap = shortBodyMap;
   }
 
   function replacePlaceholdersOfTemplate(template, req) {
@@ -309,11 +325,9 @@ module.exports = function(crowi, app) {
     const renderVars = { path };
 
     if (!isCreatablePage(path)) {
-      addRenderVarsWhenNotCreatableOrForbidden(renderVars);
       view = 'layout-growi/not_creatable';
     }
     else if (req.isForbidden) {
-      addRenderVarsWhenNotCreatableOrForbidden(renderVars);
       view = 'layout-growi/forbidden';
     }
     else {
@@ -502,7 +516,6 @@ module.exports = function(crowi, app) {
       return res.render('layout-growi/not_found_shared_page');
     }
     if (crowi.configManager.getConfig('crowi', 'security:disableLinkSharing')) {
-      addRenderVarsWhenNotCreatableOrForbidden(renderVars);
       return res.render('layout-growi/forbidden');
     }
 
@@ -609,8 +622,15 @@ module.exports = function(crowi, app) {
     const { redirectFrom } = req.query;
 
     if (pages.length >= 2) {
-      return res.render('layout-growi/identical-path-page-list', {
-        pages, redirectFrom,
+
+      const renderVars = {};
+
+      await addRenderVarsForIdenticalPage(renderVars, pages);
+
+      return res.render('layout-growi/identical-path-page', {
+        ...renderVars,
+        redirectFrom,
+        path,
       });
     }
 
@@ -1187,7 +1207,7 @@ module.exports = function(crowi, app) {
 
     try {
       if (isCompletely) {
-        if (!req.user.canDeleteCompletely(page.creator)) {
+        if (!crowi.pageService.canDeleteCompletely(page.creator, req.user)) {
           return res.json(ApiResponse.error('You can not delete completely', 'user_not_admin'));
         }
         await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively);

+ 0 - 71
packages/app/src/server/routes/user.js

@@ -75,76 +75,5 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success({ valid }));
   };
 
-  /**
-   * @swagger
-   *
-   *    /users.list:
-   *      get:
-   *        tags: [Users, CrowiCompatibles]
-   *        operationId: listUsersV1
-   *        summary: /users.list
-   *        description: Get list of users
-   *        parameters:
-   *          - in: query
-   *            name: user_ids
-   *            schema:
-   *              type: string
-   *              description: user IDs
-   *              example: 5e06fcc7516d64004dbf4da6,5e098d53baa2ac004e7d24ad
-   *        responses:
-   *          200:
-   *            description: Succeeded to get list of users.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    users:
-   *                      type: array
-   *                      items:
-   *                        $ref: '#/components/schemas/User'
-   *                      description: user list
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {get} /users.list Get user list
-   * @apiName GetUserList
-   * @apiGroup User
-   *
-   * @apiParam {String} user_ids
-   */
-  api.list = async function(req, res) {
-    const userIds = req.query.user_ids || null; // TODO: handling
-
-    let userFetcher;
-    if (!userIds || userIds.split(',').length <= 0) {
-      userFetcher = User.findAllUsers();
-    }
-    else {
-      userFetcher = User.findUsersByIds(userIds.split(','));
-    }
-
-    const data = {};
-    try {
-      const users = await userFetcher;
-      data.users = users.map((user) => {
-        // omit email
-        if (user.isEmailPublished !== true) { // compare to 'true' because Crowi original data doesn't have 'isEmailPublished'
-          user.email = undefined;
-        }
-        return user.toObject({ virtuals: true });
-      });
-    }
-    catch (err) {
-      return res.json(ApiResponse.error(err));
-    }
-
-    return res.json(ApiResponse.success(data));
-  };
-
   return actions;
 };

+ 7 - 18
packages/app/src/server/service/app.ts

@@ -2,8 +2,6 @@ import { pathUtils } from '@growi/core';
 
 import loggerFactory from '~/utils/logger';
 
-import { generateConfigsForInstalling } from '../models/config';
-
 import S2sMessage from '../models/vo/s2s-message';
 import { S2sMessageHandlable } from './s2s-messaging/handlable';
 import { S2sMessagingService } from './s2s-messaging/base';
@@ -49,6 +47,12 @@ export default class AppService implements S2sMessageHandlable {
     const isDBInitialized = await this.isDBInitialized(true);
     if (isDBInitialized) {
       this.setupAfterInstall();
+
+      // remove message handler
+      const { s2sMessagingService } = this;
+      if (s2sMessagingService != null) {
+        this.s2sMessagingService.removeMessageHandler(this);
+      }
     }
   }
 
@@ -101,15 +105,6 @@ export default class AppService implements S2sMessageHandlable {
     return this.configManager.getConfig('crowi', 'app:confidential');
   }
 
-  /**
-   * Execute only once for installing application
-   */
-  async initDB(globalLang) {
-    const initialConfig = generateConfigsForInstalling();
-    initialConfig['app:globalLang'] = globalLang;
-    await this.configManager.updateConfigsInTheSameNamespace('crowi', initialConfig, true);
-  }
-
   async isDBInitialized(forceReload) {
     if (forceReload) {
       // load configs
@@ -118,16 +113,10 @@ export default class AppService implements S2sMessageHandlable {
     return this.configManager.getConfigFromDB('crowi', 'app:installed');
   }
 
-  async setupAfterInstall() {
+  async setupAfterInstall(): Promise<void> {
     await this.crowi.pluginService.autoDetectAndLoadPlugins();
     this.crowi.setupRoutesAtLast();
     this.crowi.setupGlobalErrorHandlers();
-
-    // remove message handler
-    const { s2sMessagingService } = this;
-    if (s2sMessagingService != null) {
-      this.s2sMessagingService.removeMessageHandler(this);
-    }
   }
 
 }

+ 61 - 1
packages/app/src/server/service/config-loader.ts

@@ -178,6 +178,36 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     default: undefined,
   },
+  AUTO_INSTALL_ADMIN_USERNAME: {
+    ns:      'crowi',
+    key:     'autoInstall:adminUsername',
+    type:    ValueType.STRING,
+    default: null,
+  },
+  AUTO_INSTALL_ADMIN_NAME: {
+    ns:      'crowi',
+    key:     'autoInstall:adminName',
+    type:    ValueType.STRING,
+    default: null,
+  },
+  AUTO_INSTALL_ADMIN_EMAIL: {
+    ns:      'crowi',
+    key:     'autoInstall:adminEmail',
+    type:    ValueType.STRING,
+    default: null,
+  },
+  AUTO_INSTALL_ADMIN_PASSWORD: {
+    ns:      'crowi',
+    key:     'autoInstall:adminPassword',
+    type:    ValueType.STRING,
+    default: null,
+  },
+  AUTO_INSTALL_GLOBAL_LANG: {
+    ns:      'crowi',
+    key:     'autoInstall:globalLang',
+    type:    ValueType.STRING,
+    default: null,
+  },
   S2SMSG_PUBSUB_SERVER_TYPE: {
     ns:      'crowi',
     key:     's2sMessagingPubsub:serverType',
@@ -262,6 +292,30 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.NUMBER,
     default: 8000, // msec
   },
+  SEARCHBOX_SSL_URL: {
+    ns:      'crowi',
+    key:     'app:searchboxSslUrl',
+    type:    ValueType.STRING,
+    default: null,
+  },
+  ELASTICSEARCH_REJECT_UNAUTHORIZED: {
+    ns:      'crowi',
+    key:     'app:elasticsearchRejectUnauthorized',
+    type:    ValueType.BOOLEAN,
+    default: false,
+  },
+  ELASTICSEARCH_REINDEX_ON_BOOT: {
+    ns:      'crowi',
+    key:     'app:elasticsearchReindexOnBoot',
+    type:    ValueType.BOOLEAN,
+    default: false,
+  },
+  USE_ELASTICSEARCH_V6: {
+    ns:      'crowi',
+    key:     'app:useElasticsearchV6',
+    type:    ValueType.BOOLEAN,
+    default: false,
+  },
   MONGO_GRIDFS_TOTAL_LIMIT: {
     ns:      'crowi',
     key:     'gridfs:totalLimit',
@@ -407,7 +461,13 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     ns: 'crowi',
     key: 'security:passport-oidc:oidcClientClockTolerance',
     type: ValueType.NUMBER,
-    default: 10,
+    default: 60,
+  },
+  OIDC_ISSUER_TIMEOUT_OPTION: {
+    ns: 'crowi',
+    key: 'security:passport-oidc:oidcIssuerTimeoutOption',
+    type: ValueType.NUMBER,
+    default: 5000,
   },
   S3_REFERENCE_FILE_WITH_RELAY_MODE: {
     ns:      'crowi',

+ 127 - 0
packages/app/src/server/service/installer.ts

@@ -0,0 +1,127 @@
+import mongoose from 'mongoose';
+import fs from 'graceful-fs';
+import path from 'path';
+import ExtensibleCustomError from 'extensible-custom-error';
+
+import { IPage } from '~/interfaces/page';
+import { IUser } from '~/interfaces/user';
+import { Lang } from '~/interfaces/lang';
+import loggerFactory from '~/utils/logger';
+
+import { generateConfigsForInstalling } from '../models/config';
+
+import SearchService from './search';
+import ConfigManager from './config-manager';
+
+const logger = loggerFactory('growi:service:installer');
+
+export class FailedToCreateAdminUserError extends ExtensibleCustomError {
+}
+
+export class InstallerService {
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  crowi: any;
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
+  constructor(crowi: any) {
+    this.crowi = crowi;
+  }
+
+  private async initSearchIndex() {
+    const searchService: SearchService = this.crowi.searchService;
+    if (!searchService.isReachable) {
+      return;
+    }
+
+    await searchService.rebuildIndex();
+  }
+
+  private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
+
+    // TODO typescriptize models/user.js and remove eslint-disable-next-line
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const Page = mongoose.model('Page') as any;
+
+    try {
+      const markdown = fs.readFileSync(filePath);
+      return Page.create(pagePath, markdown, owner, {}) as IPage;
+    }
+    catch (err) {
+      logger.error(`Failed to create ${pagePath}`, err);
+    }
+  }
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  private async createInitialPages(owner, lang: Lang): Promise<any> {
+    const { localeDir } = this.crowi;
+    // create /Sandbox/*
+    /*
+     * Keep in this order to avoid creating the same pages
+     */
+    await this.createPage(path.join(localeDir, lang, 'sandbox.md'), '/Sandbox', owner);
+    await Promise.all([
+      this.createPage(path.join(localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams', owner),
+      this.createPage(path.join(localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4', owner),
+      this.createPage(path.join(localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math', owner),
+    ]);
+
+    try {
+      await this.initSearchIndex();
+    }
+    catch (err) {
+      logger.error('Failed to build Elasticsearch Indices', err);
+    }
+  }
+
+  /**
+   * Execute only once for installing application
+   */
+  private async initDB(globalLang: Lang): Promise<void> {
+    const configManager: ConfigManager = this.crowi.configManager;
+
+    const initialConfig = generateConfigsForInstalling();
+    initialConfig['app:globalLang'] = globalLang;
+    return configManager.updateConfigsInTheSameNamespace('crowi', initialConfig, true);
+  }
+
+  async install(firstAdminUserToSave: IUser, globalLang: Lang): Promise<IUser> {
+    await this.initDB(globalLang);
+
+    // TODO typescriptize models/user.js and remove eslint-disable-next-line
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const User = mongoose.model('User') as any;
+    const Page = mongoose.model('Page') as any;
+
+    // create portal page for '/' before creating admin user
+    await this.createPage(path.join(this.crowi.localeDir, globalLang, 'welcome.md'), '/', { _id: '000000000000000000000000' }); // use 0 as a mock user id
+
+    // create first admin user
+    // TODO: with transaction
+    let adminUser;
+    try {
+      const {
+        name, username, email, password,
+      } = firstAdminUserToSave;
+      adminUser = await User.createUser(name, username, email, password, globalLang);
+      await adminUser.asyncMakeAdmin();
+    }
+    catch (err) {
+      throw new FailedToCreateAdminUserError(err);
+    }
+
+    // add owner after creating admin user
+    const Revision = this.crowi.model('Revision');
+    const rootPage = await Page.findOne({ path: '/' });
+    const rootRevision = await Revision.findOne({ path: '/' });
+    rootPage.creator = adminUser._id;
+    rootRevision.creator = adminUser._id;
+    await Promise.all([rootPage.save(), rootRevision.save()]);
+
+    // create initial pages
+    await this.createInitialPages(adminUser, globalLang);
+
+    return adminUser;
+  }
+
+}

+ 1319 - 0
packages/app/src/server/service/page.js

@@ -0,0 +1,1319 @@
+import { pagePathUtils } from '@growi/core';
+
+import loggerFactory from '~/utils/logger';
+import { generateGrantCondition } from '~/server/models/page';
+
+import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
+
+import ActivityDefine from '../util/activityDefine';
+
+const mongoose = require('mongoose');
+const escapeStringRegexp = require('escape-string-regexp');
+const streamToPromise = require('stream-to-promise');
+const pathlib = require('path');
+
+const logger = loggerFactory('growi:services:page');
+const debug = require('debug')('growi:services:page');
+const { Writable } = require('stream');
+const { createBatchStream } = require('~/server/util/batch-stream');
+
+const {
+  isCreatablePage, isDeletablePage, isTrashPage, collectAncestorPaths,
+} = pagePathUtils;
+const { serializePageSecurely } = require('../models/serializers/page-serializer');
+
+const BULK_REINDEX_SIZE = 100;
+
+class PageService {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.pageEvent = crowi.event('page');
+    this.tagEvent = crowi.event('tag');
+
+    // init
+    this.initPageEvent();
+  }
+
+  initPageEvent() {
+    // create
+    this.pageEvent.on('create', this.pageEvent.onCreate);
+
+    // createMany
+    this.pageEvent.on('createMany', this.pageEvent.onCreateMany);
+    this.pageEvent.on('addSeenUsers', this.pageEvent.onAddSeenUsers);
+
+    // update
+    this.pageEvent.on('update', async(page, user) => {
+
+      this.pageEvent.onUpdate();
+
+      try {
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_UPDATE);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+
+    // rename
+    this.pageEvent.on('rename', async(page, user) => {
+      try {
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_RENAME);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+
+    // delete
+    this.pageEvent.on('delete', async(page, user) => {
+      try {
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_DELETE);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+
+    // delete completely
+    this.pageEvent.on('deleteCompletely', async(page, user) => {
+      try {
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_DELETE_COMPLETELY);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+
+    // likes
+    this.pageEvent.on('like', async(page, user) => {
+      try {
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_LIKE);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+
+    // bookmark
+    this.pageEvent.on('bookmark', async(page, user) => {
+      try {
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_BOOKMARK);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+  }
+
+  canDeleteCompletely(creatorId, operator) {
+    const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
+    if (operator.admin) {
+      return true;
+    }
+    if (pageCompleteDeletionAuthority === 'anyOne' || pageCompleteDeletionAuthority == null) {
+      return true;
+    }
+    if (pageCompleteDeletionAuthority === 'adminAndAuthor') {
+      const operatorId = operator?._id;
+      return (operatorId != null && operatorId.equals(creatorId));
+    }
+
+    return false;
+  }
+
+  async findPageAndMetaDataByViewer({ pageId, path, user }) {
+
+    const Page = this.crowi.model('Page');
+
+    let page;
+    if (pageId != null) { // prioritized
+      page = await Page.findByIdAndViewer(pageId, user);
+    }
+    else {
+      page = await Page.findByPathAndViewer(path, user);
+    }
+
+    const result = {};
+
+    if (page == null) {
+      const isExist = await Page.count({ $or: [{ _id: pageId }, { path }] }) > 0;
+      result.isForbidden = isExist;
+      result.isNotFound = !isExist;
+      result.isCreatable = isCreatablePage(path);
+      result.isDeletable = false;
+      result.canDeleteCompletely = false;
+      result.page = page;
+
+      return result;
+    }
+
+    result.page = page;
+    result.isForbidden = false;
+    result.isNotFound = false;
+    result.isCreatable = false;
+    result.isDeletable = isDeletablePage(path);
+    result.isDeleted = page.isDeleted();
+    result.canDeleteCompletely = user != null && this.canDeleteCompletely(page.creator, user);
+
+    return result;
+  }
+
+  /**
+   * go back by using redirectTo and return the paths
+   *  ex: when
+   *    '/page1' redirects to '/page2' and
+   *    '/page2' redirects to '/page3'
+   *    and given '/page3',
+   *    '/page1' and '/page2' will be return
+   *
+   * @param {string} redirectTo
+   * @param {object} redirectToPagePathMapping
+   * @param {array} pagePaths
+   */
+  prepareShoudDeletePagesByRedirectTo(redirectTo, redirectToPagePathMapping, pagePaths = []) {
+    const pagePath = redirectToPagePathMapping[redirectTo];
+
+    if (pagePath == null) {
+      return pagePaths;
+    }
+
+    pagePaths.push(pagePath);
+    return this.prepareShoudDeletePagesByRedirectTo(pagePath, redirectToPagePathMapping, pagePaths);
+  }
+
+  /**
+   * Generate read stream to operate descendants of the specified page path
+   * @param {string} targetPagePath
+   * @param {User} viewer
+   */
+  async generateReadStreamToOperateOnlyDescendants(targetPagePath, userToOperate) {
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
+
+    const builder = new PageQueryBuilder(Page.find())
+      .addConditionToExcludeRedirect()
+      .addConditionToListOnlyDescendants(targetPagePath);
+
+    await Page.addConditionToFilteringByViewerToEdit(builder, userToOperate);
+
+    return builder
+      .query
+      .lean()
+      .cursor({ batchSize: BULK_REINDEX_SIZE });
+  }
+
+  async renamePage(page, newPagePath, user, options, isRecursively = false) {
+
+    const Page = this.crowi.model('Page');
+    const Revision = this.crowi.model('Revision');
+    const path = page.path;
+    const createRedirectPage = options.createRedirectPage || false;
+    const updateMetadata = options.updateMetadata || false;
+
+    // sanitize path
+    newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
+
+    // create descendants first
+    if (isRecursively) {
+      await this.renameDescendantsWithStream(page, newPagePath, user, options);
+    }
+
+    const update = {};
+    // update Page
+    update.path = newPagePath;
+    if (updateMetadata) {
+      update.lastUpdateUser = user;
+      update.updatedAt = Date.now();
+    }
+    const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
+
+    // update Rivisions
+    await Revision.updateRevisionListByPath(path, { path: newPagePath }, {});
+
+    if (createRedirectPage) {
+      const body = `redirect ${newPagePath}`;
+      await Page.create(path, body, user, { redirectTo: newPagePath });
+    }
+
+    this.pageEvent.emit('rename', page, user);
+
+    return renamedPage;
+  }
+
+
+  async renameDescendants(pages, user, options, oldPagePathPrefix, newPagePathPrefix) {
+    const Page = this.crowi.model('Page');
+
+    const pageCollection = mongoose.connection.collection('pages');
+    const revisionCollection = mongoose.connection.collection('revisions');
+    const { updateMetadata, createRedirectPage } = options;
+
+    const unorderedBulkOp = pageCollection.initializeUnorderedBulkOp();
+    const createRediectPageBulkOp = pageCollection.initializeUnorderedBulkOp();
+    const revisionUnorderedBulkOp = revisionCollection.initializeUnorderedBulkOp();
+    const createRediectRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
+
+    pages.forEach((page) => {
+      const newPagePath = page.path.replace(oldPagePathPrefix, newPagePathPrefix);
+      const revisionId = new mongoose.Types.ObjectId();
+
+      if (updateMetadata) {
+        unorderedBulkOp
+          .find({ _id: page._id })
+          .update({ $set: { path: newPagePath, lastUpdateUser: user._id, updatedAt: new Date() } });
+      }
+      else {
+        unorderedBulkOp.find({ _id: page._id }).update({ $set: { path: newPagePath } });
+      }
+      if (createRedirectPage) {
+        createRediectPageBulkOp.insert({
+          path: page.path, revision: revisionId, creator: user._id, lastUpdateUser: user._id, status: Page.STATUS_PUBLISHED, redirectTo: newPagePath,
+        });
+        createRediectRevisionBulkOp.insert({
+          _id: revisionId, path: page.path, body: `redirect ${newPagePath}`, author: user._id, format: 'markdown',
+        });
+      }
+      revisionUnorderedBulkOp.find({ path: page.path }).update({ $set: { path: newPagePath } }, { multi: true });
+    });
+
+    try {
+      await unorderedBulkOp.execute();
+      await revisionUnorderedBulkOp.execute();
+      // Execute after unorderedBulkOp to prevent duplication
+      if (createRedirectPage) {
+        await createRediectPageBulkOp.execute();
+        await createRediectRevisionBulkOp.execute();
+      }
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw new Error('Failed to rename pages: ', err);
+      }
+    }
+
+    this.pageEvent.emit('updateMany', pages, user);
+  }
+
+  /**
+   * Create rename stream
+   */
+  async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}) {
+
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
+
+    const newPagePathPrefix = newPagePath;
+    const pathRegExp = new RegExp(`^${escapeStringRegexp(targetPage.path)}`, 'i');
+
+    const renameDescendants = this.renameDescendants.bind(this);
+    const pageEvent = this.pageEvent;
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          await renameDescendants(batch, user, options, pathRegExp, newPagePathPrefix);
+          logger.debug(`Reverting pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('revertPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Reverting pages has completed: (totalCount=${count})`);
+        // update  path
+        targetPage.path = newPagePath;
+        pageEvent.emit('syncDescendantsUpdate', targetPage, user);
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+
+    await streamToPromise(readStream);
+  }
+
+
+  async deleteCompletelyOperation(pageIds, pagePaths) {
+    // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
+    const Bookmark = this.crowi.model('Bookmark');
+    const Comment = this.crowi.model('Comment');
+    const Page = this.crowi.model('Page');
+    const PageTagRelation = this.crowi.model('PageTagRelation');
+    const ShareLink = this.crowi.model('ShareLink');
+    const Revision = this.crowi.model('Revision');
+    const Attachment = this.crowi.model('Attachment');
+
+    const { attachmentService } = this.crowi;
+    const attachments = await Attachment.find({ page: { $in: pageIds } });
+
+    const pages = await Page.find({ redirectTo: { $ne: null } });
+    const redirectToPagePathMapping = {};
+    pages.forEach((page) => {
+      redirectToPagePathMapping[page.redirectTo] = page.path;
+    });
+
+    const redirectedFromPagePaths = [];
+    pagePaths.forEach((pagePath) => {
+      redirectedFromPagePaths.push(...this.prepareShoudDeletePagesByRedirectTo(pagePath, redirectToPagePathMapping));
+    });
+
+    return Promise.all([
+      Bookmark.deleteMany({ page: { $in: pageIds } }),
+      Comment.deleteMany({ page: { $in: pageIds } }),
+      PageTagRelation.deleteMany({ relatedPage: { $in: pageIds } }),
+      ShareLink.deleteMany({ relatedPage: { $in: pageIds } }),
+      Revision.deleteMany({ path: { $in: pagePaths } }),
+      Page.deleteMany({ $or: [{ path: { $in: pagePaths } }, { path: { $in: redirectedFromPagePaths } }, { _id: { $in: pageIds } }] }),
+      attachmentService.removeAllAttachments(attachments),
+    ]);
+  }
+
+  async duplicate(page, newPagePath, user, isRecursively) {
+    const Page = this.crowi.model('Page');
+    const PageTagRelation = mongoose.model('PageTagRelation');
+    // populate
+    await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
+
+    // create option
+    const options = { page };
+    options.grant = page.grant;
+    options.grantUserGroupId = page.grantedGroup;
+    options.grantedUserIds = page.grantedUsers;
+
+    newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
+
+    const createdPage = await Page.create(
+      newPagePath, page.revision.body, user, options,
+    );
+
+    if (isRecursively) {
+      this.duplicateDescendantsWithStream(page, newPagePath, user);
+    }
+
+    // take over tags
+    const originTags = await page.findRelatedTagsById();
+    let savedTags = [];
+    if (originTags != null) {
+      await PageTagRelation.updatePageTags(createdPage.id, originTags);
+      savedTags = await PageTagRelation.listTagNamesByPage(createdPage.id);
+      this.tagEvent.emit('update', createdPage, savedTags);
+    }
+
+    const result = serializePageSecurely(createdPage);
+    result.tags = savedTags;
+
+    return result;
+  }
+
+  /**
+   * Receive the object with oldPageId and newPageId and duplicate the tags from oldPage to newPage
+   * @param {Object} pageIdMapping e.g. key: oldPageId, value: newPageId
+   */
+  async duplicateTags(pageIdMapping) {
+    const PageTagRelation = mongoose.model('PageTagRelation');
+
+    // convert pageId from string to ObjectId
+    const pageIds = Object.keys(pageIdMapping);
+    const stage = { $or: pageIds.map((pageId) => { return { relatedPage: mongoose.Types.ObjectId(pageId) } }) };
+
+    const pagesAssociatedWithTag = await PageTagRelation.aggregate([
+      {
+        $match: stage,
+      },
+      {
+        $group: {
+          _id: '$relatedTag',
+          relatedPages: { $push: '$relatedPage' },
+        },
+      },
+    ]);
+
+    const newPageTagRelation = [];
+    pagesAssociatedWithTag.forEach(({ _id, relatedPages }) => {
+      // relatedPages
+      relatedPages.forEach((pageId) => {
+        newPageTagRelation.push({
+          relatedPage: pageIdMapping[pageId], // newPageId
+          relatedTag: _id,
+        });
+      });
+    });
+
+    return PageTagRelation.insertMany(newPageTagRelation, { ordered: false });
+  }
+
+  async duplicateDescendants(pages, user, oldPagePathPrefix, newPagePathPrefix) {
+    const Page = this.crowi.model('Page');
+    const Revision = this.crowi.model('Revision');
+
+    const paths = pages.map(page => (page.path));
+    const revisions = await Revision.find({ path: { $in: paths } });
+
+    // Mapping to set to the body of the new revision
+    const pathRevisionMapping = {};
+    revisions.forEach((revision) => {
+      pathRevisionMapping[revision.path] = revision;
+    });
+
+    // key: oldPageId, value: newPageId
+    const pageIdMapping = {};
+    const newPages = [];
+    const newRevisions = [];
+
+    pages.forEach((page) => {
+      const newPageId = new mongoose.Types.ObjectId();
+      const newPagePath = page.path.replace(oldPagePathPrefix, newPagePathPrefix);
+      const revisionId = new mongoose.Types.ObjectId();
+      pageIdMapping[page._id] = newPageId;
+
+      newPages.push({
+        _id: newPageId,
+        path: newPagePath,
+        creator: user._id,
+        grant: page.grant,
+        grantedGroup: page.grantedGroup,
+        grantedUsers: page.grantedUsers,
+        lastUpdateUser: user._id,
+        redirectTo: null,
+        revision: revisionId,
+      });
+
+      newRevisions.push({
+        _id: revisionId, path: newPagePath, body: pathRevisionMapping[page.path].body, author: user._id, format: 'markdown',
+      });
+
+    });
+
+    await Page.insertMany(newPages, { ordered: false });
+    await Revision.insertMany(newRevisions, { ordered: false });
+    await this.duplicateTags(pageIdMapping);
+  }
+
+  async duplicateDescendantsWithStream(page, newPagePath, user) {
+
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(page.path, user);
+
+    const newPagePathPrefix = newPagePath;
+    const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
+
+    const duplicateDescendants = this.duplicateDescendants.bind(this);
+    const pageEvent = this.pageEvent;
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix);
+          logger.debug(`Adding pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('addAllPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Adding pages has completed: (totalCount=${count})`);
+        // update  path
+        page.path = newPagePath;
+        pageEvent.emit('syncDescendantsUpdate', page, user);
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+
+  }
+
+
+  async deletePage(page, user, options = {}, isRecursively = false) {
+    const Page = this.crowi.model('Page');
+    const PageTagRelation = this.crowi.model('PageTagRelation');
+    const Revision = this.crowi.model('Revision');
+
+    const newPath = Page.getDeletedPageName(page.path);
+    const isTrashed = isTrashPage(page.path);
+
+    if (isTrashed) {
+      throw new Error('This method does NOT support deleting trashed pages.');
+    }
+
+    if (!Page.isDeletableName(page.path)) {
+      throw new Error('Page is not deletable.');
+    }
+
+    if (isRecursively) {
+      this.deleteDescendantsWithStream(page, user, options);
+    }
+
+    // update Rivisions
+    await Revision.updateRevisionListByPath(page.path, { path: newPath }, {});
+    const deletedPage = await Page.findByIdAndUpdate(page._id, {
+      $set: {
+        path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
+      },
+    }, { new: true });
+    await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: true } });
+    const body = `redirect ${newPath}`;
+    await Page.create(page.path, body, user, { redirectTo: newPath });
+
+    this.pageEvent.emit('delete', page, user);
+    this.pageEvent.emit('create', deletedPage, user);
+
+    return deletedPage;
+  }
+
+  async deleteDescendants(pages, user) {
+    const Page = this.crowi.model('Page');
+
+    const pageCollection = mongoose.connection.collection('pages');
+    const revisionCollection = mongoose.connection.collection('revisions');
+
+    const deletePageBulkOp = pageCollection.initializeUnorderedBulkOp();
+    const updateRevisionListOp = revisionCollection.initializeUnorderedBulkOp();
+    const createRediectRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
+    const newPagesForRedirect = [];
+
+    pages.forEach((page) => {
+      const newPath = Page.getDeletedPageName(page.path);
+      const revisionId = new mongoose.Types.ObjectId();
+      const body = `redirect ${newPath}`;
+
+      deletePageBulkOp.find({ _id: page._id }).update({
+        $set: {
+          path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
+        },
+      });
+      updateRevisionListOp.find({ path: page.path }).update({ $set: { path: newPath } });
+      createRediectRevisionBulkOp.insert({
+        _id: revisionId, path: page.path, body, author: user._id, format: 'markdown',
+      });
+
+      newPagesForRedirect.push({
+        path: page.path,
+        creator: user._id,
+        grant: page.grant,
+        grantedGroup: page.grantedGroup,
+        grantedUsers: page.grantedUsers,
+        lastUpdateUser: user._id,
+        redirectTo: newPath,
+        revision: revisionId,
+      });
+    });
+
+    try {
+      await deletePageBulkOp.execute();
+      await updateRevisionListOp.execute();
+      await createRediectRevisionBulkOp.execute();
+      await Page.insertMany(newPagesForRedirect, { ordered: false });
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw new Error('Failed to revert pages: ', err);
+      }
+    }
+    finally {
+      this.pageEvent.emit('syncDescendantsDelete', pages, user);
+    }
+  }
+
+  /**
+   * Create delete stream
+   */
+  async deleteDescendantsWithStream(targetPage, user, options = {}) {
+
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
+
+    const deleteDescendants = this.deleteDescendants.bind(this);
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          deleteDescendants(batch, user);
+          logger.debug(`Reverting pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('revertPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Reverting pages has completed: (totalCount=${count})`);
+
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+  }
+
+  // delete multiple pages
+  async deleteMultipleCompletely(pages, user, options = {}) {
+    const ids = pages.map(page => (page._id));
+    const paths = pages.map(page => (page.path));
+
+    logger.debug('Deleting completely', paths);
+
+    await this.deleteCompletelyOperation(ids, paths);
+
+    this.pageEvent.emit('syncDescendantsDelete', pages, user); // update as renamed page
+
+    return;
+  }
+
+  async deleteCompletely(page, user, options = {}, isRecursively = false, preventEmitting = false) {
+    const ids = [page._id];
+    const paths = [page.path];
+
+    logger.debug('Deleting completely', paths);
+
+    await this.deleteCompletelyOperation(ids, paths);
+
+    if (isRecursively) {
+      this.deleteCompletelyDescendantsWithStream(page, user, options);
+    }
+
+    if (!preventEmitting) {
+      this.pageEvent.emit('deleteCompletely', page, user);
+    }
+
+    return;
+  }
+
+  /**
+   * Create delete completely stream
+   */
+  async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}) {
+
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
+
+    const deleteMultipleCompletely = this.deleteMultipleCompletely.bind(this);
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          await deleteMultipleCompletely(batch, user, options);
+          logger.debug(`Adding pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('addAllPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Adding pages has completed: (totalCount=${count})`);
+
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+  }
+
+  async revertDeletedDescendants(pages, user) {
+    const Page = this.crowi.model('Page');
+    const pageCollection = mongoose.connection.collection('pages');
+    const revisionCollection = mongoose.connection.collection('revisions');
+
+    const removePageBulkOp = pageCollection.initializeUnorderedBulkOp();
+    const revertPageBulkOp = pageCollection.initializeUnorderedBulkOp();
+    const revertRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
+
+    // e.g. key: '/test'
+    const pathToPageMapping = {};
+    const toPaths = pages.map(page => Page.getRevertDeletedPageName(page.path));
+    const toPages = await Page.find({ path: { $in: toPaths } });
+    toPages.forEach((toPage) => {
+      pathToPageMapping[toPage.path] = toPage;
+    });
+
+    pages.forEach((page) => {
+
+      // e.g. page.path = /trash/test, toPath = /test
+      const toPath = Page.getRevertDeletedPageName(page.path);
+
+      if (pathToPageMapping[toPath] != null) {
+      // When the page is deleted, it will always be created with "redirectTo" in the path of the original page.
+      // So, it's ok to delete the page
+      // However, If a page exists that is not "redirectTo", something is wrong. (Data correction is needed).
+        if (pathToPageMapping[toPath].redirectTo === page.path) {
+          removePageBulkOp.find({ path: toPath }).delete();
+        }
+      }
+      revertPageBulkOp.find({ _id: page._id }).update({
+        $set: {
+          path: toPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
+        },
+      });
+      revertRevisionBulkOp.find({ path: page.path }).update({ $set: { path: toPath } }, { multi: true });
+    });
+
+    try {
+      await removePageBulkOp.execute();
+      await revertPageBulkOp.execute();
+      await revertRevisionBulkOp.execute();
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw new Error('Failed to revert pages: ', err);
+      }
+    }
+  }
+
+  async revertDeletedPage(page, user, options = {}, isRecursively = false) {
+    const Page = this.crowi.model('Page');
+    const PageTagRelation = this.crowi.model('PageTagRelation');
+    const Revision = this.crowi.model('Revision');
+
+    const newPath = Page.getRevertDeletedPageName(page.path);
+    const originPage = await Page.findByPath(newPath);
+    if (originPage != null) {
+      // When the page is deleted, it will always be created with "redirectTo" in the path of the original page.
+      // So, it's ok to delete the page
+      // However, If a page exists that is not "redirectTo", something is wrong. (Data correction is needed).
+      if (originPage.redirectTo !== page.path) {
+        throw new Error('The new page of to revert is exists and the redirect path of the page is not the deleted page.');
+      }
+
+      await this.deleteCompletely(originPage, user, options, false, true);
+      this.pageEvent.emit('revert', page, user);
+    }
+
+    if (isRecursively) {
+      this.revertDeletedDescendantsWithStream(page, user, options);
+    }
+
+    page.status = Page.STATUS_PUBLISHED;
+    page.lastUpdateUser = user;
+    debug('Revert deleted the page', page, newPath);
+    const updatedPage = await Page.findByIdAndUpdate(page._id, {
+      $set: {
+        path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
+      },
+    }, { new: true });
+    await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
+    await Revision.updateMany({ path: page.path }, { $set: { path: newPath } });
+
+    return updatedPage;
+  }
+
+  /**
+   * Create revert stream
+   */
+  async revertDeletedDescendantsWithStream(targetPage, user, options = {}) {
+
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
+
+    const revertDeletedDescendants = this.revertDeletedDescendants.bind(this);
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          revertDeletedDescendants(batch, user);
+          logger.debug(`Reverting pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('revertPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Reverting pages has completed: (totalCount=${count})`);
+
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+  }
+
+
+  async handlePrivatePagesForGroupsToDelete(groupsToDelete, action, transferToUserGroupId, user) {
+    const Page = this.crowi.model('Page');
+    const pages = await Page.find({ grantedGroup: { $in: groupsToDelete } });
+
+    let operationsToPublicize;
+    switch (action) {
+      case 'public':
+        await Page.publicizePages(pages);
+        break;
+      case 'delete':
+        return this.deleteMultipleCompletely(pages, user);
+      case 'transfer':
+        await Page.transferPagesToGroup(pages, transferToUserGroupId);
+        break;
+      default:
+        throw new Error('Unknown action for private pages');
+    }
+  }
+
+  async shortBodiesMapByPageIds(pageIds = [], user) {
+    const Page = mongoose.model('Page');
+    const MAX_LENGTH = 350;
+
+    // aggregation options
+    const viewerCondition = await generateGrantCondition(user, null);
+    const filterByIds = {
+      _id: { $in: pageIds.map(id => mongoose.Types.ObjectId(id)) },
+    };
+
+    let pages;
+    try {
+      pages = await Page
+        .aggregate([
+          // filter by pageIds
+          {
+            $match: filterByIds,
+          },
+          // filter by viewer
+          viewerCondition,
+          // lookup: https://docs.mongodb.com/v4.4/reference/operator/aggregation/lookup/
+          {
+            $lookup: {
+              from: 'revisions',
+              let: { localRevision: '$revision' },
+              pipeline: [
+                {
+                  $match: {
+                    $expr: {
+                      $eq: ['$_id', '$$localRevision'],
+                    },
+                  },
+                },
+                {
+                  $project: {
+                    // What is $substrCP?
+                    // see: https://stackoverflow.com/questions/43556024/mongodb-error-substrbytes-invalid-range-ending-index-is-in-the-middle-of-a-ut/43556249
+                    revision: { $substrCP: ['$body', 0, MAX_LENGTH] },
+                  },
+                },
+              ],
+              as: 'revisionData',
+            },
+          },
+          // projection
+          {
+            $project: {
+              _id: 1,
+              revisionData: 1,
+            },
+          },
+        ]).exec();
+    }
+    catch (err) {
+      logger.error('Error occurred while generating shortBodiesMap');
+      throw err;
+    }
+
+    const shortBodiesMap = {};
+    pages.forEach((page) => {
+      shortBodiesMap[page._id] = page.revisionData?.[0]?.revision;
+    });
+
+    return shortBodiesMap;
+  }
+
+  validateCrowi() {
+    if (this.crowi == null) {
+      throw new Error('"crowi" is null. Init User model with "crowi" argument first.');
+    }
+  }
+
+  createAndSendNotifications = async function(page, user, action) {
+    const { activityService, inAppNotificationService } = this.crowi;
+
+    const snapshot = stringifySnapshot(page);
+
+    // Create activity
+    const parameters = {
+      user: user._id,
+      targetModel: ActivityDefine.MODEL_PAGE,
+      target: page,
+      action,
+    };
+    const activity = await activityService.createByParameters(parameters);
+
+    // Get user to be notified
+    const targetUsers = await activity.getNotificationTargetUsers();
+
+    // Create and send notifications
+    await inAppNotificationService.upsertByActivity(targetUsers, activity, snapshot);
+    await inAppNotificationService.emitSocketIo(targetUsers);
+  };
+
+  async v5MigrationByPageIds(pageIds) {
+    const Page = mongoose.model('Page');
+
+    if (pageIds == null || pageIds.length === 0) {
+      logger.error('pageIds is null or 0 length.');
+      return;
+    }
+
+    // generate regexps
+    const regexps = await this._generateRegExpsByPageIds(pageIds);
+
+    // migrate recursively
+    try {
+      await this._v5RecursiveMigration(null, regexps);
+    }
+    catch (err) {
+      logger.error('V5 initial miration failed.', err);
+      // socket.emit('v5InitialMirationFailed', { error: err.message }); TODO: use socket to tell user
+
+      throw err;
+    }
+  }
+
+  async _isPagePathIndexUnique() {
+    const Page = this.crowi.model('Page');
+    const now = (new Date()).toString();
+    const path = `growi_check_is_path_index_unique_${now}`;
+
+    let isUnique = false;
+
+    try {
+      await Page.insertMany([
+        { path },
+        { path },
+      ]);
+    }
+    catch (err) {
+      if (err?.code === 11000) { // Error code 11000 indicates the index is unique
+        isUnique = true;
+        logger.info('Page path index is unique.');
+      }
+      else {
+        throw err;
+      }
+    }
+    finally {
+      await Page.deleteMany({ path: { $regex: new RegExp('growi_check_is_path_index_unique', 'g') } });
+    }
+
+
+    return isUnique;
+  }
+
+  // TODO: use socket to send status to the client
+  async v5InitialMigration(grant) {
+    // const socket = this.crowi.socketIoService.getAdminSocket();
+
+    let isUnique;
+    try {
+      isUnique = await this._isPagePathIndexUnique();
+    }
+    catch (err) {
+      logger.error('Failed to check path index status', err);
+      throw err;
+    }
+
+    // drop unique index first
+    if (isUnique) {
+      try {
+        await this._v5NormalizeIndex();
+      }
+      catch (err) {
+        logger.error('V5 index normalization failed.', err);
+        // socket.emit('v5IndexNormalizationFailed', { error: err.message });
+        throw err;
+      }
+    }
+
+    // then migrate
+    try {
+      await this._v5RecursiveMigration(grant, null, true);
+    }
+    catch (err) {
+      logger.error('V5 initial miration failed.', err);
+      // socket.emit('v5InitialMirationFailed', { error: err.message });
+
+      throw err;
+    }
+
+    // update descendantCount of all public pages
+    try {
+      await this.updateDescendantCountOfSelfAndDescendants('/');
+      logger.info('Successfully updated all descendantCount of public pages.');
+    }
+    catch (err) {
+      logger.error('Failed updating descendantCount of public pages.', err);
+      throw err;
+    }
+
+    await this._setIsV5CompatibleTrue();
+  }
+
+  /*
+   * returns an array of js RegExp instance instead of RE2 instance for mongo filter
+   */
+  async _generateRegExpsByPageIds(pageIds) {
+    const Page = mongoose.model('Page');
+
+    let result;
+    try {
+      result = await Page.findListByPageIds(pageIds, null, false);
+    }
+    catch (err) {
+      logger.error('Failed to find pages by ids', err);
+      throw err;
+    }
+
+    const { pages } = result;
+    const regexps = pages.map(page => new RegExp(`^${page.path}`));
+
+    return regexps;
+  }
+
+  async _setIsV5CompatibleTrue() {
+    try {
+      await this.crowi.configManager.updateConfigsInTheSameNamespace('crowi', {
+        'app:isV5Compatible': true,
+      });
+      logger.info('Successfully migrated all public pages.');
+    }
+    catch (err) {
+      logger.warn('Failed to update app:isV5Compatible to true.');
+      throw err;
+    }
+  }
+
+  // TODO: use websocket to show progress
+  async _v5RecursiveMigration(grant, regexps, publicOnly = false) {
+    const BATCH_SIZE = 100;
+    const PAGES_LIMIT = 1000;
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
+
+    // generate filter
+    let filter = {
+      parent: null,
+      path: { $ne: '/' },
+    };
+    if (grant != null) {
+      filter = {
+        ...filter,
+        grant,
+      };
+    }
+    if (regexps != null && regexps.length !== 0) {
+      filter = {
+        ...filter,
+        path: {
+          $in: regexps,
+        },
+      };
+    }
+
+    const total = await Page.countDocuments(filter);
+
+    let baseAggregation = Page
+      .aggregate([
+        {
+          $match: filter,
+        },
+        {
+          $project: { // minimize data to fetch
+            _id: 1,
+            path: 1,
+          },
+        },
+      ]);
+
+    // limit pages to get
+    if (total > PAGES_LIMIT) {
+      baseAggregation = baseAggregation.limit(Math.floor(total * 0.3));
+    }
+
+    const pagesStream = await baseAggregation.cursor({ batchSize: BATCH_SIZE });
+
+    // use batch stream
+    const batchStream = createBatchStream(BATCH_SIZE);
+
+    let countPages = 0;
+    let shouldContinue = true;
+
+    // migrate all siblings for each page
+    const migratePagesStream = new Writable({
+      objectMode: true,
+      async write(pages, encoding, callback) {
+        // make list to create empty pages
+        const parentPathsSet = new Set(pages.map(page => pathlib.dirname(page.path)));
+        const parentPaths = Array.from(parentPathsSet);
+
+        // fill parents with empty pages
+        await Page.createEmptyPagesByPaths(parentPaths, publicOnly);
+
+        // find parents again
+        const builder = new PageQueryBuilder(Page.find({}, { _id: 1, path: 1 }), true);
+        const parents = await builder
+          .addConditionToListByPathsArray(parentPaths)
+          .query
+          .lean()
+          .exec();
+
+        // bulkWrite to update parent
+        const updateManyOperations = parents.map((parent) => {
+          const parentId = parent._id;
+
+          // modify to adjust for RegExp
+          let parentPath = parent.path === '/' ? '' : parent.path;
+          parentPath = escapeStringRegexp(parentPath);
+
+          const filter = {
+            // regexr.com/6889f
+            // ex. /parent/any_child OR /any_level1
+            path: { $regex: new RegExp(`^${parentPath}(\\/[^/]+)\\/?$`, 'i') },
+          };
+          if (grant != null) {
+            filter.grant = grant;
+          }
+
+          return {
+            updateMany: {
+              filter,
+              update: {
+                parent: parentId,
+              },
+            },
+          };
+        });
+        try {
+          const res = await Page.bulkWrite(updateManyOperations);
+          countPages += res.result.nModified;
+          logger.info(`Page migration processing: (count=${countPages})`);
+
+          // throw
+          if (res.result.writeErrors.length > 0) {
+            logger.error('Failed to migrate some pages', res.result.writeErrors);
+            throw Error('Failed to migrate some pages');
+          }
+
+          // finish migration
+          if (res.result.nModified === 0 && res.result.nMatched === 0) {
+            shouldContinue = false;
+            logger.error('Migration is unable to continue', 'parentPaths:', parentPaths, 'bulkWriteResult:', res);
+          }
+        }
+        catch (err) {
+          logger.error('Failed to update page.parent.', err);
+          throw err;
+        }
+
+        callback();
+      },
+      final(callback) {
+        callback();
+      },
+    });
+
+    pagesStream
+      .pipe(batchStream)
+      .pipe(migratePagesStream);
+
+    await streamToPromise(migratePagesStream);
+
+    if (await Page.exists(filter) && shouldContinue) {
+      return this._v5RecursiveMigration(grant, regexps, publicOnly);
+    }
+
+  }
+
+  async _v5NormalizeIndex() {
+    const collection = mongoose.connection.collection('pages');
+
+    try {
+      // drop pages.path_1 indexes
+      await collection.dropIndex('path_1');
+      logger.info('Succeeded to drop unique indexes from pages.path.');
+    }
+    catch (err) {
+      logger.warn('Failed to drop unique indexes from pages.path.', err);
+      throw err;
+    }
+
+    try {
+      // create indexes without
+      await collection.createIndex({ path: 1 }, { unique: false });
+      logger.info('Succeeded to create non-unique indexes on pages.path.');
+    }
+    catch (err) {
+      logger.warn('Failed to create non-unique indexes on pages.path.', err);
+      throw err;
+    }
+  }
+
+  async v5MigratablePrivatePagesCount(user) {
+    if (user == null) {
+      throw Error('user is required');
+    }
+    const Page = this.crowi.model('Page');
+    return Page.count({ parent: null, creator: user, grant: { $ne: Page.GRANT_PUBLIC } });
+  }
+
+  /**
+   * update descendantCount of the following pages
+   * - page that has the same path as the provided path
+   * - pages that are descendants of the above page
+   */
+  async updateDescendantCountOfSelfAndDescendants(path = '/') {
+    const BATCH_SIZE = 200;
+    const Page = this.crowi.model('Page');
+
+    const aggregateCondition = Page.getAggrConditionForPageWithProvidedPathAndDescendants(path);
+    const aggregatedPages = await Page.aggregate(aggregateCondition).cursor({ batchSize: BATCH_SIZE });
+
+    const recountWriteStream = new Writable({
+      objectMode: true,
+      async write(pageDocuments, encoding, callback) {
+        for (const document of pageDocuments) {
+          // eslint-disable-next-line no-await-in-loop
+          await Page.recountDescendantCountOfSelfAndDescendants(document._id);
+        }
+        callback();
+      },
+      final(callback) {
+        callback();
+      },
+    });
+    aggregatedPages
+      .pipe(createBatchStream(BATCH_SIZE))
+      .pipe(recountWriteStream);
+
+    await streamToPromise(recountWriteStream);
+  }
+
+  // update descendantCount of all pages that are ancestors of a provided path by count
+  async updateDescendantCountOfAncestors(path = '/', count = 0) {
+    const Page = this.crowi.model('Page');
+    const ancestors = collectAncestorPaths(path);
+    await Page.incrementDescendantCountOfPaths(ancestors, count);
+  }
+
+}
+
+module.exports = PageService;

+ 30 - 5
packages/app/src/server/service/passport.ts

@@ -12,7 +12,7 @@ import { Profile, Strategy as SamlStrategy, VerifiedCallback } from 'passport-sa
 import { BasicStrategy } from 'passport-http';
 
 import { IncomingMessage } from 'http';
-import got from 'got';
+import axiosRetry from 'axios-retry';
 import pRetry from 'p-retry';
 import loggerFactory from '~/utils/logger';
 
@@ -622,7 +622,8 @@ class PassportService implements S2sMessageHandlable {
 
     // setup client
     // extend oidc request timeouts
-    OIDCIssuer.defaultHttpOptions = { timeout: 5000 };
+    const OIDC_ISSUER_TIMEOUT_OPTION = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:oidcIssuerTimeoutOption');
+    OIDCIssuer.defaultHttpOptions = { timeout: OIDC_ISSUER_TIMEOUT_OPTION };
     const issuerHost = configManager.getConfig('crowi', 'security:passport-oidc:issuerHost');
     const clientId = configManager.getConfig('crowi', 'security:passport-oidc:clientId');
     const clientSecret = configManager.getConfig('crowi', 'security:passport-oidc:clientSecret');
@@ -710,6 +711,20 @@ class PassportService implements S2sMessageHandlable {
     this.isOidcStrategySetup = false;
   }
 
+  /**
+   * Sanitize issuer Host / URL to match specified format
+   * Acceptable format : eg. https://hostname.com
+   * @param issuerHost string
+   * @returns string URL.origin
+   */
+  getOIDCIssuerHostName(issuerHost) {
+    const protocol = 'https://';
+    const pattern = /^https?:\/\//i;
+    // Set protocol if not available on url
+    const absUrl = !pattern.test(issuerHost) ? `${protocol}${issuerHost}` : issuerHost;
+    return new URL(absUrl).origin;
+  }
+
   /**
  *
  * Check and initialize connection to OIDC issuer host
@@ -720,8 +735,18 @@ class PassportService implements S2sMessageHandlable {
  */
   async isOidcHostReachable(issuerHost) {
     try {
-      const response = await got(issuerHost, { retry: { limit: 3 } });
-      return response.statusCode === 200;
+      const hostname = this.getOIDCIssuerHostName(issuerHost);
+      const client = require('axios').default;
+      axiosRetry(client, {
+        retries: 3,
+      });
+      const response = await client.get(`${hostname}/.well-known/openid-configuration`);
+      // Check for valid OIDC Issuer configuration
+      if (!response.data.issuer) {
+        logger.debug('OidcStrategy: Invalid OIDC Issuer configurations');
+        return false;
+      }
+      return true;
     }
     catch (err) {
       logger.error('OidcStrategy: issuer host unreachable:', err.code);
@@ -740,7 +765,7 @@ class PassportService implements S2sMessageHandlable {
     const OIDC_DISCOVERY_RETRIES = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:discoveryRetries');
     const oidcIssuerHostReady = await this.isOidcHostReachable(issuerHost);
     if (!oidcIssuerHostReady) {
-      logger.error('OidcStrategy: setup failed: OIDC Issur host unreachable');
+      logger.error('OidcStrategy: setup failed');
       return;
     }
     const oidcIssuer = await pRetry(async() => {

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