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

Merge branch 'master' into fix/undeletable-trash-when-click-all-delete-button

jam411 3 лет назад
Родитель
Сommit
c02291f404
100 измененных файлов с 3651 добавлено и 1132 удалено
  1. 53 34
      .github/workflows/release-rc.yml
  2. 64 37
      .github/workflows/release.yml
  3. 56 0
      .github/workflows/reusable-app-build-image.yml
  4. 52 0
      .github/workflows/reusable-app-create-manifests.yml
  5. 5 0
      .gitignore
  6. 0 1
      packages/app/.env.development
  7. 1 0
      packages/app/config/logger/config.dev.js
  8. 5 5
      packages/app/docker/Dockerfile
  9. 65 0
      packages/app/docker/codebuild/.terraform.lock.hcl
  10. 32 0
      packages/app/docker/codebuild/buildspec.yml
  11. 25 0
      packages/app/docker/codebuild/codebuild.tf
  12. 23 0
      packages/app/docker/codebuild/main.tf
  13. 26 0
      packages/app/docker/codebuild/oidc.tf
  14. 15 0
      packages/app/docker/codebuild/secretsmanager.tf
  15. 0 6
      packages/app/docker/nocdn/.env.production.local
  16. 3 1
      packages/app/package.json
  17. 12 1
      packages/app/public/static/locales/en_US/admin.json
  18. 10 0
      packages/app/public/static/locales/en_US/commons.json
  19. 1 0
      packages/app/public/static/locales/en_US/translation.json
  20. 11 0
      packages/app/public/static/locales/ja_JP/admin.json
  21. 10 0
      packages/app/public/static/locales/ja_JP/commons.json
  22. 1 0
      packages/app/public/static/locales/ja_JP/translation.json
  23. 12 1
      packages/app/public/static/locales/zh_CN/admin.json
  24. 10 0
      packages/app/public/static/locales/zh_CN/commons.json
  25. 2 0
      packages/app/public/static/locales/zh_CN/translation.json
  26. 15 0
      packages/app/src/client/services/g2g-transfer.ts
  27. 31 38
      packages/app/src/components/Admin/App/AwsSetting.tsx
  28. 158 29
      packages/app/src/components/Admin/App/FileUploadSetting.tsx
  29. 37 34
      packages/app/src/components/Admin/App/GcsSetting.tsx
  30. 4 1
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  31. 0 250
      packages/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.jsx
  32. 232 0
      packages/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx
  33. 0 261
      packages/app/src/components/Admin/ExportArchiveDataPage.jsx
  34. 198 0
      packages/app/src/components/Admin/ExportArchiveDataPage.tsx
  35. 284 0
      packages/app/src/components/Admin/G2GDataTransfer.tsx
  36. 237 0
      packages/app/src/components/Admin/G2GDataTransferExportForm.tsx
  37. 43 0
      packages/app/src/components/Admin/G2GDataTransferStatusIcon.tsx
  38. 4 2
      packages/app/src/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx
  39. 6 8
      packages/app/src/components/Admin/MarkdownSetting/WhiteListInput.jsx
  40. 1 4
      packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx
  41. 1 25
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  42. 38 0
      packages/app/src/components/Common/CustomCopyToClipBoard.tsx
  43. 42 0
      packages/app/src/components/DataTransferForm.tsx
  44. 14 16
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  45. 8 2
      packages/app/src/components/PageComment/Comment.tsx
  46. 3 0
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  47. 23 0
      packages/app/src/interfaces/g2g-transfer.ts
  48. 6 0
      packages/app/src/interfaces/transfer-key.ts
  49. 20 7
      packages/app/src/pages/[[...path]].page.tsx
  50. 3 2
      packages/app/src/pages/_private-legacy-pages.page.tsx
  51. 3 2
      packages/app/src/pages/_search.page.tsx
  52. 54 0
      packages/app/src/pages/admin/data-transfer.page.tsx
  53. 25 4
      packages/app/src/pages/installer.page.tsx
  54. 3 2
      packages/app/src/pages/me/[[...path]].page.tsx
  55. 26 7
      packages/app/src/pages/share/[[...path]].page.tsx
  56. 3 2
      packages/app/src/pages/tags.page.tsx
  57. 3 2
      packages/app/src/pages/trash.page.tsx
  58. 1 1
      packages/app/src/pages/utils/commons.ts
  59. 13 0
      packages/app/src/server/crowi/index.js
  60. 0 1
      packages/app/src/server/models/config.ts
  61. 29 0
      packages/app/src/server/models/transfer-key.ts
  62. 5 1
      packages/app/src/server/models/user.js
  63. 34 0
      packages/app/src/server/models/vo/g2g-transfer-error.ts
  64. 332 0
      packages/app/src/server/routes/apiv3/g2g-transfer.ts
  65. 16 12
      packages/app/src/server/routes/apiv3/import.js
  66. 6 2
      packages/app/src/server/routes/apiv3/index.js
  67. 3 3
      packages/app/src/server/routes/apiv3/overwrite-params/attachmentFiles.chunks.js
  68. 8 15
      packages/app/src/server/routes/apiv3/overwrite-params/pages.js
  69. 4 4
      packages/app/src/server/routes/apiv3/overwrite-params/revisions.js
  70. 12 1
      packages/app/src/server/routes/login-passport.js
  71. 1 1
      packages/app/src/server/service/attachment.js
  72. 0 6
      packages/app/src/server/service/config-loader.ts
  73. 36 2
      packages/app/src/server/service/config-manager.ts
  74. 6 2
      packages/app/src/server/service/export.js
  75. 79 11
      packages/app/src/server/service/file-uploader/aws.ts
  76. 36 12
      packages/app/src/server/service/file-uploader/gcs.js
  77. 34 9
      packages/app/src/server/service/file-uploader/gridfs.js
  78. 55 9
      packages/app/src/server/service/file-uploader/local.js
  79. 7 1
      packages/app/src/server/service/file-uploader/none.js
  80. 48 9
      packages/app/src/server/service/file-uploader/uploader.js
  81. 676 0
      packages/app/src/server/service/g2g-transfer.ts
  82. 1 0
      packages/app/src/server/service/growi-bridge.js
  83. 11 13
      packages/app/src/server/service/import.js
  84. 2 2
      packages/app/src/server/util/createGrowiPagesFromImports.js
  85. 27 3
      packages/app/src/services/renderer/renderer.tsx
  86. 9 2
      packages/app/src/services/xss/xssOption.ts
  87. 14 13
      packages/app/src/stores/page.tsx
  88. 15 0
      packages/app/src/styles/_installer.scss
  89. 1 0
      packages/app/src/styles/style-app.scss
  90. 22 6
      packages/app/src/styles/theme/_apply-colors-dark.scss
  91. 8 2
      packages/app/src/styles/theme/_apply-colors-light.scss
  92. 10 8
      packages/app/src/styles/theme/_apply-colors.scss
  93. 0 6
      packages/app/src/styles/theme/_hsl-reboot-bootstrap-theme-colors.scss
  94. 6 22
      packages/app/src/utils/axios.ts
  95. 58 0
      packages/app/src/utils/vo/transfer-key.ts
  96. 1 1
      packages/core/src/models/vo/error-apiv3.ts
  97. 2 1
      packages/preset-themes/package.json
  98. 0 162
      packages/preset-themes/src/styles/_mixins.scss
  99. 1 2
      packages/preset-themes/src/styles/antarctic.scss
  100. 3 3
      packages/preset-themes/src/styles/christmas.scss

+ 53 - 34
.github/workflows/release-rc.yml

@@ -5,60 +5,79 @@ on:
     branches:
       - rc/**
 
-jobs:
 
-  build-rc:
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+
+jobs:
 
+  determine-tags:
     runs-on: ubuntu-latest
 
-    strategy:
-      matrix:
-        platform: [linux/amd64, linux/arm64]
+    outputs:
+      TAGS: ${{ steps.meta.outputs.tags }}
+      TAGS_GHCR: ${{ steps.meta-ghcr.outputs.tags }}
 
     steps:
     - uses: actions/checkout@v3
-      with:
-        lfs: true
 
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@1.2.0
       id: package-json
 
-    - name: Docker meta
+    - name: Docker meta for docker.io
+      uses: docker/metadata-action@v4
       id: meta
+      with:
+        images: docker.io/weseek/growi
+        sep-tags: ','
+        tags: |
+          type=raw,value=${{ steps.package-json.outputs.packageVersion }}
+          type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
+
+    - name: Docker meta for ghcr.io
       uses: docker/metadata-action@v4
+      id: meta-ghcr
       with:
-        images: weseek/growi,ghcr.io/weseek/growi
+        images: ghcr.io/weseek/growi
+        sep-tags: ','
         tags: |
           type=raw,value=${{ steps.package-json.outputs.packageVersion }}
           type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
 
-    - name: Login to docker.io registry
-      run: |
-        echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
 
-    - name: Login to GitHub Container Registry
-      uses: docker/login-action@v2
-      with:
-        registry: ghcr.io
-        username: wsmoogle
-        password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
+  build-image-rc:
+    uses: weseek/growi/.github/workflows/reusable-app-build-image.yml@master
+    with:
+      image-name: weseek/growi
+      tag-temporary: latest-rc
+    secrets:
+      AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
 
-    - name: Set up QEMU
-      if: ${{ matrix.platform == 'linux/arm64' }}
-      uses: docker/setup-qemu-action@v1
 
-    - name: Set up Docker Buildx
-      uses: docker/setup-buildx-action@v2
+  publish-image-rc:
+    needs: [determine-tags, build-image-rc]
+
+    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    with:
+      tags: ${{ needs.determine-tags.outputs.TAGS }}
+      registry: docker.io
+      image-name: weseek/growi
+      tag-temporary: latest-rc
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+
+  publish-image-rc-ghcr:
+    needs: [determine-tags, build-image-rc]
+
+    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    with:
+      tags: ${{ needs.determine-tags.outputs.TAGS_GHCR }}
+      registry: ghcr.io
+      image-name: weseek/growi
+      tag-temporary: latest-rc
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
 
-    - name: Build and push
-      uses: docker/build-push-action@v2
-      with:
-        context: .
-        file: ./packages/app/docker/Dockerfile
-        platforms: ${{ matrix.platform }}
-        push: true
-        builder: ${{ steps.buildx.outputs.name }}
-        cache-from: type=gha
-        cache-to: type=gha,mode=max
-        tags: ${{ steps.meta.outputs.tags }}

+ 64 - 37
.github/workflows/release.yml

@@ -121,63 +121,91 @@ jobs:
         github_token: ${{ secrets.GITHUB_TOKEN }}
 
 
-  build-image:
+  determine-tags:
     needs: create-github-release
-
     runs-on: ubuntu-latest
 
-    strategy:
-      matrix:
-        platform: [linux/amd64, linux/arm64]
+    outputs:
+      TAGS: ${{ steps.meta.outputs.tags }}
+      TAGS_GHCR: ${{ steps.meta-ghcr.outputs.tags }}
 
     steps:
     - uses: actions/checkout@v3
-      with:
-        ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
-        lfs: true
 
-    - name: Docker meta
-      id: meta
+    - name: Retrieve information from package.json
+      uses: myrotvorets/info-from-package-json-action@1.2.0
+      id: package-json
+
+    - name: Docker meta for docker.io
       uses: docker/metadata-action@v4
+      id: meta
       with:
-        images: weseek/growi,ghcr.io/weseek/growi
+        images: docker.io/weseek/growi
+        sep-tags: ','
         tags: |
           type=raw,value=latest
           type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}
           type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}
           type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}.{{patch}}
 
-    - name: Login to Docker Hub
-      uses: docker/login-action@v2
+    - name: Docker meta for ghcr.io
+      uses: docker/metadata-action@v4
+      id: meta-ghcr
       with:
-        username: wsmoogle
-        password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+        images: ghcr.io/weseek/growi
+        sep-tags: ','
+        tags: |
+          type=raw,value=latest
+          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}
+          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}
+          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}.{{patch}}
 
-    - name: Login to GitHub Container Registry
-      uses: docker/login-action@v2
-      with:
-        registry: ghcr.io
-        username: wsmoogle
-        password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
 
-    - name: Set up QEMU
-      if: ${{ matrix.platform == 'linux/arm64' }}
-      uses: docker/setup-qemu-action@v1
+  build-image:
+    needs: create-github-release
 
-    - name: Set up Docker Buildx
-      uses: docker/setup-buildx-action@v2
+    uses: weseek/growi/.github/workflows/reusable-app-build-image.yml@master
+    with:
+      source-version: refs/tags/v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
+      image-name: weseek/growi
+      tag-temporary: latest
+    secrets:
+      AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
+
+
+  publish-image:
+    needs: [determine-tags, build-image]
+
+    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    with:
+      tags: ${{ needs.determine-tags.outputs.TAGS }}
+      registry: docker.io
+      image-name: weseek/growi
+      tag-temporary: latest
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+
+  publish-image-ghcr:
+    needs: [determine-tags, build-image]
+
+    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    with:
+      tags: ${{ needs.determine-tags.outputs.TAGS_GHCR }}
+      registry: ghcr.io
+      image-name: weseek/growi
+      tag-temporary: latest
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
+
+
+  post-publish:
+    needs: [create-github-release, publish-image, publish-image-ghcr]
+    runs-on: ubuntu-latest
 
-    - name: Build and push
-      uses: docker/build-push-action@v3
+    steps:
+    - uses: actions/checkout@v3
       with:
-        context: .
-        file: ./packages/app/docker/Dockerfile
-        platforms: ${{ matrix.platform }}
-        push: true
-        builder: ${{ steps.buildx.outputs.name }}
-        cache-from: type=gha
-        cache-to: type=gha,mode=max
-        tags: ${{ steps.meta.outputs.tags }}
+        ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
 
     - name: Update Docker Hub Description
       uses: peter-evans/dockerhub-description@v3
@@ -193,7 +221,6 @@ jobs:
         channel: '#release'
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
         created_tag: 'v${{ needs.create-github-release.outputs.RELEASED_VERSION }}'
-        message: '*Release v${{ needs.create-github-release.outputs.RELEASED_VERSION }} (${{ matrix.platform }})* Succeeded'
 
     - name: Check whether workspace is clean
       run: |

+ 56 - 0
.github/workflows/reusable-app-build-image.yml

@@ -0,0 +1,56 @@
+name: Reusable build app container image workflow
+
+on:
+  workflow_call:
+    inputs:
+      source-version:
+        type: string
+        default: ${{ github.sha }}
+      image-name:
+        type: string
+        default: weseek/growi
+      tag-temporary:
+        type: string
+        default: latest
+    secrets:
+      AWS_ROLE_TO_ASSUME_FOR_OIDC:
+        required: true
+
+
+
+jobs:
+
+  build-image:
+    runs-on: ubuntu-latest
+
+    # These permissions are needed to interact with GitHub's OIDC Token endpoint.
+    permissions:
+      id-token: write
+      contents: write
+
+    strategy:
+      matrix:
+        platform: [amd64, arm64]
+
+    steps:
+    - uses: actions/checkout@v3
+
+    - name: Configure AWS Credentials
+      uses: aws-actions/configure-aws-credentials@v1
+      with:
+        aws-region: ap-northeast-1
+        role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
+        role-session-name: SessionForReleaseGROWI-RC
+
+    - name: Run CodeBuild
+      uses: dark-mechanicum/aws-codebuild@v1
+      with:
+        projectName: growi-official-image-builder
+      env:
+        CODEBUILD__sourceVersion: ${{ inputs.source-version }}
+        CODEBUILD__imageOverride: ${{ (matrix.platform == 'amd64' && 'aws/codebuild/standard:6.0') || 'aws/codebuild/amazonlinux2-aarch64-standard:2.0' }}
+        CODEBUILD__environmentTypeOverride: ${{ (matrix.platform == 'amd64' && 'LINUX_CONTAINER') || 'ARM_CONTAINER' }}
+        CODEBUILD__environmentVariablesOverride: '[
+          { "name": "IMAGE_TAG", "type": "PLAINTEXT", "value": "docker.io/${{ inputs.image-name }}:${{ inputs.tag-temporary }}-${{ matrix.platform }}" },
+          { "name": "IMAGE_TAG_GHCR", "type": "PLAINTEXT", "value": "ghcr.io/${{ inputs.image-name }}:${{ inputs.tag-temporary }}-${{ matrix.platform }}" }
+        ]'

+ 52 - 0
.github/workflows/reusable-app-create-manifests.yml

@@ -0,0 +1,52 @@
+name: Reusable create app container image manifests workflow
+
+on:
+  workflow_call:
+    inputs:
+      tags:
+        type: string
+        required: true
+      registry:
+        type: string
+        default: 'docker.io'
+      image-name:
+        type: string
+        default: weseek/growi
+      tag-temporary:
+        type: string
+        default: latest
+    secrets:
+      DOCKER_REGISTRY_PASSWORD:
+        required: true
+
+
+
+jobs:
+
+  create-manifest:
+    runs-on: ubuntu-latest
+
+    steps:
+    - name: Docker meta for extra-images
+      id: meta-extra-images
+      uses: docker/metadata-action@v4
+      with:
+        images: ${{ inputs.registry }}/${{ inputs.image-name }}
+        sep-tags: ','
+        tags: |
+          type=raw,value=${{ inputs.tag-temporary }}-amd64
+          type=raw,value=${{ inputs.tag-temporary }}-arm64
+
+    - name: Login to Container Registry
+      uses: docker/login-action@v2
+      with:
+        registry: ${{ inputs.registry }}
+        username: wsmoogle
+        password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+
+    - name: Create and push manifest images
+      uses: Noelware/docker-manifest-action@master
+      with:
+        base-image: ${{ inputs.tags }}
+        extra-images: ${{ steps.meta-extra-images.outputs.tags }}
+        push: true

+ 5 - 0
.gitignore

@@ -29,6 +29,11 @@ yarn-error.log*
 # typescript
 *.tsbuildinfo
 
+# Terraform
+**/.terraform/*
+# *.tfstate
+*.tfstate.*
+
 # IDE, dev #
 .idea
 *.orig

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

@@ -7,7 +7,6 @@ MIGRATIONS_DIR=src/migrations/
 APP_SITE_URL=http://localhost:3000
 FILE_UPLOAD=mongodb
 # MONGO_GRIDFS_TOTAL_LIMIT=10485760
-# NO_CDN=true
 MONGO_URI="mongodb://mongo:27017/growi"
 # REDIS_URI="http://redis:6379"
 # NCHAN_URI="http://nchan"

+ 1 - 0
packages/app/config/logger/config.dev.js

@@ -27,6 +27,7 @@ module.exports = {
   'growi-plugin:*': 'debug',
   // 'growi:InterceptorManager': 'debug',
   'growi:service:search-delegator:elasticsearch': 'debug',
+  'growi:service:g2g-transfer': 'debug',
 
   /*
    * configure level for client

+ 5 - 5
packages/app/docker/Dockerfile

@@ -95,16 +95,16 @@ WORKDIR ${optDir}
 # ignore eslint and stylelint
 COPY ["package.json", "lerna.json", "tsconfig.base.json", "./"]
 # copy all related packages
-COPY packages/app packages/app
-COPY packages/core packages/core
 COPY packages/codemirror-textlint packages/codemirror-textlint
+COPY packages/preset-themes packages/preset-themes
 COPY packages/slack packages/slack
-COPY packages/ui packages/ui
+COPY packages/hackmd packages/hackmd
 COPY packages/remark-drawio packages/remark-drawio
 COPY packages/remark-growi-directive packages/remark-growi-directive
 COPY packages/remark-lsx packages/remark-lsx
-COPY packages/hackmd packages/hackmd
-COPY packages/preset-themes packages/preset-themes
+COPY packages/ui packages/ui
+COPY packages/core packages/core
+COPY packages/app packages/app
 
 # build
 RUN yarn lerna run build

+ 65 - 0
packages/app/docker/codebuild/.terraform.lock.hcl

@@ -0,0 +1,65 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/hashicorp/aws" {
+  version     = "4.49.0"
+  constraints = "~> 4.16"
+  hashes = [
+    "h1:oOwWQpvQWd1uVP1axBz/TO6xzzLWoL982AY/MQfeF7I=",
+    "zh:09803937f00fdf2873eccf685eec7854408925cbf183c9b683df7c5825be463f",
+    "zh:2af1575e538fb0b669266f8d1385b17bfdaf17c521b6b6329baa1f2971fc4a4d",
+    "zh:3f71882b438cde3108fe68cfe2637839d3eed08157a9721bd97babf3912247a8",
+    "zh:577af1b38f5da8a9f29eebe5eebec9279d26e757cd03b0c8c59311f9ce8a859b",
+    "zh:60160d39094973beefb9b10cfd6aaa5b63a2e68c32445ecffcd1b101356e6f9b",
+    "zh:762656454722548baeccf35cbaa23b887976337e1ed321682df7390419fdf22d",
+    "zh:7f6d7887821659bf3bef815949077dc91ffcdb0d911644a887b6683b264a5ca6",
+    "zh:8f16a352cc903f8951fa4619c36233b3e66e27d724817b131f2035dd8896f524",
+    "zh:8f768f65e370366c8b91c00d01c9a6264fe26ea9ae1819f14bdcd12c066272bc",
+    "zh:95ad78c689a83c08ef7c3e544c3c9aca93ed528054aa77cc968ddd9efa3a1023",
+    "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
+    "zh:a47097ab6a4ca8302da82964303ffdd2310ed65e8f8524bfe4058816cf1addb7",
+    "zh:b66d820c70cd5fd628ffe882d2b97e76b969dca4e6827ac2ba0f8d3bc5d6e9c6",
+    "zh:b80f713a4f3e1355c3dd1600e9d08b9f15ed2370054ec792ad2c01f2541abe02",
+    "zh:ce065bc3962cb71fa7652562226b9d486f3d7fcb88285c1020ebe2f550e28641",
+  ]
+}
+
+provider "registry.terraform.io/hashicorp/random" {
+  version     = "3.4.3"
+  constraints = ">= 2.1.0"
+  hashes = [
+    "h1:xZGZf18JjMS06pFa4NErzANI98qi59SEcBsOcS2P2yQ=",
+    "zh:41c53ba47085d8261590990f8633c8906696fa0a3c4b384ff6a7ecbf84339752",
+    "zh:59d98081c4475f2ad77d881c4412c5129c56214892f490adf11c7e7a5a47de9b",
+    "zh:686ad1ee40b812b9e016317e7f34c0d63ef837e084dea4a1f578f64a6314ad53",
+    "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+    "zh:84103eae7251384c0d995f5a257c72b0096605048f757b749b7b62107a5dccb3",
+    "zh:8ee974b110adb78c7cd18aae82b2729e5124d8f115d484215fd5199451053de5",
+    "zh:9dd4561e3c847e45de603f17fa0c01ae14cae8c4b7b4e6423c9ef3904b308dda",
+    "zh:bb07bb3c2c0296beba0beec629ebc6474c70732387477a65966483b5efabdbc6",
+    "zh:e891339e96c9e5a888727b45b2e1bb3fcbdfe0fd7c5b4396e4695459b38c8cb1",
+    "zh:ea4739860c24dfeaac6c100b2a2e357106a89d18751f7693f3c31ecf6a996f8d",
+    "zh:f0c76ac303fd0ab59146c39bc121c5d7d86f878e9a69294e29444d4c653786f8",
+    "zh:f143a9a5af42b38fed328a161279906759ff39ac428ebcfe55606e05e1518b93",
+  ]
+}
+
+provider "registry.terraform.io/hashicorp/tls" {
+  version     = "4.0.4"
+  constraints = ">= 3.0.0"
+  hashes = [
+    "h1:pe9vq86dZZKCm+8k1RhzARwENslF3SXb9ErHbQfgjXU=",
+    "zh:23671ed83e1fcf79745534841e10291bbf34046b27d6e68a5d0aab77206f4a55",
+    "zh:45292421211ffd9e8e3eb3655677700e3c5047f71d8f7650d2ce30242335f848",
+    "zh:59fedb519f4433c0fdb1d58b27c210b27415fddd0cd73c5312530b4309c088be",
+    "zh:5a8eec2409a9ff7cd0758a9d818c74bcba92a240e6c5e54b99df68fff312bbd5",
+    "zh:5e6a4b39f3171f53292ab88058a59e64825f2b842760a4869e64dc1dc093d1fe",
+    "zh:810547d0bf9311d21c81cc306126d3547e7bd3f194fc295836acf164b9f8424e",
+    "zh:824a5f3617624243bed0259d7dd37d76017097dc3193dac669be342b90b2ab48",
+    "zh:9361ccc7048be5dcbc2fafe2d8216939765b3160bd52734f7a9fd917a39ecbd8",
+    "zh:aa02ea625aaf672e649296bce7580f62d724268189fe9ad7c1b36bb0fa12fa60",
+    "zh:c71b4cd40d6ec7815dfeefd57d88bc592c0c42f5e5858dcc88245d371b4b8b1e",
+    "zh:dabcd52f36b43d250a3d71ad7abfa07b5622c69068d989e60b79b2bb4f220316",
+    "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
+  ]
+}

+ 32 - 0
packages/app/docker/codebuild/buildspec.yml

@@ -0,0 +1,32 @@
+version: 0.2
+
+env:
+  variables:
+    DOCKER_BUILDKIT: 1
+    IMAGE_TAG: ''
+    IMAGE_TAG_GHCR: ''
+  secrets-manager:
+    DOCKER_REGISTRY_PASSWORD: growi/official-image-builder:DOCKER_REGISTRY_PASSWORD
+    DOCKER_REGISTRY_ON_GITHUB_PAT: growi/official-image-builder:DOCKER_REGISTRY_ON_GITHUB_PAT
+
+phases:
+  pre_build:
+    commands:
+      # login to docker.io
+      - echo ${DOCKER_REGISTRY_PASSWORD} | docker login --username wsmoogle --password-stdin
+      # login to ghcr.io
+      - echo ${DOCKER_REGISTRY_ON_GITHUB_PAT} | docker login ghcr.io --username wsmoogle --password-stdin
+  build:
+    commands:
+      - docker build -t ${IMAGE_TAG} -f ./packages/app/docker/Dockerfile .
+      - docker tag ${IMAGE_TAG} ${IMAGE_TAG_GHCR}
+
+  post_build:
+    commands:
+      - docker push $IMAGE_TAG
+      - docker push $IMAGE_TAG_GHCR
+
+cache:
+  paths:
+    - node_modules/**/*
+    - packages/*/node_modules/**/*

+ 25 - 0
packages/app/docker/codebuild/codebuild.tf

@@ -0,0 +1,25 @@
+module "codebuild" {
+  source = "cloudposse/codebuild/aws"
+
+  name                = "growi-official-image-builder"
+  description         = "The CodeBuild Project for GROWI official docker image"
+
+  artifact_type       = "NO_ARTIFACTS"
+
+  source_type         = "GITHUB"
+  source_location     = "https://github.com/weseek/growi.git"
+  source_version      = "refs/heads/master"
+  git_clone_depth     = 1
+
+  buildspec           = "packages/app/docker/codebuild/buildspec.yml"
+
+  # https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-available.html
+  build_image         = "aws/codebuild/amazonlinux2-x86_64-standard:3.0"
+  build_compute_type  = "BUILD_GENERAL1_LARGE"
+
+  privileged_mode     = true
+
+  cache_type          = "LOCAL"
+  local_cache_modes   = ["LOCAL_DOCKER_LAYER_CACHE", "LOCAL_CUSTOM_CACHE"]
+
+}

+ 23 - 0
packages/app/docker/codebuild/main.tf

@@ -0,0 +1,23 @@
+terraform {
+  backend "remote" {
+    organization = "weseek"
+
+    workspaces {
+      name = "growi-official-image-builder"
+    }
+  }
+
+  required_providers {
+    aws = {
+      source  = "hashicorp/aws"
+      version = "~> 4.16"
+    }
+  }
+
+  required_version = ">= 1.2.0"
+}
+
+provider "aws" {
+  profile = "weseek"
+  region  = "ap-northeast-1"
+}

+ 26 - 0
packages/app/docker/codebuild/oidc.tf

@@ -0,0 +1,26 @@
+module "oidc_github" {
+  source  = "unfunco/oidc-github/aws"
+
+  iam_role_name = "GitHubOIDC-for-growi"
+  iam_role_inline_policies = {
+    "inline_policy" : data.aws_iam_policy_document.policy_document.json
+  }
+
+  github_repositories = [
+    "weseek/growi",
+  ]
+}
+
+data "aws_iam_policy_document" "policy_document" {
+  statement {
+    actions   = [
+      "codebuild:StartBuild",
+      "codebuild:StopBuild",
+      "codebuild:RetryBuild",
+      "codebuild:BatchGetBuilds"
+    ]
+    resources = [
+      module.codebuild.project_arn
+    ]
+  }
+}

+ 15 - 0
packages/app/docker/codebuild/secretsmanager.tf

@@ -0,0 +1,15 @@
+resource "aws_secretsmanager_secret" "secret" {
+  name = "growi/official-image-builder"
+}
+
+resource "aws_secretsmanager_secret_version" "main" {
+  secret_id     = aws_secretsmanager_secret.secret.id
+  secret_string = jsonencode({
+    KEY1 = "CHANGE THIS"
+    KEY2 = "CHANGE THIS"
+  })
+
+  lifecycle {
+    ignore_changes = [secret_string, version_stages]
+  }
+}

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

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

+ 3 - 1
packages/app/package.json

@@ -46,7 +46,7 @@
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "resources:hackmd": "yarn lerna run build --scope=@growi/hackmd",
-    "resources:preset-themes": "yarn lerna run build --scope=@growi/preset-themes",
+    "resources:preset-themes": "yarn lerna run dev:nowatch --scope=@growi/preset-themes",
     "// resources:dl-resources": "yarn ts-node bin/download-cdn-resources.ts",
     "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config"
   },
@@ -96,6 +96,7 @@
     "cookie-parser": "^1.4.5",
     "csurf": "^1.11.0",
     "csv-to-markdown-table": "^1.1.0",
+    "dayjs": "^1.11.7",
     "date-fns": "^2.23.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
@@ -111,6 +112,7 @@
     "express-session": "^1.16.1",
     "express-validator": "^6.14.0",
     "extensible-custom-error": "^0.0.7",
+    "form-data": "^4.0.0",
     "graceful-fs": "^4.1.11",
     "hast-util-select": "^5.0.2",
     "helmet": "^4.6.0",

+ 12 - 1
packages/app/public/static/locales/en_US/admin.json

@@ -526,7 +526,7 @@
       "upload": "Upload",
       "discard": "Discard uploaded data",
       "errors": {
-        "different_versions": "This growi and the uploaded data versions are not met",
+        "different_versions": "The version of this GROWI and the uploaded data are not the same",
         "at_least_one": "Select one or more collections.",
         "page_and_revision": "'Pages' and 'Revisions' must be imported both.",
         "depends": "'{{target}}' must be selected when '{{condition}}' is selected."
@@ -848,6 +848,12 @@
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
   },
+  "g2g_data_transfer": {
+    "transfer_data_to_another_growi": "Transfer data from this GROWI to another GROWI",
+    "advanced_options": "Advanced options",
+    "start_transfer": "Start transfer",
+    "paste_transfer_key": "Paste transter key here"
+  },
   "plugins": {
     "plugins": "Plugins",
     "plugin_installer": "Plugin Installer",
@@ -1024,6 +1030,11 @@
     "ADMIN_SEARCH_INDICES_NORMALIZE": "Normalize of Elasticsearch indexes",
     "ADMIN_SEARCH_INDICES_REBUILD": "Rebuild Elasticsearch indexes"
   },
+  "g2g": {
+    "transfer_success": "Completed GROWI to GROWI transfer successfully",
+    "error_generate_growi_archive": "Failed to generate GROWI archive file",
+    "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
+  },
   "toaster": {
     "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin",

+ 10 - 0
packages/app/public/static/locales/en_US/commons.json

@@ -96,5 +96,15 @@
 
   "not_found_page": {
     "page_not_exist": "This page does not exist."
+  },
+
+  "g2g_data_transfer": {
+    "tab": "Data transfer",
+    "data_transfer": "GROWI To GROWI Data Transfer",
+    "transfer_data_to_this_growi": "Transfer data from another GROWI to this GROWI",
+    "publish_transfer_key": "Publish transfer key",
+    "transfer_key_limit": "Transfer keys are valid for 1 hour after issuance.",
+    "once_transfer_key_used": "Once the transfer key is used for transfer, it cannot be used for any other transfer.",
+    "transfer_to_growi_cloud": "If you wish to transfer to GROWI.cloud, please click here."
   }
 }

+ 1 - 0
packages/app/public/static/locales/en_US/translation.json

@@ -165,6 +165,7 @@
     "no_page_list": "There are no pages under this page."
   },
   "installer": {
+    "tab": "Create account",
     "title": "Installer",
     "setup": "Setup",
     "create_initial_account": "Create an initial account",

+ 11 - 0
packages/app/public/static/locales/ja_JP/admin.json

@@ -856,6 +856,12 @@
       "log_type": "https://docs.growi.org/ja/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
   },
+  "g2g_data_transfer": {
+    "transfer_data_to_another_growi": "このGROWIのデータを別GROWIへ移行する",
+    "advanced_options": "詳細オプション",
+    "start_transfer": "移行を開始する",
+    "paste_transfer_key": "移行キーをここにペースト"
+  },
   "plugins": {
     "plugins": "プラグイン",
     "plugin_installer": "プラグインインストーラー",
@@ -1032,6 +1038,11 @@
     "ADMIN_SEARCH_INDICES_NORMALIZE": "Elasticsearch のインデックスの正規化",
     "ADMIN_SEARCH_INDICES_REBUILD": "Elasticsearch のインデックスのリビルド"
   },
+  "g2g": {
+    "transfer_success": "G2G移行が完了しました",
+    "error_generate_growi_archive": "GROWI アーカイブファイルの作成に失敗しました",
+    "error_send_growi_archive": "GROWI アーカイブファイルの送信に失敗しました"
+  },
   "toaster": {
     "give_user_admin": "{{username}}を管理者に設定しました",
     "remove_user_admin": "{{username}}を管理者から外しました",

+ 10 - 0
packages/app/public/static/locales/ja_JP/commons.json

@@ -96,5 +96,15 @@
 
   "not_found_page": {
     "page_not_exist": "このページは存在しません。"
+  },
+
+  "g2g_data_transfer": {
+    "tab": "データ移行",
+    "data_transfer": "別GROWIとのデータ移行",
+    "transfer_data_to_this_growi": "別GROWIのデータをこのGROWIへ移行する",
+    "publish_transfer_key": "移行キーを発行する",
+    "transfer_key_limit": "※ 移行キーの有効期限は発行から1時間となります。",
+    "once_transfer_key_used": "※ 移行キーは一度移行に利用するとそれ移行はご利用いただけなくなります。",
+    "transfer_to_growi_cloud": "※ GROWI.cloud への移行を実施する場合はこちらをご確認ください。"
   }
 }

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

@@ -167,6 +167,7 @@
     "no_page_list": "このページの配下にはページが存在しません。"
   },
   "installer": {
+    "tab": "アカウント作成",
     "title": "インストーラー",
     "setup": "セットアップ",
     "create_initial_account": "最初のアカウントの作成",

+ 12 - 1
packages/app/public/static/locales/zh_CN/admin.json

@@ -534,7 +534,7 @@
       "upload": "Upload",
       "discard": "Discard uploaded data",
       "errors": {
-        "versions_not_met": "this growi and the uploaded data versions are not met",
+        "different_versions": "The version of this GROWI and the uploaded data are not the same",
         "at_least_one": "Select one or more collections.",
         "page_and_revision": "'Pages' and 'Revisions' must be imported both.",
         "depends": "'{{target}}' must be selected when '{{condition}}' is selected."
@@ -856,6 +856,12 @@
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
   },
+  "g2g_data_transfer": {
+    "transfer_data_to_another_growi": "将数据从这个GROWI迁移到另一个GROWI上",
+    "advanced_options": "高级选项",
+    "start_transfer": "开始迁移",
+    "paste_transfer_key": "在这里粘贴过渡键"
+  },
   "plugins": {
     "plugins": "Plugins",
     "plugin_installer": "Plugin Installer",
@@ -1032,6 +1038,11 @@
     "ADMIN_SEARCH_INDICES_NORMALIZE": "试图重新连接Elasticsearch",
     "ADMIN_SEARCH_INDICES_REBUILD": "重建 Elasticsearch 索引"
   },
+  "g2g": {
+    "transfer_success": "Completed GROWI to GROWI transfer successfully",
+    "error_generate_growi_archive": "Failed to generate GROWI archive file",
+    "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
+  },
   "toaster": {
     "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin ",

+ 10 - 0
packages/app/public/static/locales/zh_CN/commons.json

@@ -96,5 +96,15 @@
 
   "not_found_page": {
     "page_not_exist": "该页面不存在"
+  },
+
+  "g2g_data_transfer": {
+    "tab": "数据迁移",
+    "data_transfer": "与另一个GROWI的数据转移",
+    "transfer_data_to_this_growi": "将数据从另一个GROWI迁移到这个GROWI上",
+    "publish_transfer_key": "发布迁移密钥",
+    "transfer_key_limit": "迁移密钥在签发后一小时内有效。",
+    "once_transfer_key_used": "一旦迁移密钥被用于迁移,它将不再可用于进一步的迁移。",
+    "transfer_to_growi_cloud": "如果您希望迁移到GROWI.cloud,请点击这里。"
   }
 }

+ 2 - 0
packages/app/public/static/locales/zh_CN/translation.json

@@ -159,6 +159,7 @@
   "not_allowed_to_see_this_page": "你不能看到这个页面",
   "Confirm": "确定",
   "Successfully requested": "进程成功接受",
+  "copied_to_clipboard": "它已复制到剪贴板。",
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
@@ -172,6 +173,7 @@
     "no_page_list": "There are no pages under this page."
   },
 	"installer": {
+    "tab": "创建账户",
     "title": "安装",
 		"setup": "安装",
 		"create_initial_account": "创建初始用户",

+ 15 - 0
packages/app/src/client/services/g2g-transfer.ts

@@ -0,0 +1,15 @@
+import { useCallback, useState } from 'react';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+export const useGenerateTransferKey = (): {transferKey: string, generateTransferKey: () => Promise<void>} => {
+  const [transferKey, setTransferKey] = useState('');
+
+  const generateTransferKey = useCallback(async() => {
+    const response = await apiv3Post('/g2g-transfer/generate-key', { appSiteUrl: window.location.origin });
+    const { transferKey } = response.data;
+    setTransferKey(transferKey);
+  }, []);
+
+  return { transferKey, generateTransferKey };
+};

+ 31 - 38
packages/app/src/components/Admin/App/AwsSetting.jsx → packages/app/src/components/Admin/App/AwsSetting.tsx

@@ -1,20 +1,25 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
 
-import AdminAppContainer from '~/client/services/AdminAppContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
+export type AwsSettingMoleculeProps = {
+  s3ReferenceFileWithRelayMode
+  s3Region
+  s3CustomEndpoint
+  s3Bucket
+  s3AccessKeyId
+  s3SecretAccessKey
+  onChangeS3ReferenceFileWithRelayMode: (val: boolean) => void
+  onChangeS3Region: (val: string) => void
+  onChangeS3CustomEndpoint: (val: string) => void
+  onChangeS3Bucket: (val: string) => void
+  onChangeS3AccessKeyId: (val: string) => void
+  onChangeS3SecretAccessKey: (val: string) => void
+};
 
-function AwsSetting(props) {
+export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element => {
   const { t } = useTranslation();
-  const { adminAppContainer } = props;
-  const { s3ReferenceFileWithRelayMode } = adminAppContainer.state;
 
   return (
-    <React.Fragment>
+    <>
 
       <div className="row form-group my-3">
         <label className="text-left text-md-right col-md-3 col-form-label">
@@ -31,21 +36,21 @@ function AwsSetting(props) {
               aria-haspopup="true"
               aria-expanded="true"
             >
-              {s3ReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_relay')}
-              {!s3ReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_redirect')}
+              {props.s3ReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_relay')}
+              {!props.s3ReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_redirect')}
             </button>
             <div className="dropdown-menu" aria-labelledby="ddS3ReferenceFileWithRelayMode">
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { adminAppContainer.changeS3ReferenceFileWithRelayMode(true) }}
+                onClick={() => { props?.onChangeS3ReferenceFileWithRelayMode(true) }}
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { adminAppContainer.changeS3ReferenceFileWithRelayMode(false) }}
+                onClick={() => { props?.onChangeS3ReferenceFileWithRelayMode(false) }}
               >
                 { t('admin:app_setting.file_delivery_method_redirect')}
               </button>
@@ -68,9 +73,9 @@ function AwsSetting(props) {
           <input
             className="form-control"
             placeholder={`${t('eg')} ap-northeast-1`}
-            defaultValue={adminAppContainer.state.s3Region || ''}
+            defaultValue={props.s3Region || ''}
             onChange={(e) => {
-              adminAppContainer.changeS3Region(e.target.value);
+              props?.onChangeS3Region(e.target.value);
             }}
           />
         </div>
@@ -85,9 +90,9 @@ function AwsSetting(props) {
             className="form-control"
             type="text"
             placeholder={`${t('eg')} http://localhost:9000`}
-            defaultValue={adminAppContainer.state.s3CustomEndpoint || ''}
+            defaultValue={props.s3CustomEndpoint || ''}
             onChange={(e) => {
-              adminAppContainer.changeS3CustomEndpoint(e.target.value);
+              props?.onChangeS3CustomEndpoint(e.target.value);
             }}
           />
           <p className="form-text text-muted">{t('admin:app_setting.custom_endpoint_change')}</p>
@@ -103,9 +108,9 @@ function AwsSetting(props) {
             className="form-control"
             type="text"
             placeholder={`${t('eg')} crowi`}
-            defaultValue={adminAppContainer.state.s3Bucket || ''}
+            defaultValue={props.s3Bucket || ''}
             onChange={(e) => {
-              adminAppContainer.changeS3Bucket(e.target.value);
+              props.onChangeS3Bucket(e.target.value);
             }}
           />
         </div>
@@ -119,9 +124,9 @@ function AwsSetting(props) {
           <input
             className="form-control"
             type="text"
-            defaultValue={adminAppContainer.state.s3AccessKeyId || ''}
+            defaultValue={props.s3AccessKeyId || ''}
             onChange={(e) => {
-              adminAppContainer.changeS3AccessKeyId(e.target.value);
+              props?.onChangeS3AccessKeyId(e.target.value);
             }}
           />
         </div>
@@ -135,27 +140,15 @@ function AwsSetting(props) {
           <input
             className="form-control"
             type="text"
-            defaultValue={adminAppContainer.state.s3SecretAccessKey || ''}
+            defaultValue={props.s3SecretAccessKey || ''}
             onChange={(e) => {
-              adminAppContainer.changeS3SecretAccessKey(e.target.value);
+              props?.onChangeS3SecretAccessKey(e.target.value);
             }}
           />
         </div>
       </div>
 
 
-    </React.Fragment>
+    </>
   );
-}
-
-
-/**
- * Wrapper component for using unstated
- */
-const AwsSettingWrapper = withUnstatedContainers(AwsSetting, [AdminAppContainer]);
-
-AwsSetting.propTypes = {
-  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
-
-export default AwsSettingWrapper;

+ 158 - 29
packages/app/src/components/Admin/App/FileUploadSetting.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { ChangeEvent, useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
@@ -8,30 +8,22 @@ import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
-import AwsSetting from './AwsSetting';
-import GcsSettings from './GcsSettings';
+import { AwsSettingMolecule } from './AwsSetting';
+import type { AwsSettingMoleculeProps } from './AwsSetting';
+import { GcsSettingMolecule } from './GcsSetting';
+import type { GcsSettingMoleculeProps } from './GcsSetting';
 
+const fileUploadTypes = ['aws', 'gcs', 'gridfs', 'local'] as const;
 
-type Props = {
-  adminAppContainer: AdminAppContainer,
-}
-
+type FileUploadSettingMoleculeProps = {
+  fileUploadType: string
+  isFixedFileUploadByEnvVar: boolean
+  envFileUploadType?: string
+  onChangeFileUploadType: (e: ChangeEvent, type: string) => void
+} & AwsSettingMoleculeProps & GcsSettingMoleculeProps;
 
-const FileUploadSetting = (props: Props) => {
+export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMoleculeProps): JSX.Element => {
   const { t } = useTranslation(['admin', 'commons']);
-  const { adminAppContainer } = props;
-  const { fileUploadType } = adminAppContainer.state;
-  const fileUploadTypes = ['aws', 'gcs', 'gridfs', 'local'];
-
-  const submitHandler = useCallback(async() => {
-    try {
-      await adminAppContainer.updateFileUploadSettingHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.file_upload_settings'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [adminAppContainer, t]);
 
   return (
     <>
@@ -59,29 +51,166 @@ const FileUploadSetting = (props: Props) => {
                   className="custom-control-input"
                   name="file-upload-type"
                   id={`file-upload-type-radio-${type}`}
-                  checked={adminAppContainer.state.fileUploadType === type}
-                  disabled={adminAppContainer.state.isFixedFileUploadByEnvVar}
-                  onChange={() => { adminAppContainer.changeFileUploadType(type) }}
+                  checked={props.fileUploadType === type}
+                  disabled={props.isFixedFileUploadByEnvVar}
+                  onChange={(e) => { props?.onChangeFileUploadType(e, type) }}
                 />
                 <label className="custom-control-label" htmlFor={`file-upload-type-radio-${type}`}>{t(`admin:app_setting.${type}_label`)}</label>
               </div>
             );
           })}
         </div>
-        {adminAppContainer.state.isFixedFileUploadByEnvVar && (
+        {props.isFixedFileUploadByEnvVar && (
           <p className="alert alert-warning mt-2 text-left offset-3 col-6">
             <i className="icon-exclamation icon-fw">
             </i><b>FIXED</b><br />
             {/* eslint-disable-next-line react/no-danger */}
-            <b dangerouslySetInnerHTML={{ __html: t('admin:app_setting.fixed_by_env_var', { fileUploadType: adminAppContainer.state.envFileUploadType }) }} />
+            <b dangerouslySetInnerHTML={{ __html: t('admin:app_setting.fixed_by_env_var', { fileUploadType: props.envFileUploadType }) }} />
           </p>
         )}
       </div>
 
-      {fileUploadType === 'aws' && <AwsSetting />}
-      {fileUploadType === 'gcs' && <GcsSettings />}
+      {props.fileUploadType === 'aws' && <AwsSettingMolecule
+        s3ReferenceFileWithRelayMode={props.s3ReferenceFileWithRelayMode}
+        s3Region={props.s3Region}
+        s3CustomEndpoint={props.s3CustomEndpoint}
+        s3Bucket={props.s3Bucket}
+        s3AccessKeyId={props.s3AccessKeyId}
+        s3SecretAccessKey={props.s3SecretAccessKey}
+        onChangeS3ReferenceFileWithRelayMode={props.onChangeS3ReferenceFileWithRelayMode}
+        onChangeS3Region={props.onChangeS3Region}
+        onChangeS3CustomEndpoint={props.onChangeS3CustomEndpoint}
+        onChangeS3Bucket={props.onChangeS3Bucket}
+        onChangeS3AccessKeyId={props.onChangeS3AccessKeyId}
+        onChangeS3SecretAccessKey={props.onChangeS3SecretAccessKey}
+      />}
+      {props.fileUploadType === 'gcs' && <GcsSettingMolecule
+        gcsReferenceFileWithRelayMode={props.gcsReferenceFileWithRelayMode}
+        gcsUseOnlyEnvVars={props.gcsUseOnlyEnvVars}
+        gcsApiKeyJsonPath={props.gcsApiKeyJsonPath}
+        gcsBucket={props.gcsBucket}
+        gcsUploadNamespace={props.gcsUploadNamespace}
+        envGcsApiKeyJsonPath={props.envGcsApiKeyJsonPath}
+        envGcsBucket={props.envGcsBucket}
+        envGcsUploadNamespace={props.envGcsUploadNamespace}
+        onChangeGcsReferenceFileWithRelayMode={props.onChangeGcsReferenceFileWithRelayMode}
+        onChangeGcsApiKeyJsonPath={props.onChangeGcsApiKeyJsonPath}
+        onChangeGcsBucket={props.onChangeGcsBucket}
+        onChangeGcsUploadNamespace={props.onChangeGcsUploadNamespace}
+      />}
+    </>
+  );
+});
+FileUploadSettingMolecule.displayName = 'FileUploadSettingMolecule';
+
+
+type FileUploadSettingProps = {
+  adminAppContainer: AdminAppContainer
+}
 
-      <AdminUpdateButtonRow onClick={submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
+  const { t } = useTranslation(['admin', 'commons']);
+  const { adminAppContainer } = props;
+
+  const {
+    fileUploadType, isFixedFileUploadByEnvVar, envFileUploadType, retrieveError,
+    s3ReferenceFileWithRelayMode,
+    s3Region, s3CustomEndpoint, s3Bucket,
+    s3AccessKeyId, s3SecretAccessKey,
+    gcsReferenceFileWithRelayMode, gcsUseOnlyEnvVars,
+    gcsApiKeyJsonPath, envGcsApiKeyJsonPath, gcsBucket,
+    envGcsBucket, gcsUploadNamespace, envGcsUploadNamespace,
+  } = adminAppContainer.state;
+
+  const submitHandler = useCallback(async() => {
+    try {
+      await adminAppContainer.updateFileUploadSettingHandler();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.file_upload_settings'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminAppContainer, t]);
+
+  const onChangeFileUploadTypeHandler = useCallback((e: ChangeEvent, type: string) => {
+    adminAppContainer.changeFileUploadType(type);
+  }, [adminAppContainer]);
+
+  // S3
+  const onChangeS3ReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
+    adminAppContainer.changeS3ReferenceFileWithRelayMode(val);
+  }, [adminAppContainer]);
+
+  const onChangeS3RegionHandler = useCallback((val: string) => {
+    adminAppContainer.changeS3Region(val);
+  }, [adminAppContainer]);
+
+  const onChangeS3CustomEndpointHandler = useCallback((val: string) => {
+    adminAppContainer.changeS3CustomEndpoint(val);
+  }, [adminAppContainer]);
+
+  const onChangeS3BucketHandler = useCallback((val: string) => {
+    adminAppContainer.changeS3Bucket(val);
+  }, [adminAppContainer]);
+
+  const onChangeS3AccessKeyIdHandler = useCallback((val: string) => {
+    adminAppContainer.changeS3AccessKeyId(val);
+  }, [adminAppContainer]);
+
+  const onChangeS3SecretAccessKeyHandler = useCallback((val: string) => {
+    adminAppContainer.changeS3SecretAccessKey(val);
+  }, [adminAppContainer]);
+
+  // GCS
+  const onChangeGcsReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
+    adminAppContainer.changeGcsReferenceFileWithRelayMode(val);
+  }, [adminAppContainer]);
+
+  const onChangeGcsApiKeyJsonPathHandler = useCallback((val: string) => {
+    adminAppContainer.changeGcsApiKeyJsonPath(val);
+  }, [adminAppContainer]);
+
+  const onChangeGcsBucketHandler = useCallback((val: string) => {
+    adminAppContainer.changeGcsBucket(val);
+  }, [adminAppContainer]);
+
+  const onChangeGcsUploadNamespaceHandler = useCallback((val: string) => {
+    adminAppContainer.changeGcsUploadNamespace(val);
+  }, [adminAppContainer]);
+
+  return (
+    <>
+      <FileUploadSettingMolecule
+        fileUploadType={fileUploadType}
+        isFixedFileUploadByEnvVar={isFixedFileUploadByEnvVar}
+        envFileUploadType={envFileUploadType}
+        onChangeFileUploadType={onChangeFileUploadTypeHandler}
+        s3ReferenceFileWithRelayMode={s3ReferenceFileWithRelayMode}
+        s3Region={s3Region}
+        s3CustomEndpoint={s3CustomEndpoint}
+        s3Bucket={s3Bucket}
+        s3AccessKeyId={s3AccessKeyId}
+        s3SecretAccessKey={s3SecretAccessKey}
+        onChangeS3ReferenceFileWithRelayMode={onChangeS3ReferenceFileWithRelayModeHandler}
+        onChangeS3Region={onChangeS3RegionHandler}
+        onChangeS3CustomEndpoint={onChangeS3CustomEndpointHandler}
+        onChangeS3Bucket={onChangeS3BucketHandler}
+        onChangeS3AccessKeyId={onChangeS3AccessKeyIdHandler}
+        onChangeS3SecretAccessKey={onChangeS3SecretAccessKeyHandler}
+        gcsReferenceFileWithRelayMode={gcsReferenceFileWithRelayMode}
+        gcsUseOnlyEnvVars={gcsUseOnlyEnvVars}
+        gcsApiKeyJsonPath={gcsApiKeyJsonPath}
+        gcsBucket={gcsBucket}
+        gcsUploadNamespace={gcsUploadNamespace}
+        envGcsApiKeyJsonPath={envGcsApiKeyJsonPath}
+        envGcsBucket={envGcsBucket}
+        envGcsUploadNamespace={envGcsUploadNamespace}
+        onChangeGcsReferenceFileWithRelayMode={onChangeGcsReferenceFileWithRelayModeHandler}
+        onChangeGcsApiKeyJsonPath={onChangeGcsApiKeyJsonPathHandler}
+        onChangeGcsBucket={onChangeGcsBucketHandler}
+        onChangeGcsUploadNamespace={onChangeGcsUploadNamespaceHandler}
+      />
+      <AdminUpdateButtonRow onClick={submitHandler} disabled={retrieveError != null} />
     </>
   );
 };

+ 37 - 34
packages/app/src/components/Admin/App/GcsSettings.jsx → packages/app/src/components/Admin/App/GcsSetting.tsx

@@ -1,18 +1,33 @@
-
-import React from 'react';
-
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
 
-import AdminAppContainer from '~/client/services/AdminAppContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
+export type GcsSettingMoleculeProps = {
+  gcsReferenceFileWithRelayMode
+  gcsUseOnlyEnvVars
+  gcsApiKeyJsonPath
+  gcsBucket
+  gcsUploadNamespace
+  envGcsApiKeyJsonPath?
+  envGcsBucket?
+  envGcsUploadNamespace?
+  onChangeGcsReferenceFileWithRelayMode: (val: boolean) => void
+  onChangeGcsApiKeyJsonPath: (val: string) => void
+  onChangeGcsBucket: (val: string) => void
+  onChangeGcsUploadNamespace: (val: string) => void
+};
 
-const GcsSetting = (props) => {
+export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element => {
   const { t } = useTranslation();
-  const { adminAppContainer } = props;
-  const { gcsReferenceFileWithRelayMode, gcsUseOnlyEnvVars } = adminAppContainer.state;
+
+  const {
+    gcsReferenceFileWithRelayMode,
+    gcsUseOnlyEnvVars,
+    gcsApiKeyJsonPath,
+    envGcsApiKeyJsonPath,
+    gcsBucket,
+    envGcsBucket,
+    gcsUploadNamespace,
+    envGcsUploadNamespace,
+  } = props;
 
   return (
     <>
@@ -39,14 +54,14 @@ const GcsSetting = (props) => {
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { adminAppContainer.changeGcsReferenceFileWithRelayMode(true) }}
+                onClick={() => { props?.onChangeGcsReferenceFileWithRelayMode(true) }}
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { adminAppContainer.changeGcsReferenceFileWithRelayMode(false) }}
+                onClick={() => { props?.onChangeGcsReferenceFileWithRelayMode(false) }}
               >
                 { t('admin:app_setting.file_delivery_method_redirect')}
               </button>
@@ -90,12 +105,12 @@ const GcsSetting = (props) => {
                 type="text"
                 name="gcsApiKeyJsonPath"
                 readOnly={gcsUseOnlyEnvVars}
-                defaultValue={adminAppContainer.state.gcsApiKeyJsonPath}
-                onChange={e => adminAppContainer.changeGcsApiKeyJsonPath(e.target.value)}
+                defaultValue={gcsApiKeyJsonPath}
+                onChange={e => props?.onChangeGcsApiKeyJsonPath(e.target.value)}
               />
             </td>
             <td>
-              <input className="form-control" type="text" value={adminAppContainer.state.envGcsApiKeyJsonPath || ''} readOnly tabIndex="-1" />
+              <input className="form-control" type="text" value={envGcsApiKeyJsonPath || ''} readOnly tabIndex={-1} />
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
                 <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'GCS_API_KEY_JSON_PATH' }) }} />
@@ -110,12 +125,12 @@ const GcsSetting = (props) => {
                 type="text"
                 name="gcsBucket"
                 readOnly={gcsUseOnlyEnvVars}
-                defaultValue={adminAppContainer.state.gcsBucket}
-                onChange={e => adminAppContainer.changeGcsBucket(e.target.value)}
+                defaultValue={gcsBucket}
+                onChange={e => props?.onChangeGcsBucket(e.target.value)}
               />
             </td>
             <td>
-              <input className="form-control" type="text" value={adminAppContainer.state.envGcsBucket || ''} readOnly tabIndex="-1" />
+              <input className="form-control" type="text" value={envGcsBucket || ''} readOnly tabIndex={-1} />
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
                 <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'GCS_BUCKET' }) }} />
@@ -130,12 +145,12 @@ const GcsSetting = (props) => {
                 type="text"
                 name="gcsUploadNamespace"
                 readOnly={gcsUseOnlyEnvVars}
-                defaultValue={adminAppContainer.state.gcsUploadNamespace}
-                onChange={e => adminAppContainer.changeGcsUploadNamespace(e.target.value)}
+                defaultValue={gcsUploadNamespace}
+                onChange={e => props?.onChangeGcsUploadNamespace(e.target.value)}
               />
             </td>
             <td>
-              <input className="form-control" type="text" value={adminAppContainer.state.envGcsUploadNamespace || ''} readOnly tabIndex="-1" />
+              <input className="form-control" type="text" value={envGcsUploadNamespace || ''} readOnly tabIndex={-1} />
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
                 <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'GCS_UPLOAD_NAMESPACE' }) }} />
@@ -147,16 +162,4 @@ const GcsSetting = (props) => {
 
     </>
   );
-
 };
-
-/**
- * Wrapper component for using unstated
- */
-const GcsSettingWrapper = withUnstatedContainers(GcsSetting, [AdminAppContainer]);
-
-GcsSetting.propTypes = {
-  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
-};
-
-export default GcsSettingWrapper;

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

@@ -12,7 +12,7 @@ import { useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '../../../stores/co
 // import { withUnstatedContainers } from '../../UnstatedUtils';
 
 const AdminNavigation = (props) => {
-  const { t } = useTranslation('admin');
+  const { t } = useTranslation(['admin', 'commons']);
   // const { appContainer } = props;
   const pathname = window.location.pathname;
 
@@ -36,6 +36,7 @@ const AdminNavigation = (props) => {
       case 'user-groups':              return <><i className="mr-1 icon-fw icon-people"></i>{          t('user_group_management.user_group_management') }</>;
       case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
       case 'audit-log':                return <><i className="mr-1 icon-fw icon-feed"></i>{            t('audit_log_management.audit_log')}</>;
+      case 'data-transfer':            return <><i className="mr-1 icon-fw icon-arrow-right"></i>{     t('g2g_data_transfer.data_transfer', { ns: 'commons' })}</>;
       case 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{          t('plugins.plugins')}</>;
       case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
       default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_home_page') }</>;
@@ -93,6 +94,7 @@ const AdminNavigation = (props) => {
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
         <MenuLink menu="plugins"      isListGroupItems isActive={isActiveMenu('/plugins')} />
+        <MenuLink menu="data-transfer" isListGroupItems isActive={isActiveMenu('/data-transfer')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
         {growiCloudUri != null && growiAppIdForGrowiCloud != null
           && (
@@ -143,6 +145,7 @@ const AdminNavigation = (props) => {
             {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
             {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
             {isActiveMenu('/plugins') &&           <MenuLabel menu="plugins" />}
+            {isActiveMenu('/data-transfer') &&     <MenuLabel menu="data-transfer" />}
             {/* eslint-enable no-multi-spaces */}
           </span>
         </button>

+ 0 - 250
packages/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.jsx

@@ -1,250 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-import * as toastr from 'toastr';
-
-import { apiPost } from '~/client/util/apiv1-client';
-
-// import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-
-const GROUPS_PAGE = [
-  'pages', 'revisions', 'tags', 'pagetagrelations', 'pageredirects', 'comments', 'sharelinks',
-];
-const GROUPS_USER = [
-  'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
-  'useruisettings', 'editorsettings', 'bookmarks', 'subscriptions',
-  'inappnotificationsettings',
-];
-const GROUPS_CONFIG = [
-  'configs', 'updateposts', 'globalnotificationsettings', 'slackappintegrations',
-];
-const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
-
-class SelectCollectionsModal extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      selectedCollections: new Set(),
-    };
-
-    this.toggleCheckbox = this.toggleCheckbox.bind(this);
-    this.checkAll = this.checkAll.bind(this);
-    this.uncheckAll = this.uncheckAll.bind(this);
-    this.export = this.export.bind(this);
-    this.validateForm = this.validateForm.bind(this);
-  }
-
-  toggleCheckbox(e) {
-    const { target } = e;
-    const { name, checked } = target;
-
-    this.setState((prevState) => {
-      const selectedCollections = new Set(prevState.selectedCollections);
-      if (checked) {
-        selectedCollections.add(name);
-      }
-      else {
-        selectedCollections.delete(name);
-      }
-
-      return { selectedCollections };
-    });
-  }
-
-  checkAll() {
-    this.setState({ selectedCollections: new Set(this.props.collections) });
-  }
-
-  uncheckAll() {
-    this.setState({ selectedCollections: new Set() });
-  }
-
-  async export(e) {
-    e.preventDefault();
-
-    try {
-      // TODO: use apiv3Post
-      const result = await apiPost('/v3/export', { collections: Array.from(this.state.selectedCollections) });
-      // TODO: toastSuccess, toastError
-
-      if (!result.ok) {
-        throw new Error('Error occured.');
-      }
-
-      // TODO: toastSuccess, toastError
-      toastr.success(undefined, 'Export process has requested.', {
-        closeButton: true,
-        progressBar: true,
-        newestOnTop: false,
-        showDuration: '100',
-        hideDuration: '100',
-        timeOut: '1200',
-        extendedTimeOut: '150',
-      });
-
-      this.props.onExportingRequested();
-      this.props.onClose();
-
-      this.setState({ selectedCollections: new Set() });
-
-    }
-    catch (err) {
-      // TODO: toastSuccess, toastError
-      toastr.error(err, 'Error', {
-        closeButton: true,
-        progressBar: true,
-        newestOnTop: false,
-        showDuration: '100',
-        hideDuration: '100',
-        timeOut: '3000',
-      });
-    }
-  }
-
-  validateForm() {
-    return this.state.selectedCollections.size > 0;
-  }
-
-  renderWarnForUser() {
-    // whether this.state.selectedCollections includes one of GROUPS_USER
-    const isUserRelatedDataSelected = GROUPS_USER.some((collectionName) => {
-      return this.state.selectedCollections.has(collectionName);
-    });
-
-    if (!isUserRelatedDataSelected) {
-      return <></>;
-    }
-
-    const html = this.props.t('admin:export_management.desc_password_seed');
-
-    // eslint-disable-next-line react/no-danger
-    return <div className="card well" dangerouslySetInnerHTML={{ __html: html }}></div>;
-  }
-
-  renderGroups(groupList, color) {
-    const collectionNames = groupList.filter((collectionName) => {
-      return this.props.collections.includes(collectionName);
-    });
-
-    return this.renderCheckboxes(collectionNames, color);
-  }
-
-  renderOthers() {
-    const collectionNames = this.props.collections.filter((collectionName) => {
-      return !ALL_GROUPED_COLLECTIONS.includes(collectionName);
-    });
-
-    return this.renderCheckboxes(collectionNames);
-  }
-
-  renderCheckboxes(collectionNames, color) {
-    const checkboxColor = color ? `custom-checkbox-${color}` : 'custom-checkbox-info';
-
-    return (
-      <div className={`custom-control custom-checkbox ${checkboxColor}`}>
-        <div className="row">
-          {collectionNames.map((collectionName) => {
-            return (
-              <div className="col-sm-6 my-1" key={collectionName}>
-                <input
-                  type="checkbox"
-                  className="custom-control-input"
-                  id={collectionName}
-                  name={collectionName}
-                  value={collectionName}
-                  checked={this.state.selectedCollections.has(collectionName)}
-                  onChange={this.toggleCheckbox}
-                />
-                <label className="text-capitalize custom-control-label ml-3" htmlFor={collectionName}>
-                  {collectionName}
-                </label>
-              </div>
-            );
-          })}
-        </div>
-      </div>
-    );
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
-        <ModalHeader tag="h4" toggle={this.props.onClose} className="bg-info text-light">
-          {t('admin:export_management.export_collections')}
-        </ModalHeader>
-
-        <form onSubmit={this.export}>
-          <ModalBody>
-            <div className="row">
-              <div className="col-sm-12">
-                <button type="button" className="btn btn-sm btn-outline-secondary mr-2" onClick={this.checkAll}>
-                  <i className="fa fa-check-square-o"></i> {t('admin:export_management.check_all')}
-                </button>
-                <button type="button" className="btn btn-sm btn-outline-secondary mr-2" onClick={this.uncheckAll}>
-                  <i className="fa fa-square-o"></i> {t('admin:export_management.uncheck_all')}
-                </button>
-              </div>
-            </div>
-            <div className="row mt-4">
-              <div className="col-sm-12">
-                <h3 className="admin-setting-header">MongoDB Page Collections</h3>
-                {this.renderGroups(GROUPS_PAGE)}
-              </div>
-            </div>
-            <div className="row mt-4">
-              <div className="col-sm-12">
-                <h3 className="admin-setting-header">MongoDB User Collections</h3>
-                {this.renderGroups(GROUPS_USER, 'danger')}
-                {this.renderWarnForUser()}
-              </div>
-            </div>
-            <div className="row mt-4">
-              <div className="col-sm-12">
-                <h3 className="admin-setting-header">MongoDB Config Collections</h3>
-                {this.renderGroups(GROUPS_CONFIG)}
-              </div>
-            </div>
-            <div className="row mt-4">
-              <div className="col-sm-12">
-                <h3 className="admin-setting-header">MongoDB Other Collections</h3>
-                {this.renderOthers()}
-              </div>
-            </div>
-          </ModalBody>
-
-          <ModalFooter>
-            <button type="button" className="btn btn-sm btn-outline-secondary" onClick={this.props.onClose}>{t('admin:export_management.cancel')}</button>
-            <button type="submit" className="btn btn-sm btn-primary" disabled={!this.validateForm()}>{t('admin:export_management.export')}</button>
-          </ModalFooter>
-        </form>
-      </Modal>
-    );
-  }
-
-}
-
-SelectCollectionsModal.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  isOpen: PropTypes.bool.isRequired,
-  onExportingRequested: PropTypes.func.isRequired,
-  onClose: PropTypes.func.isRequired,
-  collections: PropTypes.arrayOf(PropTypes.string).isRequired,
-};
-
-const SelectCollectionsModalWrapperFc = (props) => {
-  const { t } = useTranslation();
-
-  return <SelectCollectionsModal t={t} {...props} />;
-};
-
-export default SelectCollectionsModalWrapperFc;

+ 232 - 0
packages/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx

@@ -0,0 +1,232 @@
+import React, { useCallback, useState, useEffect } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import * as toastr from 'toastr';
+
+import { apiPost } from '~/client/util/apiv1-client';
+
+// import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+
+const GROUPS_PAGE = [
+  'pages', 'revisions', 'tags', 'pagetagrelations', 'pageredirects', 'comments', 'sharelinks',
+];
+const GROUPS_USER = [
+  'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
+  'useruisettings', 'editorsettings', 'bookmarks', 'subscriptions',
+  'inappnotificationsettings',
+];
+const GROUPS_CONFIG = [
+  'configs', 'updateposts', 'globalnotificationsettings', 'slackappintegrations',
+];
+const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
+
+type Props = {
+  isOpen: boolean,
+  onExportingRequested: () => void,
+  onClose: () => void,
+  collections: string[],
+  isAllChecked?: boolean,
+};
+
+const SelectCollectionsModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    isOpen, onExportingRequested, onClose, collections, isAllChecked,
+  } = props;
+
+  const [selectedCollections, setSelectedCollections] = useState<Set<string>>(new Set());
+
+  const toggleCheckbox = useCallback((e) => {
+    const { target } = e;
+    const { name, checked } = target;
+
+    setSelectedCollections((prevState) => {
+      const selectedCollections = new Set(prevState);
+      if (checked) {
+        selectedCollections.add(name);
+      }
+      else {
+        selectedCollections.delete(name);
+      }
+
+      return selectedCollections;
+    });
+  }, []);
+
+  const checkAll = useCallback(() => {
+    setSelectedCollections(new Set(collections));
+  }, [collections]);
+
+  const uncheckAll = useCallback(() => {
+    setSelectedCollections(new Set());
+  }, []);
+
+  const doExport = useCallback(async(e) => {
+    e.preventDefault();
+
+    try {
+      // TODO: use apiv3Post
+      const result = await apiPost<any>('/v3/export', { collections: Array.from(selectedCollections) });
+      // TODO: toastSuccess, toastError
+
+      if (!result.ok) {
+        throw new Error('Error occured.');
+      }
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, 'Export process has requested.', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+
+      onExportingRequested();
+      onClose();
+      uncheckAll();
+    }
+    catch (err) {
+      // TODO: toastSuccess, toastError
+      toastr.error(err, 'Error', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    }
+  }, [onClose, onExportingRequested, selectedCollections, uncheckAll]);
+
+  const validateForm = useCallback(() => {
+    return selectedCollections.size > 0;
+  }, [selectedCollections.size]);
+
+  const renderWarnForUser = useCallback(() => {
+    // whether selectedCollections includes one of GROUPS_USER
+    const isUserRelatedDataSelected = GROUPS_USER.some((collectionName) => {
+      return selectedCollections.has(collectionName);
+    });
+
+    if (!isUserRelatedDataSelected) {
+      return <></>;
+    }
+
+    const html = t('admin:export_management.desc_password_seed');
+
+    // eslint-disable-next-line react/no-danger
+    return <div className="card well" dangerouslySetInnerHTML={{ __html: html }}></div>;
+  }, [selectedCollections, t]);
+
+  const renderCheckboxes = useCallback((collectionNames, color?) => {
+    const checkboxColor = color ? `custom-checkbox-${color}` : 'custom-checkbox-info';
+
+    return (
+      <div className={`custom-control custom-checkbox ${checkboxColor}`}>
+        <div className="row">
+          {collectionNames.map((collectionName) => {
+            return (
+              <div className="col-sm-6 my-1" key={collectionName}>
+                <input
+                  type="checkbox"
+                  className="custom-control-input"
+                  id={collectionName}
+                  name={collectionName}
+                  value={collectionName}
+                  checked={selectedCollections.has(collectionName)}
+                  onChange={toggleCheckbox}
+                />
+                <label className="text-capitalize custom-control-label ml-3" htmlFor={collectionName}>
+                  {collectionName}
+                </label>
+              </div>
+            );
+          })}
+        </div>
+      </div>
+    );
+  }, [selectedCollections, toggleCheckbox]);
+
+  const renderGroups = useCallback((groupList, color?) => {
+    const collectionNames = groupList.filter((collectionName) => {
+      return collections.includes(collectionName);
+    });
+
+    return renderCheckboxes(collectionNames, color);
+  }, [collections, renderCheckboxes]);
+
+  const renderOthers = useCallback(() => {
+    const collectionNames = collections.filter((collectionName) => {
+      return !ALL_GROUPED_COLLECTIONS.includes(collectionName);
+    });
+
+    return renderCheckboxes(collectionNames);
+  }, [collections, renderCheckboxes]);
+
+  useEffect(() => {
+    if (isAllChecked) checkAll();
+  }, [isAllChecked, checkAll]);
+
+  return (
+    <Modal isOpen={isOpen} toggle={onClose}>
+      <ModalHeader tag="h4" toggle={onClose} className="bg-info text-light">
+        {t('admin:export_management.export_collections')}
+      </ModalHeader>
+
+      <form onSubmit={doExport}>
+        <ModalBody>
+          <div className="row">
+            <div className="col-sm-12">
+              <button type="button" className="btn btn-sm btn-outline-secondary mr-2" onClick={checkAll}>
+                <i className="fa fa-check-square-o"></i> {t('admin:export_management.check_all')}
+              </button>
+              <button type="button" className="btn btn-sm btn-outline-secondary mr-2" onClick={uncheckAll}>
+                <i className="fa fa-square-o"></i> {t('admin:export_management.uncheck_all')}
+              </button>
+            </div>
+          </div>
+          <div className="row mt-4">
+            <div className="col-sm-12">
+              <h3 className="admin-setting-header">MongoDB Page Collections</h3>
+              {renderGroups(GROUPS_PAGE)}
+            </div>
+          </div>
+          <div className="row mt-4">
+            <div className="col-sm-12">
+              <h3 className="admin-setting-header">MongoDB User Collections</h3>
+              {renderGroups(GROUPS_USER, 'danger')}
+              {renderWarnForUser()}
+            </div>
+          </div>
+          <div className="row mt-4">
+            <div className="col-sm-12">
+              <h3 className="admin-setting-header">MongoDB Config Collections</h3>
+              {renderGroups(GROUPS_CONFIG)}
+            </div>
+          </div>
+          <div className="row mt-4">
+            <div className="col-sm-12">
+              <h3 className="admin-setting-header">MongoDB Other Collections</h3>
+              {renderOthers()}
+            </div>
+          </div>
+        </ModalBody>
+
+        <ModalFooter>
+          <button type="button" className="btn btn-sm btn-outline-secondary" onClick={onClose}>{t('admin:export_management.cancel')}</button>
+          <button type="submit" className="btn btn-sm btn-primary" disabled={!validateForm()}>{t('admin:export_management.export')}</button>
+        </ModalFooter>
+      </form>
+    </Modal>
+  );
+};
+
+export default SelectCollectionsModal;

+ 0 - 261
packages/app/src/components/Admin/ExportArchiveDataPage.jsx

@@ -1,261 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import * as toastr from 'toastr';
-
-
-// import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiDelete, apiGet } from '~/client/util/apiv1-client';
-import { useAdminSocket } from '~/stores/socket-io';
-
-import LabeledProgressBar from './Common/LabeledProgressBar';
-import ArchiveFilesTable from './ExportArchiveData/ArchiveFilesTable';
-import SelectCollectionsModal from './ExportArchiveData/SelectCollectionsModal';
-
-
-const IGNORED_COLLECTION_NAMES = [
-  'sessions', 'rlflx', 'activities',
-];
-
-class ExportArchiveDataPage extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      collections: [],
-      zipFileStats: [],
-      progressList: [],
-      isExportModalOpen: false,
-      isExporting: false,
-      isZipping: false,
-      isExported: false,
-    };
-
-    this.onZipFileStatAdd = this.onZipFileStatAdd.bind(this);
-    this.onZipFileStatRemove = this.onZipFileStatRemove.bind(this);
-    this.openExportModal = this.openExportModal.bind(this);
-    this.closeExportModal = this.closeExportModal.bind(this);
-    this.exportingRequestedHandler = this.exportingRequestedHandler.bind(this);
-  }
-
-  async UNSAFE_componentWillMount() {
-    // TODO:: use apiv3.get
-    // eslint-disable-next-line no-unused-vars
-    const [{ collections }, { status }] = await Promise.all([
-      apiGet('/v3/mongo/collections', {}),
-      apiGet('/v3/export/status', {}),
-    ]);
-    // TODO: toastSuccess, toastError
-
-    // filter only not ignored collection names
-    const filteredCollections = collections.filter((collectionName) => {
-      return !IGNORED_COLLECTION_NAMES.includes(collectionName);
-    });
-
-    const { zipFileStats, isExporting, progressList } = status;
-    this.setState({
-      collections: filteredCollections,
-      zipFileStats,
-      isExporting,
-      progressList,
-    });
-
-    this.setupWebsocketEventHandler();
-  }
-
-  setupWebsocketEventHandler() {
-    const { socket } = this.props;
-
-    if (socket != null) {
-      // websocket event
-      socket.on('admin:onProgressForExport', ({ currentCount, totalCount, progressList }) => {
-        this.setState({
-          isExporting: true,
-          progressList,
-        });
-      });
-
-      // websocket event
-      socket.on('admin:onStartZippingForExport', () => {
-        this.setState({
-          isZipping: true,
-        });
-      });
-
-      // websocket event
-      socket.on('admin:onTerminateForExport', ({ addedZipFileStat }) => {
-        const zipFileStats = this.state.zipFileStats.concat([addedZipFileStat]);
-
-        this.setState({
-          isExporting: false,
-          isZipping: false,
-          isExported: true,
-          zipFileStats,
-        });
-
-        // TODO: toastSuccess, toastError
-        toastr.success(undefined, `New Archive Data '${addedZipFileStat.fileName}' is added`, {
-          closeButton: true,
-          progressBar: true,
-          newestOnTop: false,
-          showDuration: '100',
-          hideDuration: '100',
-          timeOut: '1200',
-          extendedTimeOut: '150',
-        });
-      });
-    }
-  }
-
-  onZipFileStatAdd(newStat) {
-    this.setState((prevState) => {
-      return {
-        zipFileStats: [...prevState.zipFileStats, newStat],
-      };
-    });
-  }
-
-  async onZipFileStatRemove(fileName) {
-    try {
-      await apiDelete(`/v3/export/${fileName}`, {});
-
-      this.setState((prevState) => {
-        return {
-          zipFileStats: prevState.zipFileStats.filter(stat => stat.fileName !== fileName),
-        };
-      });
-
-      // TODO: toastSuccess, toastError
-      toastr.success(undefined, `Deleted ${fileName}`, {
-        closeButton: true,
-        progressBar: true,
-        newestOnTop: false,
-        showDuration: '100',
-        hideDuration: '100',
-        timeOut: '1200',
-        extendedTimeOut: '150',
-      });
-    }
-    catch (err) {
-      // TODO: toastSuccess, toastError
-      toastr.error(err, 'Error', {
-        closeButton: true,
-        progressBar: true,
-        newestOnTop: false,
-        showDuration: '100',
-        hideDuration: '100',
-        timeOut: '3000',
-      });
-    }
-  }
-
-  openExportModal() {
-    this.setState({ isExportModalOpen: true });
-  }
-
-  closeExportModal() {
-    this.setState({ isExportModalOpen: false });
-  }
-
-  /**
-   * event handler invoked when export process was requested successfully
-   */
-  exportingRequestedHandler() {
-  }
-
-  renderProgressBarsForCollections() {
-    const cols = this.state.progressList.map((progressData) => {
-      const { collectionName, currentCount, totalCount } = progressData;
-      return (
-        <div className="col-md-6" key={collectionName}>
-          <LabeledProgressBar
-            header={collectionName}
-            currentCount={currentCount}
-            totalCount={totalCount}
-          />
-        </div>
-      );
-    });
-
-    return <div className="row px-3">{cols}</div>;
-  }
-
-  renderProgressBarForZipping() {
-    const { isZipping, isExported } = this.state;
-    const showZippingBar = isZipping || isExported;
-
-    if (!showZippingBar) {
-      return <></>;
-    }
-
-    return (
-      <div className="row px-3">
-        <div className="col-md-12" key="progressBarForZipping">
-          <LabeledProgressBar
-            header="Zip Files"
-            currentCount={1}
-            totalCount={1}
-            isInProgress={isZipping}
-          />
-        </div>
-      </div>
-    );
-  }
-
-  render() {
-    const { t } = this.props;
-    const { isExporting, isExported, progressList } = this.state;
-
-    const showExportingData = (isExported || isExporting) && (progressList != null);
-
-    return (
-      <div data-testid="admin-export-archive-data">
-        <h2>{t('export_management.export_archive_data')}</h2>
-
-        <button type="button" className="btn btn-outline-secondary" disabled={isExporting} onClick={this.openExportModal}>
-          {t('export_management.create_new_archive_data')}
-        </button>
-
-        { showExportingData && (
-          <div className="mt-5">
-            <h3>{t('export_management.exporting_collection_list')}</h3>
-            { this.renderProgressBarsForCollections() }
-            { this.renderProgressBarForZipping() }
-          </div>
-        ) }
-
-        <div className="mt-5">
-          <h3>{t('export_management.exported_data_list')}</h3>
-          <ArchiveFilesTable
-            zipFileStats={this.state.zipFileStats}
-            onZipFileStatRemove={this.onZipFileStatRemove}
-          />
-        </div>
-
-        <SelectCollectionsModal
-          isOpen={this.state.isExportModalOpen}
-          onExportingRequested={this.exportingRequestedHandler}
-          onClose={this.closeExportModal}
-          collections={this.state.collections}
-        />
-      </div>
-    );
-  }
-
-}
-
-ExportArchiveDataPage.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  socket: PropTypes.object,
-};
-
-const ExportArchiveDataPageWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-  const { data: socket } = useAdminSocket();
-
-  return <ExportArchiveDataPage t={t} socket={socket} {...props} />;
-};
-
-export default ExportArchiveDataPageWrapperFC;

+ 198 - 0
packages/app/src/components/Admin/ExportArchiveDataPage.tsx

@@ -0,0 +1,198 @@
+import React, { useCallback, useEffect, useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import * as toastr from 'toastr';
+
+
+import { apiDelete } from '~/client/util/apiv1-client';
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { useAdminSocket } from '~/stores/socket-io';
+
+import LabeledProgressBar from './Common/LabeledProgressBar';
+import ArchiveFilesTable from './ExportArchiveData/ArchiveFilesTable';
+import SelectCollectionsModal from './ExportArchiveData/SelectCollectionsModal';
+
+
+const IGNORED_COLLECTION_NAMES = [
+  'sessions', 'rlflx', 'activities',
+];
+
+const ExportArchiveDataPage = (): JSX.Element => {
+  const { data: socket } = useAdminSocket();
+  const { t } = useTranslation();
+
+  const [collections, setCollections] = useState<any[]>([]);
+  const [zipFileStats, setZipFileStats] = useState<any[]>([]);
+  const [progressList, setProgressList] = useState<any[]>([]);
+  const [isExportModalOpen, setExportModalOpen] = useState(false);
+  const [isExporting, setExporting] = useState(false);
+  const [isZipping, setZipping] = useState(false);
+  const [isExported, setExported] = useState(false);
+
+  const fetchData = useCallback(async() => {
+    const [{ data: collectionsData }, { data: statusData }] = await Promise.all([
+      apiv3Get<{collections: any[]}>('/mongo/collections', {}),
+      apiv3Get<{status: { zipFileStats: any[], isExporting: boolean, progressList: any[] }}>('/export/status', {}),
+    ]);
+    // TODO: toastSuccess, toastError
+
+    // filter only not ignored collection names
+    const filteredCollections = collectionsData.collections.filter((collectionName) => {
+      return !IGNORED_COLLECTION_NAMES.includes(collectionName);
+    });
+
+    const { zipFileStats, isExporting, progressList } = statusData.status;
+    setCollections(filteredCollections);
+    setZipFileStats(zipFileStats);
+    setExporting(isExporting);
+    setProgressList(progressList);
+  }, []);
+
+  const setupWebsocketEventHandler = useCallback(() => {
+    if (socket != null) {
+      // websocket event
+      socket.on('admin:onProgressForExport', ({ currentCount, totalCount, progressList }) => {
+        setExporting(true);
+        setProgressList(progressList);
+      });
+
+      // websocket event
+      socket.on('admin:onStartZippingForExport', () => {
+        setZipping(true);
+      });
+
+      // websocket event
+      socket.on('admin:onTerminateForExport', ({ addedZipFileStat }) => {
+
+        setExporting(false);
+        setZipping(false);
+        setExported(true);
+        setZipFileStats(prev => prev.concat([addedZipFileStat]));
+
+        // TODO: toastSuccess, toastError
+        toastr.success(undefined, `New Archive Data '${addedZipFileStat.fileName}' is added`, {
+          closeButton: true,
+          progressBar: true,
+          newestOnTop: false,
+          showDuration: '100',
+          hideDuration: '100',
+          timeOut: '1200',
+          extendedTimeOut: '150',
+        });
+      });
+    }
+  }, [socket]);
+
+  const onZipFileStatRemove = useCallback(async(fileName) => {
+    try {
+      await apiDelete(`/v3/export/${fileName}`, {});
+
+      setZipFileStats(prev => prev.filter(stat => stat.fileName !== fileName));
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, `Deleted ${fileName}`, {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+    }
+    catch (err) {
+      // TODO: toastSuccess, toastError
+      toastr.error(err, 'Error', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    }
+  }, []);
+
+  const exportingRequestedHandler = useCallback(() => {}, []);
+
+  const renderProgressBarsForCollections = useCallback(() => {
+    const cols = progressList.map((progressData) => {
+      const { collectionName, currentCount, totalCount } = progressData;
+      return (
+        <div className="col-md-6" key={collectionName}>
+          <LabeledProgressBar
+            header={collectionName}
+            currentCount={currentCount}
+            totalCount={totalCount}
+          />
+        </div>
+      );
+    });
+
+    return <div className="row px-3">{cols}</div>;
+  }, [progressList]);
+
+  const renderProgressBarForZipping = useCallback(() => {
+    const showZippingBar = isZipping || isExported;
+
+    if (!showZippingBar) {
+      return <></>;
+    }
+
+    return (
+      <div className="row px-3">
+        <div className="col-md-12" key="progressBarForZipping">
+          <LabeledProgressBar
+            header="Zip Files"
+            currentCount={1}
+            totalCount={1}
+            isInProgress={isZipping}
+          />
+        </div>
+      </div>
+    );
+  }, [isExported, isZipping]);
+
+  useEffect(() => {
+    fetchData();
+
+    setupWebsocketEventHandler();
+  }, [fetchData, setupWebsocketEventHandler]);
+
+  const showExportingData = (isExported || isExporting) && (progressList != null);
+
+  return (
+    <div data-testid="admin-export-archive-data">
+      <h2>{t('export_management.export_archive_data')}</h2>
+
+      <button type="button" className="btn btn-outline-secondary" disabled={isExporting} onClick={() => setExportModalOpen(true)}>
+        {t('export_management.create_new_archive_data')}
+      </button>
+
+      { showExportingData && (
+        <div className="mt-5">
+          <h3>{t('export_management.exporting_collection_list')}</h3>
+          { renderProgressBarsForCollections() }
+          { renderProgressBarForZipping() }
+        </div>
+      ) }
+
+      <div className="mt-5">
+        <h3>{t('export_management.exported_data_list')}</h3>
+        <ArchiveFilesTable
+          zipFileStats={zipFileStats}
+          onZipFileStatRemove={onZipFileStatRemove}
+        />
+      </div>
+
+      <SelectCollectionsModal
+        isOpen={isExportModalOpen}
+        onExportingRequested={exportingRequestedHandler}
+        onClose={() => setExportModalOpen(false)}
+        collections={collections}
+      />
+    </div>
+  );
+};
+
+export default ExportArchiveDataPage;

+ 284 - 0
packages/app/src/components/Admin/G2GDataTransfer.tsx

@@ -0,0 +1,284 @@
+import React, {
+  ChangeEvent, useCallback, useEffect, useState,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { useGenerateTransferKey } from '~/client/services/g2g-transfer';
+import { toastError, toastSuccess } from '~/client/util/apiNotification';
+import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
+import { G2G_PROGRESS_STATUS, type G2GProgress } from '~/interfaces/g2g-transfer';
+import { useAdminSocket } from '~/stores/socket-io';
+
+import CustomCopyToClipBoard from '../Common/CustomCopyToClipBoard';
+
+// import { FileUploadSettingMolecule } from './App/FileUploadSetting';
+import G2GDataTransferExportForm from './G2GDataTransferExportForm';
+import G2GDataTransferStatusIcon from './G2GDataTransferStatusIcon';
+
+const IGNORED_COLLECTION_NAMES = [
+  'sessions', 'rlflx', 'activities', 'attachmentFiles.files', 'attachmentFiles.chunks',
+];
+
+const G2GDataTransfer = (): JSX.Element => {
+  const { data: socket } = useAdminSocket();
+  const { t } = useTranslation(['admin', 'commons']);
+
+  const [startTransferKey, setStartTransferKey] = useState('');
+  const [collections, setCollections] = useState<string[]>([]);
+  const [selectedCollections, setSelectedCollections] = useState<Set<string>>(new Set());
+  const [optionsMap, setOptionsMap] = useState<any>({});
+  const [isShowExportForm, setShowExportForm] = useState(false);
+  const [isTransferring, setTransferring] = useState(false);
+  const [g2gProgress, setG2GProgress] = useState<G2GProgress>({
+    mongo: G2G_PROGRESS_STATUS.PENDING,
+    attachments: G2G_PROGRESS_STATUS.PENDING,
+  });
+
+  // File upload settings
+  // const [fileUploadType, setFileUploadType] = useState('aws');
+  // const [s3ReferenceFileWithRelayMode, setS3ReferenceFileWithRelayMode] = useState(false);
+  // const [s3Region, setS3Region] = useState('');
+  // const [s3CustomEndpoint, setS3CustomEndpoint] = useState('');
+  // const [s3Bucket, setS3Bucket] = useState('');
+  // const [s3AccessKeyId, setS3AccessKeyId] = useState('');
+  // const [s3SecretAccessKey, setS3SecretAccessKey] = useState('');
+  // const [gcsReferenceFileWithRelayMode, setGcsReferenceFileWithRelayMode] = useState(false);
+  // const [gcsApiKeyJsonPath, setGcsApiKeyJsonPath] = useState('');
+  // const [gcsBucket, setGcsBucket] = useState('');
+  // const [gcsUploadNamespace, setGcsUploadNamespace] = useState('');
+
+  const updateSelectedCollections = (newSelectedCollections: Set<string>) => {
+    setSelectedCollections(newSelectedCollections);
+  };
+
+  const updateOptionsMap = (newOptionsMap: any) => {
+    setOptionsMap(newOptionsMap);
+  };
+
+  const onChangeTransferKeyHandler = useCallback((e) => {
+    setStartTransferKey(e.target.value);
+  }, []);
+
+  const setCollectionsAndSelectedCollections = useCallback(async() => {
+    const { data: collectionsData } = await apiv3Get<{collections: any[]}>('/mongo/collections', {});
+
+    // filter only not ignored collection names
+    const filteredCollections = collectionsData.collections.filter((collectionName) => {
+      return !IGNORED_COLLECTION_NAMES.includes(collectionName);
+    });
+
+    setCollections(filteredCollections);
+    setSelectedCollections(new Set(filteredCollections));
+  }, []);
+
+  const setupWebsocketEventHandler = useCallback(() => {
+    if (socket != null) {
+      socket.on('admin:g2gProgress', (g2gProgress: G2GProgress) => {
+        setG2GProgress(g2gProgress);
+
+        if (g2gProgress.mongo === G2G_PROGRESS_STATUS.COMPLETED && g2gProgress.attachments === G2G_PROGRESS_STATUS.COMPLETED) {
+          toastSuccess(t('admin:g2g:transfer_success'));
+        }
+      });
+
+      socket.on('admin:g2gError', ({ key }) => {
+        setTransferring(false);
+        toastError(t(key));
+      });
+    }
+  }, [socket, t, setTransferring, setG2GProgress]);
+
+  const cleanUpWebsocketEventHandler = useCallback(() => {
+    if (socket != null) {
+      socket.off('admin:g2gProgress');
+      socket.off('admin:g2gError');
+    }
+  }, [socket]);
+
+  const { transferKey, generateTransferKey } = useGenerateTransferKey();
+
+  const onClickHandler = useCallback(async() => {
+    try {
+      await generateTransferKey();
+    }
+    catch (errs) {
+      toastError(errs);
+    }
+  }, [generateTransferKey]);
+
+  const startTransfer = useCallback(async(e) => {
+    e.preventDefault();
+    setTransferring(true);
+
+    try {
+      await apiv3Post('/g2g-transfer/transfer', {
+        transferKey: startTransferKey,
+        collections: Array.from(selectedCollections),
+        optionsMap,
+      });
+    }
+    catch (errs) {
+      toastError(errs);
+    }
+  }, [setTransferring, startTransferKey, selectedCollections, optionsMap]);
+
+  // File upload
+  // const onChangeFileUploadTypeHandler = useCallback((e: ChangeEvent, type: string) => {
+  //   setFileUploadType(type);
+  // }, []);
+
+  // S3
+  // const onChangeS3ReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
+  //   setS3ReferenceFileWithRelayMode(val);
+  // }, []);
+
+  // const onChangeS3RegionHandler = useCallback((val: string) => {
+  //   setS3Region(val);
+  // }, []);
+
+  // const onChangeS3CustomEndpointHandler = useCallback((val: string) => {
+  //   setS3CustomEndpoint(val);
+  // }, []);
+
+  // const onChangeS3BucketHandler = useCallback((val: string) => {
+  //   setS3Bucket(val);
+  // }, []);
+
+  // const onChangeS3AccessKeyIdHandler = useCallback((val: string) => {
+  //   setS3AccessKeyId(val);
+  // }, []);
+
+  // const onChangeS3SecretAccessKeyHandler = useCallback((val: string) => {
+  //   setS3SecretAccessKey(val);
+  // }, []);
+
+  // // GCS
+  // const onChangeGcsReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
+  //   setGcsReferenceFileWithRelayMode(val);
+  // }, []);
+
+  // const onChangeGcsApiKeyJsonPathHandler = useCallback((val: string) => {
+  //   setGcsApiKeyJsonPath(val);
+  // }, []);
+
+  // const onChangeGcsBucketHandler = useCallback((val: string) => {
+  //   setGcsBucket(val);
+  // }, []);
+
+  // const onChangeGcsUploadNamespaceHandler = useCallback((val: string) => {
+  //   setGcsUploadNamespace(val);
+  // }, []);
+
+
+  useEffect(() => {
+    setCollectionsAndSelectedCollections();
+    setupWebsocketEventHandler();
+
+    return () => {
+      cleanUpWebsocketEventHandler();
+    };
+  }, [setCollectionsAndSelectedCollections, setupWebsocketEventHandler, cleanUpWebsocketEventHandler]);
+
+  return (
+    <div data-testid="admin-export-archive-data">
+      <h2 className="border-bottom">{t('admin:g2g_data_transfer.transfer_data_to_another_growi')}</h2>
+
+      <button type="button" className="btn btn-outline-secondary mt-4" disabled={isTransferring} onClick={() => setShowExportForm(!isShowExportForm)}>
+        {t('admin:g2g_data_transfer.advanced_options')}
+      </button>
+
+      {collections.length !== 0 && (
+        <div className={`${isShowExportForm ? '' : 'd-none'} px-3 pt-3`}>
+          {/* <h3 className='mb-1'>{t('admin:app_setting.file_upload')}</h3>
+          <FileUploadSettingMolecule
+            fileUploadType={fileUploadType}
+            isFixedFileUploadByEnvVar={false}
+            onChangeFileUploadType={onChangeFileUploadTypeHandler}
+            s3ReferenceFileWithRelayMode={s3ReferenceFileWithRelayMode}
+            s3Region={s3Region}
+            s3CustomEndpoint={s3CustomEndpoint}
+            s3Bucket={s3Bucket}
+            s3AccessKeyId={s3AccessKeyId}
+            s3SecretAccessKey={s3SecretAccessKey}
+            onChangeS3ReferenceFileWithRelayMode={onChangeS3ReferenceFileWithRelayModeHandler}
+            onChangeS3Region={onChangeS3RegionHandler}
+            onChangeS3CustomEndpoint={onChangeS3CustomEndpointHandler}
+            onChangeS3Bucket={onChangeS3BucketHandler}
+            onChangeS3AccessKeyId={onChangeS3AccessKeyIdHandler}
+            onChangeS3SecretAccessKey={onChangeS3SecretAccessKeyHandler}
+            gcsReferenceFileWithRelayMode={gcsReferenceFileWithRelayMode}
+            gcsUseOnlyEnvVars={false}
+            gcsApiKeyJsonPath={gcsApiKeyJsonPath}
+            gcsBucket={gcsBucket}
+            gcsUploadNamespace={gcsUploadNamespace}
+            onChangeGcsReferenceFileWithRelayMode={onChangeGcsReferenceFileWithRelayModeHandler}
+            onChangeGcsApiKeyJsonPath={onChangeGcsApiKeyJsonPathHandler}
+            onChangeGcsBucket={onChangeGcsBucketHandler}
+            onChangeGcsUploadNamespace={onChangeGcsUploadNamespaceHandler}
+          /> */}
+          <h3 className='mb-1'>{t('export_management.export_archive_data')}</h3>
+          <G2GDataTransferExportForm
+            allCollectionNames={collections}
+            selectedCollections={selectedCollections}
+            updateSelectedCollections={updateSelectedCollections}
+            optionsMap={optionsMap}
+            updateOptionsMap={updateOptionsMap}
+          />
+        </div>
+      )}
+
+      <form onSubmit={startTransfer}>
+        <div className="form-group row mt-3">
+          <div className="col-9">
+            <input
+              className="form-control"
+              type="text"
+              placeholder={t('admin:g2g_data_transfer.paste_transfer_key')}
+              onChange={onChangeTransferKeyHandler}
+              required
+            />
+          </div>
+          <div className="col-3">
+            <button type="submit" className="btn btn-primary w-100">{t('admin:g2g_data_transfer.start_transfer')}</button>
+          </div>
+        </div>
+      </form>
+
+      {isTransferring && (
+        <div className='border rounded p-4'>
+          <div>
+            <G2GDataTransferStatusIcon className='mr-2 mb-2' status={g2gProgress.mongo} /> MongoDB
+          </div>
+          <div>
+            <G2GDataTransferStatusIcon className='mr-2' status={g2gProgress.attachments} /> Attachments
+          </div>
+        </div>
+      )}
+
+      <h2 className="border-bottom mt-5">{t('commons:g2g_data_transfer.transfer_data_to_this_growi')}</h2>
+
+      <div className="form-group row mt-4">
+        <div className="col-md-3">
+          <button type="button" className="btn btn-primary w-100" onClick={onClickHandler}>
+            {t('commons:g2g_data_transfer.publish_transfer_key')}
+          </button>
+        </div>
+        <div className="col-md-9">
+          <div className="input-group-prepend mx-1">
+            <input className="form-control" type="text" value={transferKey} readOnly />
+            <CustomCopyToClipBoard textToBeCopied={transferKey} message="admin:slack_integration.copied_to_clipboard" />
+          </div>
+        </div>
+      </div>
+
+      <div className="alert alert-warning mt-4">
+        <p className="mb-1">{t('commons:g2g_data_transfer.transfer_key_limit')}</p>
+        <p className="mb-1">{t('commons:g2g_data_transfer.once_transfer_key_used')}</p>
+        <p className="mb-0">{t('commons:g2g_data_transfer.transfer_to_growi_cloud')}</p>
+      </div>
+    </div>
+  );
+};
+
+export default G2GDataTransfer;

+ 237 - 0
packages/app/src/components/Admin/G2GDataTransferExportForm.tsx

@@ -0,0 +1,237 @@
+import React, {
+  useState, useEffect, useCallback, useMemo,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import GrowiArchiveImportOption from '~/models/admin/growi-archive-import-option';
+import ImportOptionForPages from '~/models/admin/import-option-for-pages';
+import ImportOptionForRevisions from '~/models/admin/import-option-for-revisions';
+
+import ImportCollectionConfigurationModal from './ImportData/GrowiArchive/ImportCollectionConfigurationModal';
+import ImportCollectionItem, { DEFAULT_MODE, MODE_RESTRICTED_COLLECTION } from './ImportData/GrowiArchive/ImportCollectionItem';
+
+const GROUPS_PAGE = [
+  'pages', 'revisions', 'tags', 'pagetagrelations',
+];
+const GROUPS_USER = [
+  'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
+];
+const GROUPS_CONFIG = [
+  'configs', 'updateposts', 'globalnotificationsettings',
+];
+const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
+
+const IMPORT_OPTION_CLASS_MAPPING = {
+  pages: ImportOptionForPages,
+  revisions: ImportOptionForRevisions,
+};
+
+type Props = {
+  allCollectionNames: string[],
+  selectedCollections: Set<string>,
+  updateSelectedCollections: (newSelectedCollections: Set<string>) => void,
+  optionsMap: any,
+  updateOptionsMap: (newOptionsMap: any) => void,
+};
+
+const G2GDataTransferExportForm = (props: Props): JSX.Element => {
+  const { t } = useTranslation('admin');
+
+  const {
+    allCollectionNames, selectedCollections, updateSelectedCollections, optionsMap, updateOptionsMap,
+  } = props;
+
+  const [isConfigurationModalOpen, setConfigurationModalOpen] = useState(false);
+  const [collectionNameForConfiguration, setCollectionNameForConfiguration] = useState<any>();
+
+  const checkAll = useCallback(() => {
+    updateSelectedCollections(new Set(allCollectionNames));
+  }, [allCollectionNames, updateSelectedCollections]);
+
+  const uncheckAll = useCallback(() => {
+    updateSelectedCollections(new Set());
+  }, [updateSelectedCollections]);
+
+  const updateOption = useCallback((collectionName, data) => {
+    const options = optionsMap[collectionName];
+
+    // merge
+    Object.assign(options, data);
+
+    const updatedOptionsMap = {};
+    updatedOptionsMap[collectionName] = options;
+    updateOptionsMap((prev) => {
+      return { ...prev, updatedOptionsMap };
+    });
+  }, [optionsMap, updateOptionsMap]);
+
+  const ImportItems = ({ collectionNames }): JSX.Element => {
+    const toggleCheckbox = (collectionName, bool) => {
+      const collections = new Set(selectedCollections);
+      if (bool) {
+        collections.add(collectionName);
+      }
+      else {
+        collections.delete(collectionName);
+      }
+
+      updateSelectedCollections(collections);
+
+      // TODO: validation
+      // this.validate();
+    };
+
+    const openConfigurationModal = (collectionName) => {
+      setConfigurationModalOpen(true);
+      setCollectionNameForConfiguration(collectionName);
+    };
+
+    return (
+      <div className="row">
+        {collectionNames.map((collectionName) => {
+          const isConfigButtonAvailable = Object.keys(IMPORT_OPTION_CLASS_MAPPING).includes(collectionName);
+
+          if (optionsMap[collectionName] == null) {
+            return null;
+          }
+
+          return (
+            <div className="col-md-6 my-1" key={collectionName}>
+              <ImportCollectionItem
+                isImporting={false}
+                isImported={false}
+                insertedCount={0}
+                modifiedCount={0}
+                errorsCount={0}
+                collectionName={collectionName}
+                isSelected={selectedCollections.has(collectionName)}
+                option={optionsMap[collectionName]}
+                isConfigButtonAvailable={isConfigButtonAvailable}
+                // events
+                onChange={toggleCheckbox}
+                onOptionChange={updateOption}
+                onConfigButtonClicked={openConfigurationModal}
+                // TODO: show progress
+                isHideProgress
+              />
+            </div>
+          );
+        })}
+      </div>
+    );
+  };
+
+  const WarnForGroups = ({ errors }): JSX.Element => {
+    if (errors.length === 0) {
+      return <></>;
+    }
+
+    return (
+      <div className="alert alert-warning">
+        <ul>
+          {errors.map((error, i) => {
+            return <li key={i}>{error}</li>;
+          })}
+        </ul>
+      </div>
+    );
+  };
+
+  const GroupImportItems = ({ groupList, groupName, errors }): JSX.Element => {
+    const collectionNames = groupList.filter((groupCollectionName) => {
+      return allCollectionNames.includes(groupCollectionName);
+    });
+
+    if (collectionNames.length === 0) {
+      return <></>;
+    }
+
+    return (
+      <div className="mt-4">
+        <legend>{groupName} Collections</legend>
+        <ImportItems collectionNames={collectionNames} />
+        <WarnForGroups errors={errors} />
+      </div>
+    );
+  };
+
+  const OtherImportItems = (): JSX.Element => {
+    const collectionNames = allCollectionNames.filter((collectionName) => {
+      return !ALL_GROUPED_COLLECTIONS.includes(collectionName);
+    });
+
+    // TODO: エラー対応
+    return <GroupImportItems groupList={collectionNames} groupName='Other' errors={[]} />;
+  };
+
+  const configurationModal = useMemo(() => {
+    if (collectionNameForConfiguration == null) {
+      return <></>;
+    }
+
+    return (
+      <ImportCollectionConfigurationModal
+        isOpen={isConfigurationModalOpen}
+        onClose={() => setConfigurationModalOpen(false)}
+        onOptionChange={updateOption}
+        collectionName={collectionNameForConfiguration}
+        option={optionsMap[collectionNameForConfiguration]}
+      />
+    );
+  }, [collectionNameForConfiguration, isConfigurationModalOpen, optionsMap, updateOption]);
+
+  const setInitialOptionsMap = useCallback(() => {
+    const initialOptionsMap = {};
+    allCollectionNames.forEach((collectionName) => {
+      const initialMode = (MODE_RESTRICTED_COLLECTION[collectionName] != null)
+        ? MODE_RESTRICTED_COLLECTION[collectionName][0]
+        : DEFAULT_MODE;
+      const ImportOption = IMPORT_OPTION_CLASS_MAPPING[collectionName] || GrowiArchiveImportOption;
+      initialOptionsMap[collectionName] = new ImportOption(initialMode);
+    });
+    updateOptionsMap(initialOptionsMap);
+  }, [allCollectionNames, updateOptionsMap]);
+
+  useEffect(() => {
+    setInitialOptionsMap();
+  }, []);
+
+  return (
+    <>
+      <form className="form-inline mt-3">
+        <div className="form-group">
+          <button type="button" className="btn btn-sm btn-outline-secondary mr-2" onClick={checkAll}>
+            <i className="fa fa-check-square-o"></i> {t('admin:export_management.check_all')}
+          </button>
+        </div>
+        <div className="form-group">
+          <button type="button" className="btn btn-sm btn-outline-secondary mr-2" onClick={uncheckAll}>
+            <i className="fa fa-square-o"></i> {t('admin:export_management.uncheck_all')}
+          </button>
+        </div>
+      </form>
+
+      <div className="card well small my-4">
+        <ul>
+          <li>{t('admin:importer_management.growi_settings.description_of_import_mode.about')}</li>
+          <ul>
+            <li>{t('admin:importer_management.growi_settings.description_of_import_mode.insert')}</li>
+            <li>{t('admin:importer_management.growi_settings.description_of_import_mode.upsert')}</li>
+            <li>{t('admin:importer_management.growi_settings.description_of_import_mode.flash_and_insert')}</li>
+          </ul>
+        </ul>
+      </div>
+
+      {/* TODO: エラー追加 */}
+      <GroupImportItems groupList={GROUPS_PAGE} groupName='Page' errors={[]} />
+      <GroupImportItems groupList={GROUPS_USER} groupName='User' errors={[]} />
+      <GroupImportItems groupList={GROUPS_CONFIG} groupName='Config' errors={[]} />
+      <OtherImportItems />
+
+      {configurationModal}
+    </>
+  );
+};
+
+export default G2GDataTransferExportForm;

+ 43 - 0
packages/app/src/components/Admin/G2GDataTransferStatusIcon.tsx

@@ -0,0 +1,43 @@
+import React, { type ComponentPropsWithoutRef } from 'react';
+
+import { G2G_PROGRESS_STATUS, type G2GProgressStatus } from '~/interfaces/g2g-transfer';
+
+/**
+ * Props for {@link G2GDataTransferStatusIcon}
+ */
+interface Props extends ComponentPropsWithoutRef<'i'>{
+  status: G2GProgressStatus;
+}
+
+/**
+ * Icon for G2G transfer status
+ */
+const G2GDataTransferStatusIcon = ({ status, className, ...props }: Props): JSX.Element => {
+  if (status === G2G_PROGRESS_STATUS.IN_PROGRESS) {
+    return (
+      <i className={`fa fa-spinner fa-pulse fa-fw ${className}`} aria-label="in progress" {...props} />
+    );
+  }
+
+  if (status === G2G_PROGRESS_STATUS.COMPLETED) {
+    return (
+      <i className={`fa fa-check-circle-o fa-fw text-info ${className}`} aria-label="completed" {...props} />
+    );
+  }
+
+  if (status === G2G_PROGRESS_STATUS.ERROR) {
+    return (
+      <i className={`fa fa-exclamation-circle fa-fw text-danger ${className}`} aria-label="error" {...props} />
+    );
+  }
+
+  if (status === G2G_PROGRESS_STATUS.SKIPPED) {
+    return (
+      <i className={`fa fa-ban fa-fw ${className}`} aria-label="skipped" {...props} />
+    );
+  }
+
+  return <i className={`fa fa-circle-o fa-fw ${className}`} aria-label="pending" {...props} />;
+};
+
+export default G2GDataTransferStatusIcon;

+ 4 - 2
packages/app/src/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx

@@ -17,6 +17,7 @@ export const DEFAULT_MODE = 'insert';
 export const MODE_RESTRICTED_COLLECTION = {
   configs: ['flushAndInsert'],
   users: ['insert', 'upsert'],
+  pages: ['upsert', 'flushAndInsert'],
 };
 
 export default class ImportCollectionItem extends React.Component {
@@ -194,7 +195,7 @@ export default class ImportCollectionItem extends React.Component {
 
   render() {
     const {
-      isSelected,
+      isSelected, isHideProgress,
     } = this.props;
 
     return (
@@ -210,7 +211,7 @@ export default class ImportCollectionItem extends React.Component {
             </span>
           </div>
         </div>
-        {isSelected && (
+        {isSelected && !isHideProgress && (
           <>
             {this.renderProgressBar()}
             <div className="card-body">{this.renderBody()}</div>
@@ -225,6 +226,7 @@ export default class ImportCollectionItem extends React.Component {
 ImportCollectionItem.propTypes = {
   collectionName: PropTypes.string.isRequired,
   isSelected: PropTypes.bool.isRequired,
+  isHideProgress: PropTypes.bool,
   option: PropTypes.instanceOf(GrowiArchiveImportOption).isRequired,
 
   isImporting: PropTypes.bool.isRequired,

+ 6 - 8
packages/app/src/components/Admin/MarkdownSetting/WhiteListInput.jsx

@@ -24,13 +24,13 @@ class WhiteListInput extends React.Component {
   }
 
   onClickRecommendTagButton() {
-    // this.tagWhiteList.current.value = this.tags;
-    // this.props.adminMarkDownContainer.setState({ tagWhiteList: this.tags });
+    this.tagWhiteList.current.value = this.tags;
+    this.props.adminMarkDownContainer.setState({ tagWhiteList: this.tags });
   }
 
   onClickRecommendAttrButton() {
-    // this.attrWhiteList.current.value = this.attrs;
-    // this.props.adminMarkDownContainer.setState({ attrWhiteList: this.attrs });
+    this.attrWhiteList.current.value = this.attrs;
+    this.props.adminMarkDownContainer.setState({ attrWhiteList: this.attrs });
   }
 
   render() {
@@ -41,12 +41,11 @@ class WhiteListInput extends React.Component {
         <div className="mt-4">
           <div className="d-flex justify-content-between">
             {t('markdown_settings.xss_options.tag_names')}
-            <p id="btn-import-tags" className="btn btn-sm btn-primary mb-0 disabled" onClick={this.onClickRecommendTagButton}>
+            <p id="btn-import-tags" className="btn btn-sm btn-primary mb-0" onClick={this.onClickRecommendTagButton}>
               {t('markdown_settings.xss_options.import_recommended', { target: 'Tags' })}
             </p>
           </div>
           <textarea
-            disabled
             className="form-control xss-list"
             name="recommendedTags"
             rows="6"
@@ -59,12 +58,11 @@ class WhiteListInput extends React.Component {
         <div className="mt-4">
           <div className="d-flex justify-content-between">
             {t('markdown_settings.xss_options.tag_attributes')}
-            <p id="btn-import-tags" className="btn btn-sm btn-primary mb-0 disabled" onClick={this.onClickRecommendAttrButton}>
+            <p id="btn-import-tags" className="btn btn-sm btn-primary mb-0" onClick={this.onClickRecommendAttrButton}>
               {t('markdown_settings.xss_options.import_recommended', { target: 'Attrs' })}
             </p>
           </div>
           <textarea
-            disabled
             className="form-control xss-list"
             name="recommendedAttrs"
             rows="6"

+ 1 - 4
packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx

@@ -93,7 +93,6 @@ class XssForm extends React.Component {
           <div className="col-md-6 col-sm-12 align-self-start mb-4">
             <div className="custom-control custom-radio">
               <input
-                disabled
                 type="radio"
                 className="custom-control-input"
                 id="xssOption2"
@@ -102,9 +101,7 @@ class XssForm extends React.Component {
                 onChange={() => { adminMarkDownContainer.setState({ xssOption: RehypeSanitizeOption.CUSTOM }) }}
               />
               <label className="custom-control-label w-100" htmlFor="xssOption2">
-                <p className="font-weight-bold">{t('markdown_settings.xss_options.custom_whitelist')}
-                  <span className='text-warning'> (TBD: Currently unavailable)</span>
-                </p>
+                <p className="font-weight-bold">{t('markdown_settings.xss_options.custom_whitelist')}</p>
                 <WhiteListInput customizable />
               </label>
             </div>

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

@@ -12,6 +12,7 @@ import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
 import { useSiteUrl } from '~/stores/context';
 import loggerFactory from '~/utils/logger';
 
+import CustomCopyToClipBoard from '../../Common/CustomCopyToClipBoard';
 import Accordion from '../Common/Accordion';
 
 import ManageCommandsProcess from './ManageCommandsProcess';
@@ -117,31 +118,6 @@ const RegisteringProxyUrlProcess = () => {
   );
 };
 
-// To get different messages for each copy happend, wrapping CopyToClipBoard and Tooltip together
-const CustomCopyToClipBoard = (props) => {
-  const { t } = useTranslation();
-  const [tooltipOpen, setTooltipOpen] = useState(false);
-
-  const showToolTip = useCallback(() => {
-    setTooltipOpen(true);
-    setTimeout(() => {
-      setTooltipOpen(false);
-    }, 1000);
-  }, []);
-  return (
-    <>
-      <CopyToClipboard text={props.textToBeCopied || ''} onCopy={showToolTip}>
-        <div className="btn input-group-text" id="tooltipTarget">
-          <i className="fa fa-clipboard mx-1" aria-hidden="true"></i>
-        </div>
-      </CopyToClipboard>
-      <Tooltip target="tooltipTarget" fade={false} isOpen={tooltipOpen}>
-        {t(props.message)}
-      </Tooltip>
-    </>
-  );
-};
-
 const GeneratingTokensAndRegisteringProxyServiceProcess = (props) => {
   const { t } = useTranslation();
   const { slackAppIntegrationId } = props;

+ 38 - 0
packages/app/src/components/Common/CustomCopyToClipBoard.tsx

@@ -0,0 +1,38 @@
+import React, { FC, useState, useCallback } from 'react';
+
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+import { useTranslation } from 'react-i18next';
+import { Tooltip } from 'reactstrap';
+
+type Props = {
+  message: string
+  textToBeCopied?: string
+}
+
+// To get different messages for each copy happend, wrapping CopyToClipBoard and Tooltip together
+const CustomCopyToClipBoard: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const [tooltipOpen, setTooltipOpen] = useState(false);
+
+  const showToolTip = useCallback(() => {
+    setTooltipOpen(true);
+    setTimeout(() => {
+      setTooltipOpen(false);
+    }, 1000);
+  }, []);
+
+  return (
+    <>
+      <CopyToClipboard text={props.textToBeCopied || ''} onCopy={showToolTip}>
+        <div className="btn input-group-text" id="tooltipTarget">
+          <i className="fa fa-clipboard mx-1" aria-hidden="true"></i>
+        </div>
+      </CopyToClipboard>
+      <Tooltip target="tooltipTarget" fade={false} isOpen={tooltipOpen}>
+        {t(props.message)}
+      </Tooltip>
+    </>
+  );
+};
+
+export default CustomCopyToClipBoard;

+ 42 - 0
packages/app/src/components/DataTransferForm.tsx

@@ -0,0 +1,42 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { useGenerateTransferKey } from '~/client/services/g2g-transfer';
+
+import CustomCopyToClipBoard from './Common/CustomCopyToClipBoard';
+
+const DataTransferForm = (): JSX.Element => {
+  const { t } = useTranslation('commons');
+  const { transferKey, generateTransferKey } = useGenerateTransferKey();
+
+  return (
+    <div data-testid="installerForm" className="p-3">
+      <p className="alert alert-success">
+        <strong>{ t('g2g_data_transfer.transfer_data_to_this_growi')}</strong>
+      </p>
+
+      <div className="form-group row mt-3">
+        <div className="col-md-12">
+          <button type="button" className="btn btn-primary w-100" onClick={generateTransferKey}>
+            {t('g2g_data_transfer.publish_transfer_key')}
+          </button>
+        </div>
+        <div className="col-md-12 mt-1">
+          <div className="input-group-prepend">
+            <input className="form-control" type="text" value={transferKey} readOnly />
+            <CustomCopyToClipBoard textToBeCopied={transferKey} message="copied_to_clipboard" />
+          </div>
+        </div>
+      </div>
+
+      <div className="alert alert-warning mt-4">
+        <p className="mb-1">{t('g2g_data_transfer.transfer_key_limit')}</p>
+        <p className="mb-1">{t('g2g_data_transfer.once_transfer_key_used')}</p>
+        <p className="mb-0">{t('g2g_data_transfer.transfer_to_growi_cloud')}</p>
+      </div>
+    </div>
+  );
+};
+
+export default DataTransferForm;

+ 14 - 16
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -184,7 +184,7 @@ const CreateTemplateMenuItems = (props: CreateTemplateMenuItemsProps): JSX.Eleme
 };
 
 type GrowiContextualSubNavigationProps = {
-  currentPage?: IPagePopulatedToShowRevision,
+  currentPage?: IPagePopulatedToShowRevision | null,
   isCompactMode?: boolean,
   isLinkSharingDisabled: boolean,
 };
@@ -423,21 +423,19 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     : currentPage?.path;
 
   return (
-    <div data-testid="grw-contextual-sub-nav">
-      <GrowiSubNavigation
-        pagePath={pagePath}
-        pageId={currentPage?._id}
-        showDrawerToggler={isDrawerMode}
-        showTagLabel={isAbleToShowTagLabel}
-        isGuestUser={isGuestUser}
-        isDrawerMode={isDrawerMode}
-        isCompactMode={isCompactMode}
-        tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}
-        tagsUpdatedHandler={isViewMode ? tagsUpdatedHandlerForViewMode : tagsUpdatedHandlerForEditMode}
-        rightComponent={RightComponent}
-        additionalClasses={['container-fluid']}
-      />
-    </div>
+    <GrowiSubNavigation
+      pagePath={pagePath}
+      pageId={currentPage?._id}
+      showDrawerToggler={isDrawerMode}
+      showTagLabel={isAbleToShowTagLabel}
+      isGuestUser={isGuestUser}
+      isDrawerMode={isDrawerMode}
+      isCompactMode={isCompactMode}
+      tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}
+      tagsUpdatedHandler={isViewMode ? tagsUpdatedHandlerForViewMode : tagsUpdatedHandlerForEditMode}
+      rightComponent={RightComponent}
+      additionalClasses={['container-fluid']}
+    />
   );
 };
 

+ 8 - 2
packages/app/src/components/PageComment/Comment.tsx

@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
 
 import { IUser, pathUtils } from '@growi/core';
 import { UserPicture } from '@growi/ui';
-import { format } from 'date-fns';
+import { format, parseISO } from 'date-fns';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 import { UncontrolledTooltip } from 'reactstrap';
@@ -79,12 +79,18 @@ export const Comment = (props: CommentProps): JSX.Element => {
   const getRootClassName = (comment: ICommentHasId) => {
     let className = 'page-comment flex-column';
 
+    // TODO: fix so that `comment.createdAt` to be type Date https://redmine.weseek.co.jp/issues/113876
+    let commentCreatedAt = comment.createdAt;
+    if (typeof commentCreatedAt === 'string') {
+      commentCreatedAt = parseISO(commentCreatedAt);
+    }
+
     // Conditional for called from SearchResultContext
     if (revisionId != null && revisionCreatedAt != null) {
       if (comment.revision === revisionId) {
         className += ' page-comment-current';
       }
-      else if (comment.createdAt.getTime() > revisionCreatedAt.getTime()) {
+      else if (commentCreatedAt.getTime() > revisionCreatedAt.getTime()) {
         className += ' page-comment-newer';
       }
       else {

+ 3 - 0
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -55,6 +55,9 @@ const markTarget = (children: ItemNode[], targetPathOrId?: Nullable<string>): vo
     if (node.page._id === targetPathOrId || node.page.path === targetPathOrId) {
       node.page.isTarget = true;
     }
+    else {
+      node.page.isTarget = false;
+    }
     return node;
   });
 };

+ 23 - 0
packages/app/src/interfaces/g2g-transfer.ts

@@ -0,0 +1,23 @@
+/**
+ * G2G transfer progress status master
+ */
+export const G2G_PROGRESS_STATUS = {
+  PENDING: 'PENDING',
+  IN_PROGRESS: 'IN_PROGRESS',
+  COMPLETED: 'COMPLETED',
+  ERROR: 'ERROR',
+  SKIPPED: 'SKIPPED',
+} as const;
+
+/**
+ * G2G transfer progress status
+ */
+export type G2GProgressStatus = typeof G2G_PROGRESS_STATUS[keyof typeof G2G_PROGRESS_STATUS];
+
+/**
+ * G2G transfer progress
+ */
+export interface G2GProgress {
+ mongo: G2GProgressStatus;
+ attachments: G2GProgressStatus;
+}

+ 6 - 0
packages/app/src/interfaces/transfer-key.ts

@@ -0,0 +1,6 @@
+export interface ITransferKey<ID = string> {
+  _id: ID
+  expireAt: Date
+  keyString: string,
+  key: string,
+}

+ 20 - 7
packages/app/src/pages/[[...path]].page.tsx

@@ -57,7 +57,7 @@ import loggerFactory from '~/utils/logger';
 // import GrowiSubNavigationSwitcher from '../client/js/components/Navbar/GrowiSubNavigationSwitcher';
 import { DescendantsPageListModal } from '../components/DescendantsPageListModal';
 import { BasicLayoutWithEditorMode } from '../components/Layout/BasicLayout';
-import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
+import GrowiContextualSubNavigationSubstance from '../components/Navbar/GrowiContextualSubNavigation';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 // import { serializeUserSecurely } from '../server/models/serializers/user-serializer';
 // import PageStatusAlert from '../client/js/components/PageStatusAlert';
@@ -133,6 +133,20 @@ superjson.registerCustom<IPageToShowRevisionWithMeta, IPageToShowRevisionWithMet
   'IPageToShowRevisionWithMetaTransformer',
 );
 
+// GrowiContextualSubNavigation for NOT shared page
+type GrowiContextualSubNavigationProps = {
+  isLinkSharingDisabled: boolean,
+}
+
+const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
+  const { isLinkSharingDisabled } = props;
+  const { data: currentPage } = useSWRxCurrentPage();
+  return (
+    <div data-testid="grw-contextual-sub-nav">
+      <GrowiContextualSubNavigationSubstance currentPage={currentPage} isLinkSharingDisabled={isLinkSharingDisabled}/>
+    </div>
+  );
+};
 
 const IdenticalPathPage = (): JSX.Element => {
   const IdenticalPathPage = dynamic(() => import('../components/IdenticalPathPage').then(mod => mod.IdenticalPathPage), { ssr: false });
@@ -333,7 +347,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
       <div className={`dynamic-layout-root ${growiLayoutFluidClass} h-100 d-flex flex-column justify-content-between`}>
         <header className="py-0 position-relative">
           <div id="grw-subnav-container">
-            <GrowiContextualSubNavigation currentPage={pageWithMeta?.data} isLinkSharingDisabled={props.disableLinkSharing} />
+            <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
           </div>
         </header>
         <div className="d-edit-none">
@@ -590,12 +604,11 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
 
     // XSS Options
-    attrWhiteList: crowi.xssService.getAttrWhiteList(),
-    tagWhiteList: crowi.xssService.getTagWhiteList(),
-    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
-
-    // XSS: rehype-sanitize options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
+    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
+    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 
   props.sidebarConfig = {

+ 3 - 2
packages/app/src/pages/_private-legacy-pages.page.tsx

@@ -136,8 +136,9 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    attrWhiteList: crowi.xssService.getAttrWhiteList(),
-    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
+    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 }

+ 3 - 2
packages/app/src/pages/_search.page.tsx

@@ -158,8 +158,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    attrWhiteList: crowi.xssService.getAttrWhiteList(),
-    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
+    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 

+ 54 - 0
packages/app/src/pages/admin/data-transfer.page.tsx

@@ -0,0 +1,54 @@
+import { isClient } from '@growi/core';
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import Head from 'next/head';
+import { Container, Provider } from 'unstated';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
+import { CommonProps } from '~/pages/utils/commons';
+import { useCurrentUser } from '~/stores/context';
+
+import { retrieveServerSideProps } from '../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+const G2GDataTransferPage = dynamic(() => import('~/components/Admin/G2GDataTransfer'), { ssr: false });
+
+
+type Props = CommonProps;
+
+
+const DataTransferPage: NextPage<Props> = (props) => {
+  const { t } = useTranslation('commons');
+  useCurrentUser(props.currentUser ?? null);
+
+  const title = t('g2g_data_transfer.data_transfer');
+
+  const injectableContainers: Container<any>[] = [];
+  if (isClient()) {
+    const adminAppContainer = new AdminAppContainer();
+    injectableContainers.push(adminAppContainer);
+  }
+
+  return (
+    <Provider inject={[...injectableContainers]}>
+      <AdminLayout componentTitle={title}>
+        <Head>
+          <title>{title}</title>
+        </Head>
+        <G2GDataTransferPage />
+      </AdminLayout>
+    </Provider>
+  );
+};
+
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context);
+  return props;
+};
+
+
+export default DataTransferPage;

+ 25 - 4
packages/app/src/pages/installer.page.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useMemo } from 'react';
 
 import { pagePathUtils } from '@growi/core';
 import {
@@ -6,6 +6,7 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+import dynamic from 'next/dynamic';
 import Head from 'next/head';
 
 import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
@@ -15,12 +16,14 @@ import {
   useCsrfToken, useAppTitle, useSiteUrl, useConfidential,
 } from '../stores/context';
 
-
 import {
   CommonProps, getNextI18NextConfig, getServerSideCommonProps, generateCustomTitle,
 } from './utils/commons';
 
 
+const DataTransferForm = dynamic(() => import('../components/DataTransferForm'), { ssr: false });
+const CustomNavAndContents = dynamic(() => import('../components/CustomNavigation/CustomNavAndContents'), { ssr: false });
+
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 
 async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
@@ -35,6 +38,24 @@ type Props = CommonProps & {
 
 const InstallerPage: NextPage<Props> = (props: Props) => {
   const { t } = useTranslation();
+  const { t: tCommons } = useTranslation('commons');
+
+  const navTabMapping = useMemo(() => {
+    return {
+      user_infomation: {
+        Icon: () => <i className="icon-fw icon-user"></i>,
+        Content: InstallerForm,
+        i18n: t('installer.tab'),
+        index: 0,
+      },
+      external_accounts: {
+        Icon: () => <i className="icon-fw icon-share-alt"></i>,
+        Content: DataTransferForm,
+        i18n: tCommons('g2g_data_transfer.tab'),
+        index: 1,
+      },
+    };
+  }, [t, tCommons]);
 
   // commons
   useAppTitle(props.appTitle);
@@ -50,8 +71,8 @@ const InstallerPage: NextPage<Props> = (props: Props) => {
       <Head>
         <title>{title}</title>
       </Head>
-      <div id="installer-form-container">
-        <InstallerForm />
+      <div id="installer-form-container" className="nologin-dialog mx-auto">
+        <CustomNavAndContents navTabMapping={navTabMapping} tabContentClasses={['p-0']} />
       </div>
     </NoLoginLayout>
   );

+ 3 - 2
packages/app/src/pages/me/[[...path]].page.tsx

@@ -186,8 +186,9 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    attrWhiteList: crowi.xssService.getAttrWhiteList(),
-    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
+    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 }

+ 26 - 7
packages/app/src/pages/share/[[...path]].page.tsx

@@ -12,7 +12,7 @@ import superjson from 'superjson';
 import { useCurrentGrowiLayoutFluidClassName } from '~/client/services/layout';
 import { MainPane } from '~/components/Layout/MainPane';
 import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
-import GrowiContextualSubNavigation from '~/components/Navbar/GrowiContextualSubNavigation';
+import GrowiContextualSubNavigationSubstance from '~/components/Navbar/GrowiContextualSubNavigation';
 import { Page } from '~/components/Page';
 import type { PageSideContentsProps } from '~/components/PageSideContents';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
@@ -22,7 +22,7 @@ import { RendererConfig } from '~/interfaces/services/renderer';
 import { IShareLinkHasId } from '~/interfaces/share-link';
 import type { PageDocument } from '~/server/models/page';
 import {
-  useCurrentUser, useCurrentPathname, useCurrentPageId, useRendererConfig, useIsSearchPage,
+  useCurrentUser, useCurrentPageId, useRendererConfig, useIsSearchPage, useCurrentPathname,
   useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useDrawioUri, useIsContainerFluid,
 } from '~/stores/context';
 import loggerFactory from '~/utils/logger';
@@ -68,12 +68,29 @@ superjson.registerCustom<IShareLinkRelatedPage, string>(
   'IShareLinkRelatedPageTransformer',
 );
 
+// GrowiContextualSubNavigation for shared page
+// get page info from props not to send request 'GET /page' from client
+type GrowiContextualSubNavigationForSharedPageProps = {
+  currentPage?: IPagePopulatedToShowRevision,
+  isLinkSharingDisabled: boolean,
+}
+
+const GrowiContextualSubNavigationForSharedPage = (props: GrowiContextualSubNavigationForSharedPageProps): JSX.Element => {
+  const { currentPage, isLinkSharingDisabled } = props;
+  if (currentPage == null) { return <></> }
+  return (
+    <div data-testid="grw-contextual-sub-nav">
+      <GrowiContextualSubNavigationSubstance currentPage={currentPage} isLinkSharingDisabled={isLinkSharingDisabled}/>
+    </div>
+  );
+};
+
 const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
+  useCurrentPathname(props.shareLink?.relatedPage.path);
   useIsSearchPage(false);
   useShareLinkId(props.shareLink?._id);
   useCurrentPageId(props.shareLink?.relatedPage._id);
   useCurrentUser(props.currentUser);
-  const { data: currentPathname } = useCurrentPathname(props.currentPathname);
   useRendererConfig(props.rendererConfig);
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
@@ -88,7 +105,7 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   const isShowSharedPage = !props.disableLinkSharing && !isNotFound && !props.isExpired;
   const shareLink = props.shareLink;
 
-  const title = generateCustomTitleForPage(props, currentPathname ?? '');
+  const title = generateCustomTitleForPage(props, props.shareLinkRelatedPage?.path ?? '');
 
 
   const sideContents = shareLink != null
@@ -111,7 +128,8 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
 
       <div className={`dynamic-layout-root ${growiLayoutFluidClass} h-100 d-flex flex-column justify-content-between`}>
         <header className="py-0 position-relative">
-          {isShowSharedPage && <GrowiContextualSubNavigation currentPage={props.shareLinkRelatedPage} isLinkSharingDisabled={props.disableLinkSharing} />}
+          {isShowSharedPage
+          && <GrowiContextualSubNavigationForSharedPage currentPage={props.shareLinkRelatedPage} isLinkSharingDisabled={props.disableLinkSharing} />}
         </header>
 
         <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
@@ -191,8 +209,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    attrWhiteList: xssService.getAttrWhiteList(),
-    tagWhiteList: xssService.getTagWhiteList(),
+    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
+    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 }

+ 3 - 2
packages/app/src/pages/tags.page.tsx

@@ -165,8 +165,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    attrWhiteList: crowi.xssService.getAttrWhiteList(),
-    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
+    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 }

+ 3 - 2
packages/app/src/pages/trash.page.tsx

@@ -160,8 +160,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    attrWhiteList: crowi.xssService.getAttrWhiteList(),
-    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
+    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 }

+ 1 - 1
packages/app/src/pages/utils/commons.ts

@@ -36,7 +36,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
   } = crowi;
 
   const url = new URL(context.resolvedUrl, 'http://example.com');
-  const currentPathname = decodeURI(url.pathname);
+  const currentPathname = decodeURIComponent(url.pathname);
 
   const isMaintenanceMode = appService.isMaintenanceMode();
 

+ 13 - 0
packages/app/src/server/crowi/index.js

@@ -23,6 +23,7 @@ import AclService from '../service/acl';
 import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
 import ConfigManager from '../service/config-manager';
+import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
@@ -56,6 +57,8 @@ function Crowi() {
   this.config = {};
   this.configManager = null;
   this.s2sMessagingService = null;
+  this.g2gTransferPusherService = null;
+  this.g2gTransferReceiverService = null;
   this.mailService = null;
   this.passportService = null;
   this.globalNotificationService = null;
@@ -126,6 +129,7 @@ Crowi.prototype.init = async function() {
     this.setupPluginer(),
     this.setupMailer(),
     this.setupSlackIntegrationService(),
+    this.setupG2GTransferService(),
     this.setUpFileUpload(),
     this.setUpFileUploaderSwitchService(),
     this.setupAttachmentService(),
@@ -765,4 +769,13 @@ Crowi.prototype.setupSlackIntegrationService = async function() {
   }
 };
 
+Crowi.prototype.setupG2GTransferService = async function() {
+  if (this.g2gTransferPusherService == null) {
+    this.g2gTransferPusherService = new G2GTransferPusherService(this);
+  }
+  if (this.g2gTransferReceiverService == null) {
+    this.g2gTransferReceiverService = new G2GTransferReceiverService(this);
+  }
+};
+
 export default Crowi;

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

@@ -230,7 +230,6 @@ schema.statics.getLocalconfig = function(crowi) {
       DRAWIO_URI: env.DRAWIO_URI || null,
       HACKMD_URI: env.HACKMD_URI || null,
       MATHJAX: env.MATHJAX || null,
-      NO_CDN: env.NO_CDN || null,
       GROWI_CLOUD_URI: env.GROWI_CLOUD_URI || null,
       GROWI_APP_ID_FOR_GROWI_CLOUD: env.GROWI_APP_ID_FOR_GROWI_CLOUD || null,
     },

+ 29 - 0
packages/app/src/server/models/transfer-key.ts

@@ -0,0 +1,29 @@
+import { Model, Schema, HydratedDocument } from 'mongoose';
+
+import { ITransferKey } from '~/interfaces/transfer-key';
+
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+interface ITransferKeyMethods {
+  findOneActiveTransferKey(key: string): Promise<HydratedDocument<ITransferKey, ITransferKeyMethods> | null>;
+}
+
+type TransferKeyModel = Model<ITransferKey, any, ITransferKeyMethods>;
+
+const schema = new Schema<ITransferKey, TransferKeyModel, ITransferKeyMethods>({
+  expireAt: { type: Date, default: () => new Date(), expires: '30m' },
+  keyString: { type: String, unique: true }, // original key string
+  key: { type: String, unique: true },
+}, {
+  timestamps: {
+    createdAt: true,
+    updatedAt: false,
+  },
+});
+
+// TODO: validate createdAt
+schema.statics.findOneActiveTransferKey = async function(key: string): Promise<HydratedDocument<ITransferKey, ITransferKeyMethods> | null> {
+  return this.findOne({ key });
+};
+
+export default getOrCreateModel('TransferKey', schema);

+ 5 - 1
packages/app/src/server/models/user.js

@@ -447,7 +447,7 @@ module.exports = function(crowi) {
 
     const userUpperLimit = configManager.getConfig('crowi', 'security:userUpperLimit');
 
-    const activeUsers = await this.countListByStatus(STATUS_ACTIVE);
+    const activeUsers = await this.countActiveUsers();
     if (userUpperLimit <= activeUsers) {
       return true;
     }
@@ -455,6 +455,10 @@ module.exports = function(crowi) {
     return false;
   };
 
+  userSchema.statics.countActiveUsers = async function() {
+    return this.countListByStatus(STATUS_ACTIVE);
+  };
+
   userSchema.statics.countListByStatus = async function(status) {
     const User = this;
     const conditions = { status };

+ 34 - 0
packages/app/src/server/models/vo/g2g-transfer-error.ts

@@ -0,0 +1,34 @@
+import ExtensibleCustomError from 'extensible-custom-error';
+
+export const G2GTransferErrorCode = {
+  INVALID_TRANSFER_KEY_STRING: 'INVALID_TRANSFER_KEY_STRING',
+  FAILED_TO_RETRIEVE_GROWI_INFO: 'FAILED_TO_RETRIEVE_GROWI_INFO',
+  FAILED_TO_RETRIEVE_FILE_METADATA: 'FAILED_TO_RETRIEVE_FILE_METADATA',
+} as const;
+
+export type G2GTransferErrorCode = typeof G2GTransferErrorCode[keyof typeof G2GTransferErrorCode];
+
+export class G2GTransferError extends ExtensibleCustomError {
+
+  readonly id = 'G2GTransferError';
+
+  code!: G2GTransferErrorCode;
+
+  constructor(message: string, code: G2GTransferErrorCode) {
+    super(message);
+    this.code = code;
+  }
+
+}
+
+export const isG2GTransferError = (err: any): err is G2GTransferError => {
+  if (err == null || typeof err !== 'object') {
+    return false;
+  }
+
+  if (err instanceof G2GTransferError) {
+    return true;
+  }
+
+  return err?.id === 'G2GTransferError';
+};

+ 332 - 0
packages/app/src/server/routes/apiv3/g2g-transfer.ts

@@ -0,0 +1,332 @@
+import { createReadStream } from 'fs';
+import path from 'path';
+
+import { ErrorV3 } from '@growi/core';
+import express, { NextFunction, Request, Router } from 'express';
+import { body } from 'express-validator';
+import multer from 'multer';
+
+import { isG2GTransferError } from '~/server/models/vo/g2g-transfer-error';
+import { IDataGROWIInfo, X_GROWI_TRANSFER_KEY_HEADER_NAME } from '~/server/service/g2g-transfer';
+import loggerFactory from '~/utils/logger';
+import { TransferKey } from '~/utils/vo/transfer-key';
+
+
+import Crowi from '../../crowi';
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
+import { ApiV3Response } from './interfaces/apiv3-response';
+
+interface AuthorizedRequest extends Request {
+  user?: any
+}
+
+const logger = loggerFactory('growi:routes:apiv3:transfer');
+
+const validator = {
+  transfer: [
+    body('transferKey').isString().withMessage('transferKey is required'),
+    body('collections').isArray().withMessage('collections is required'),
+    body('optionsMap').isObject().withMessage('optionsMap is required'),
+  ],
+};
+
+/*
+ * Routes
+ */
+module.exports = (crowi: Crowi): Router => {
+  const {
+    g2gTransferPusherService, g2gTransferReceiverService, exportService, importService,
+    growiBridgeService, configManager,
+  } = crowi;
+  if (g2gTransferPusherService == null || g2gTransferReceiverService == null || exportService == null || importService == null
+    || growiBridgeService == null || configManager == null) {
+    throw Error('GROWI is not ready for g2g transfer');
+  }
+
+  const uploads = multer({
+    storage: multer.diskStorage({
+      destination: (req, file, cb) => {
+        cb(null, importService.baseDir);
+      },
+      filename(req, file, cb) {
+        // to prevent hashing the file name. files with same name will be overwritten.
+        cb(null, file.originalname);
+      },
+    }),
+    fileFilter: (req, file, cb) => {
+      if (path.extname(file.originalname) === '.zip') {
+        return cb(null, true);
+      }
+      cb(new Error('Only ".zip" is allowed'));
+    },
+  });
+
+  const uploadsForAttachment = multer({
+    storage: multer.diskStorage({
+      destination: (req, file, cb) => {
+        cb(null, importService.baseDir);
+      },
+      filename(req, file, cb) {
+        // to prevent hashing the file name. files with same name will be overwritten.
+        cb(null, file.originalname);
+      },
+    }),
+  });
+
+  const isInstalled = crowi.configManager?.getConfig('crowi', 'app:installed');
+
+  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
+  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+
+  // Middleware
+  const adminRequiredIfInstalled = (req: Request, res: ApiV3Response, next: NextFunction) => {
+    if (!isInstalled) {
+      next();
+      return;
+    }
+
+    return adminRequired(req, res, next);
+  };
+
+  // Middleware
+  const appSiteUrlRequiredIfNotInstalled = (req: Request, res: ApiV3Response, next: NextFunction) => {
+    if (!isInstalled && req.body.appSiteUrl != null) {
+      next();
+      return;
+    }
+
+    if (crowi.configManager?.getConfig('crowi', 'app:siteUrl') != null || req.body.appSiteUrl != null) {
+      next();
+      return;
+    }
+
+    return res.apiv3Err(new ErrorV3('Body param "appSiteUrl" is required when GROWI is NOT installed yet'), 400);
+  };
+
+  // Local middleware to check if key is valid or not
+  const validateTransferKey = async(req: Request, res: ApiV3Response, next: NextFunction) => {
+    const transferKey = req.headers[X_GROWI_TRANSFER_KEY_HEADER_NAME] as string;
+
+    try {
+      await g2gTransferReceiverService.validateTransferKey(transferKey);
+    }
+    catch (err) {
+      return res.apiv3Err(new ErrorV3('Invalid transfer key', 'invalid_transfer_key'), 403);
+    }
+
+    next();
+  };
+
+  const router = express.Router();
+  const receiveRouter = express.Router();
+  const pushRouter = express.Router();
+
+  // eslint-disable-next-line max-len
+  receiveRouter.get('/files', validateTransferKey, async(req: Request, res: ApiV3Response) => {
+    const files = await crowi.fileUploadService.listFiles();
+    return res.apiv3({ files });
+  });
+
+  // Auto import
+  // eslint-disable-next-line max-len
+  receiveRouter.post('/', uploads.single('transferDataZipFile'), validateTransferKey, async(req: Request & { file: any; }, res: ApiV3Response) => {
+    const { file } = req;
+    const {
+      collections: strCollections,
+      optionsMap: strOptionsMap,
+      operatorUserId,
+      uploadConfigs: strUploadConfigs,
+    } = req.body;
+
+    /*
+     * parse multipart form data
+     */
+    let collections;
+    let optionsMap;
+    let sourceGROWIUploadConfigs;
+    try {
+      collections = JSON.parse(strCollections);
+      optionsMap = JSON.parse(strOptionsMap);
+      sourceGROWIUploadConfigs = JSON.parse(strUploadConfigs);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(new ErrorV3('Failed to parse request body.', 'parse_failed'), 500);
+    }
+
+    /*
+     * unzip and parse
+     */
+    let meta;
+    let innerFileStats;
+    try {
+      const zipFile = importService.getFile(file.filename);
+      await importService.unzip(zipFile);
+
+      const { meta: parsedMeta, innerFileStats: _innerFileStats } = await growiBridgeService.parseZipFile(zipFile);
+      innerFileStats = _innerFileStats;
+      meta = parsedMeta;
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(new ErrorV3('Failed to validate transfer data file.', 'validation_failed'), 500);
+    }
+
+    /*
+     * validate meta.json
+     */
+    try {
+      importService.validate(meta);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(
+        new ErrorV3(
+          'The version of this GROWI and the uploaded GROWI data are not the same',
+          'version_incompatible',
+        ),
+        500,
+      );
+    }
+
+    /*
+     * generate maps of ImportSettings to import
+     */
+    let importSettingsMap;
+    try {
+      importSettingsMap = g2gTransferReceiverService.getImportSettingMap(innerFileStats, optionsMap, operatorUserId);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(new ErrorV3('Import settings are invalid. See GROWI docs about details.', 'import_settings_invalid'));
+    }
+
+    try {
+      await g2gTransferReceiverService.importCollections(collections, importSettingsMap, sourceGROWIUploadConfigs);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(new ErrorV3('Failed to import MongoDB collections', 'mongo_collection_import_failure'), 500);
+    }
+
+    return res.apiv3({ message: 'Successfully started to receive transfer data.' });
+  });
+
+  // This endpoint uses multer's MemoryStorage since the received data should be persisted directly on attachment storage.
+  receiveRouter.post('/attachment', uploadsForAttachment.single('content'), validateTransferKey,
+    async(req: Request & { file: any; }, res: ApiV3Response) => {
+      const { file } = req;
+      const { attachmentMetadata } = req.body;
+
+      let attachmentMap;
+      try {
+        attachmentMap = JSON.parse(attachmentMetadata);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3('Failed to parse body.', 'parse_failed'), 500);
+      }
+
+      const fileStream = createReadStream(file.path, {
+        flags: 'r', mode: 0o666, autoClose: true,
+      });
+      try {
+        await g2gTransferReceiverService.receiveAttachment(fileStream, attachmentMap);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3('Failed to upload.', 'upload_failed'), 500);
+      }
+
+      return res.apiv3({ message: 'Successfully imported attached file.' });
+    });
+
+  receiveRouter.get('/growi-info', validateTransferKey, async(req: Request, res: ApiV3Response) => {
+    let growiInfo: IDataGROWIInfo;
+    try {
+      growiInfo = await g2gTransferReceiverService.answerGROWIInfo();
+    }
+    catch (err) {
+      logger.error(err);
+
+      if (!isG2GTransferError(err)) {
+        return res.apiv3Err(new ErrorV3('Failed to prepare GROWI info', 'failed_to_prepare_growi_info'), 500);
+      }
+
+      return res.apiv3Err(new ErrorV3(err.message, err.code), 500);
+    }
+
+    return res.apiv3({ growiInfo });
+  });
+
+  // eslint-disable-next-line max-len
+  receiveRouter.post('/generate-key', accessTokenParser, adminRequiredIfInstalled, appSiteUrlRequiredIfNotInstalled, async(req: Request, res: ApiV3Response) => {
+    const appSiteUrl = req.body.appSiteUrl ?? crowi.configManager?.getConfig('crowi', 'app:siteUrl');
+
+    let appSiteUrlOrigin: string;
+    try {
+      appSiteUrlOrigin = new URL(appSiteUrl).origin;
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(new ErrorV3('appSiteUrl may be wrong', 'failed_to_generate_key_string'));
+    }
+
+    // Save TransferKey document
+    let transferKeyString: string;
+    try {
+      transferKeyString = await g2gTransferReceiverService.createTransferKey(appSiteUrlOrigin);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(new ErrorV3('Error occurred while generating transfer key.', 'failed_to_generate_key'));
+    }
+
+    return res.apiv3({ transferKey: transferKeyString });
+  });
+
+  // eslint-disable-next-line max-len
+  pushRouter.post('/transfer', accessTokenParser, loginRequiredStrictly, adminRequired, validator.transfer, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const { transferKey, collections, optionsMap } = req.body;
+
+    // Parse transfer key
+    let tk: TransferKey;
+    try {
+      tk = TransferKey.parse(transferKey);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(new ErrorV3('Transfer key is invalid', 'transfer_key_invalid'), 400);
+    }
+
+    // get growi info
+    let destGROWIInfo: IDataGROWIInfo;
+    try {
+      destGROWIInfo = await g2gTransferPusherService.askGROWIInfo(tk);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(new ErrorV3('Error occurred while asking GROWI info.', 'failed_to_ask_growi_info'));
+    }
+
+    // Check if can transfer
+    const transferability = await g2gTransferPusherService.getTransferability(destGROWIInfo);
+    if (!transferability.canTransfer) {
+      return res.apiv3Err(new ErrorV3(transferability.reason, 'growi_incompatible_to_transfer'));
+    }
+
+    // Start transfer
+    // DO NOT "await". Let it run in the background.
+    // Errors should be emitted through websocket.
+    g2gTransferPusherService.startTransfer(tk, req.user, collections, optionsMap, destGROWIInfo);
+
+    return res.apiv3({ message: 'Successfully requested auto transfer.' });
+  });
+
+  // Merge receiveRouter and pushRouter
+  router.use(receiveRouter, pushRouter);
+
+  return router;
+};

+ 16 - 12
packages/app/src/server/routes/apiv3/import.js

@@ -1,11 +1,14 @@
 import { ErrorV3 } from '@growi/core';
-import mongoose from 'mongoose';
 
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 
+import overwriteParamsAttachmentFilesChunks from './overwrite-params/attachmentFiles.chunks';
+import overwriteParamsPages from './overwrite-params/pages';
+import overwriteParamsRevisions from './overwrite-params/revisions';
+
 
 const logger = loggerFactory('growi:routes:apiv3:import'); // eslint-disable-line no-unused-vars
 
@@ -51,23 +54,23 @@ const router = express.Router();
 /**
  * generate overwrite params with overwrite-params/* modules
  * @param {string} collectionName
- * @param {object} req Request Object
+ * @param {string} operatorUserId Operator user id
  * @param {GrowiArchiveImportOption} options GrowiArchiveImportOption instance
  */
-const generateOverwriteParams = (collectionName, req, options) => {
+export const generateOverwriteParams = (collectionName, operatorUserId, options) => {
   switch (collectionName) {
     case 'pages':
-      return require('./overwrite-params/pages')(req, options);
+      return overwriteParamsPages(operatorUserId, options);
     case 'revisions':
-      return require('./overwrite-params/revisions')(req, options);
+      return overwriteParamsRevisions(operatorUserId, options);
     case 'attachmentFiles.chunks':
-      return require('./overwrite-params/attachmentFiles.chunks')(req, options);
+      return overwriteParamsAttachmentFilesChunks(operatorUserId, options);
     default:
       return {};
   }
 };
 
-module.exports = (crowi) => {
+export default function route(crowi) {
   const { growiBridgeService, importService, socketIoService } = crowi;
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi);
@@ -282,7 +285,7 @@ module.exports = (crowi) => {
       importSettings.jsonFileName = fileName;
 
       // generate overwrite params
-      importSettings.overwriteParams = generateOverwriteParams(collectionName, req, options);
+      importSettings.overwriteParams = generateOverwriteParams(collectionName, req.user._id, options);
 
       importSettingsMap[collectionName] = importSettings;
     });
@@ -292,6 +295,7 @@ module.exports = (crowi) => {
      */
     try {
       importService.import(collections, importSettingsMap);
+
       const parameters = { action: SupportedAction.ACTION_ADMIN_GROWI_DATA_IMPORTED };
       activityEvent.emit('update', res.locals.activity._id, parameters);
     }
@@ -352,9 +356,9 @@ module.exports = (crowi) => {
       return res.apiv3(data);
     }
     catch {
-      const msg = 'the version of this growi and the growi that exported the data are not met';
-      const varidationErr = 'versions-are-not-met';
-      return res.apiv3Err(new ErrorV3(msg, varidationErr), 500);
+      const msg = 'The version of this GROWI and the uploaded GROWI data are not the same';
+      const validationErr = 'versions-are-not-met';
+      return res.apiv3Err(new ErrorV3(msg, validationErr), 500);
     }
   });
 
@@ -384,4 +388,4 @@ module.exports = (crowi) => {
   });
 
   return router;
-};
+}

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

@@ -5,6 +5,8 @@ import injectUserRegistrationOrderByTokenMiddleware from '../../middlewares/inje
 import * as loginFormValidator from '../../middlewares/login-form-validator';
 import * as registerFormValidator from '../../middlewares/register-form-validator';
 
+import g2gTransfer from './g2g-transfer';
+import importRoute from './import';
 import pageListing from './page-listing';
 import * as userActivation from './user-activation';
 
@@ -33,13 +35,14 @@ module.exports = (crowi, app) => {
   routerForAdmin.use('/users', require('./users')(crowi));
   routerForAdmin.use('/user-groups', require('./user-group')(crowi));
   routerForAdmin.use('/export', require('./export')(crowi));
-  routerForAdmin.use('/import', require('./import')(crowi));
+  routerForAdmin.use('/import', importRoute(crowi));
   routerForAdmin.use('/search', require('./search')(crowi));
   routerForAdmin.use('/security-setting', require('./security-setting')(crowi));
   routerForAdmin.use('/mongo', require('./mongo')(crowi));
   routerForAdmin.use('/slack-integration-settings', require('./slack-integration-settings')(crowi));
   routerForAdmin.use('/slack-integration-legacy-settings', require('./slack-integration-legacy-settings')(crowi));
   routerForAdmin.use('/activity', require('./activity')(crowi));
+  routerForAdmin.use('/g2g-transfer', g2gTransfer(crowi));
 
   // auth
   const applicationInstalled = require('../../middlewares/application-installed')(crowi);
@@ -48,7 +51,8 @@ module.exports = (crowi, app) => {
   const loginPassport = require('../login-passport')(crowi, app);
 
   routerForAuth.post('/login', applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation,
-    addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.cannotLoginErrorHadnler, loginPassport.loginFailure);
+    addActivity, loginPassport.isEnableLoginWithLocalOrLdap, loginPassport.loginWithLocal, loginPassport.loginWithLdap,
+    loginPassport.cannotLoginErrorHadnler, loginPassport.loginFailure);
 
   routerForAuth.use('/invited', require('./invited')(crowi));
   routerForAuth.use('/logout', require('./logout')(crowi));

+ 3 - 3
packages/app/src/server/routes/apiv3/overwrite-params/attachmentFiles.chunks.js

@@ -5,13 +5,13 @@ class AttachmentFilesChunksOverwriteParamsFactory {
 
   /**
    * generate overwrite params object
-   * @param {object} req
+   * @param {string} operatorUserId
    * @param {ImportOptionForPages} option
    * @return object
    *  key: property name
    *  value: any value or a function `(value, { document, schema, propertyName }) => { return newValue }`
    */
-  static generate(req, option) {
+  static generate(operatorUserId, option) {
     const params = {};
 
     // Date
@@ -29,4 +29,4 @@ class AttachmentFilesChunksOverwriteParamsFactory {
 
 }
 
-module.exports = (req, option) => AttachmentFilesChunksOverwriteParamsFactory.generate(req, option);
+module.exports = (operatorUserId, option) => AttachmentFilesChunksOverwriteParamsFactory.generate(operatorUserId, option);

+ 8 - 15
packages/app/src/server/routes/apiv3/overwrite-params/pages.js

@@ -1,46 +1,39 @@
+const { PageGrant } = require('@growi/core');
 const mongoose = require('mongoose');
-const { format } = require('date-fns');
-const { pagePathUtils } = require('@growi/core');
-
-const { isTopPage } = pagePathUtils;
 
 // eslint-disable-next-line no-unused-vars
 const ImportOptionForPages = require('~/models/admin/import-option-for-pages');
 
 const { ObjectId } = mongoose.Types;
 
-const {
-  GRANT_PUBLIC,
-} = mongoose.model('Page');
-
 class PageOverwriteParamsFactory {
 
   /**
    * generate overwrite params object
-   * @param {object} req
+   * @param {string} operatorUserId
    * @param {ImportOptionForPages} option
    * @return object
    *  key: property name
    *  value: any value or a function `(value, { document, schema, propertyName }) => { return newValue }`
    */
-  static generate(req, option) {
+  static generate(operatorUserId, option) {
     const params = {};
 
     if (option.isOverwriteAuthorWithCurrentUser) {
-      const userId = ObjectId(req.user._id);
+      const userId = ObjectId(operatorUserId);
       params.creator = userId;
       params.lastUpdateUser = userId;
     }
 
     params.grant = (value, { document, schema, propertyName }) => {
       if (option.makePublicForGrant2 && value === 2) {
-        return GRANT_PUBLIC;
+        return PageGrant.GRANT_PUBLIC;
       }
       if (option.makePublicForGrant4 && value === 4) {
-        return GRANT_PUBLIC;
+        return PageGrant.GRANT_PUBLIC;
       }
       if (option.makePublicForGrant5 && value === 5) {
-        return GRANT_PUBLIC;
+        return PageGrant.GRANT_PUBLIC;
       }
       return value;
     };
@@ -71,4 +64,4 @@ class PageOverwriteParamsFactory {
 
 }
 
-module.exports = (req, option) => PageOverwriteParamsFactory.generate(req, option);
+module.exports = (operatorUserId, option) => PageOverwriteParamsFactory.generate(operatorUserId, option);

+ 4 - 4
packages/app/src/server/routes/apiv3/overwrite-params/revisions.js

@@ -9,17 +9,17 @@ class RevisionOverwriteParamsFactory {
 
   /**
    * generate overwrite params object
-   * @param {object} req
+   * @param {string} operatorUserId
    * @param {ImportOptionForPages} option
    * @return object
    *  key: property name
    *  value: any value or a function `(value, { document, schema, propertyName }) => { return newValue }`
    */
-  static generate(req, option) {
+  static generate(operatorUserId, option) {
     const params = {};
 
     if (option.isOverwriteAuthorWithCurrentUser) {
-      const userId = ObjectId(req.user._id);
+      const userId = ObjectId(operatorUserId);
       params.author = userId;
     }
 
@@ -28,4 +28,4 @@ class RevisionOverwriteParamsFactory {
 
 }
 
-module.exports = (req, option) => RevisionOverwriteParamsFactory.generate(req, option);
+module.exports = (operatorUserId, option) => RevisionOverwriteParamsFactory.generate(operatorUserId, option);

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

@@ -133,6 +133,16 @@ module.exports = function(crowi, app) {
     return res.apiv3({ redirectTo, userStatus: req.user.status });
   };
 
+  const isEnableLoginWithLocalOrLdap = (req, res, next) => {
+    if (!passportService.isLocalStrategySetup && !passportService.isLdapStrategySetup) {
+      logger.error('LocalStrategy and LdapStrategy has not been set up');
+      const error = new ErrorV3('message.strategy_has_not_been_set_up', '', undefined, { strategy: 'LocalStrategy and LdapStrategy' });
+      return next(error);
+    }
+
+    return next();
+  };
+
   const cannotLoginErrorHadnler = (req, res, next) => {
     // this is called when all login method is somehow failed without invoking 'return next(<any Error>)'
     const err = new ErrorV3('message.sign_in_failure');
@@ -328,7 +338,7 @@ module.exports = function(crowi, app) {
   const loginWithLocal = (req, res, next) => {
     if (!passportService.isLocalStrategySetup) {
       debug('LocalStrategy has not been set up');
-      return res.apiv3Err(new ErrorV3('message.strategy_has_not_been_set_up', '', undefined, { strategy: 'LocalStrategy' }), 405);
+      return next();
     }
 
     if (!req.form.isValid) {
@@ -585,6 +595,7 @@ module.exports = function(crowi, app) {
 
   return {
     cannotLoginErrorHadnler,
+    isEnableLoginWithLocalOrLdap,
     loginFailure,
     loginFailureForExternalAccount,
     loginWithLdap,

+ 1 - 1
packages/app/src/server/service/attachment.js

@@ -37,7 +37,7 @@ class AttachmentService {
     let attachment;
     try {
       attachment = Attachment.createWithoutSave(pageId, user, fileStream, file.originalname, file.mimetype, file.size, attachmentType);
-      await fileUploadService.uploadFile(fileStream, attachment);
+      await fileUploadService.uploadAttachment(fileStream, attachment);
       await attachment.save();
     }
     catch (err) {

+ 0 - 6
packages/app/src/server/service/config-loader.ts

@@ -73,12 +73,6 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     default: null,
   },
-  NO_CDN: {
-    ns:      'crowi',
-    key:     'app:noCdn',
-    type:    ValueType.STRING,
-    default: null,
-  },
   // PLANTUML_URI: {
   //   ns:      ,
   //   key:     ,

+ 36 - 2
packages/app/src/server/service/config-manager.ts

@@ -4,9 +4,10 @@ import loggerFactory from '~/utils/logger';
 
 import ConfigModel from '../models/config';
 import S2sMessage from '../models/vo/s2s-message';
+
+import ConfigLoader, { ConfigObject } from './config-loader';
 import { S2sMessagingService } from './s2s-messaging/base';
 import { S2sMessageHandlable } from './s2s-messaging/handlable';
-import ConfigLoader, { ConfigObject } from './config-loader';
 
 const logger = loggerFactory('growi:service:ConfigManager');
 
@@ -187,7 +188,7 @@ export default class ConfigManager implements S2sMessageHandlable {
    *  );
    * ```
    */
-  async updateConfigsInTheSameNamespace(namespace, configs, withoutPublishingS2sMessage) {
+  async updateConfigsInTheSameNamespace(namespace, configs, withoutPublishingS2sMessage?) {
     const queries: any[] = [];
     for (const key of Object.keys(configs)) {
       queries.push({
@@ -208,6 +209,25 @@ export default class ConfigManager implements S2sMessageHandlable {
     }
   }
 
+  async removeConfigsInTheSameNamespace(namespace, configKeys: string[], withoutPublishingS2sMessage?) {
+    const queries: any[] = [];
+    for (const key of configKeys) {
+      queries.push({
+        deleteOne: {
+          filter: { ns: namespace, key },
+        },
+      });
+    }
+    await ConfigModel.bulkWrite(queries);
+
+    await this.loadConfigs();
+
+    // publish updated date after reloading
+    if (this.s2sMessagingService != null && !withoutPublishingS2sMessage) {
+      this.publishUpdateMessage();
+    }
+  }
+
   /**
    * return whether the specified namespace/key should be retrieved only from env vars
    */
@@ -356,4 +376,18 @@ export default class ConfigManager implements S2sMessageHandlable {
     return this.loadConfigs();
   }
 
+  /**
+   * Returns file upload total limit in bytes.
+   * Reference to previous implementation is
+   * {@link https://github.com/weseek/growi/blob/798e44f14ad01544c1d75ba83d4dfb321a94aa0b/src/server/service/file-uploader/gridfs.js#L86-L88}
+   * @returns file upload total limit in bytes
+   */
+  getFileUploadTotalLimit(): number {
+    const fileUploadTotalLimit = this.getConfig('crowi', 'app:fileUploadType') === 'mongodb'
+      // Use app:fileUploadTotalLimit if gridfs:totalLimit is null (default for gridfs:totalLimit is null)
+      ? this.getConfig('crowi', 'gridfs:totalLimit') ?? this.getConfig('crowi', 'app:fileUploadTotalLimit')
+      : this.getConfig('crowi', 'app:fileUploadTotalLimit');
+    return fileUploadTotalLimit;
+  }
+
 }

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

@@ -231,6 +231,8 @@ class ExportService {
     // send terminate event
     this.emitTerminateEvent(addedZipFileStat);
 
+    return addedZipFileStat;
+
     // TODO: remove broken zip file
   }
 
@@ -242,13 +244,15 @@ class ExportService {
     this.currentProgressingStatus = new ExportProgressingStatus(collections);
     await this.currentProgressingStatus.init();
 
+    let zipFileStat;
     try {
-      await this.exportCollectionsToZippedJson(collections);
+      zipFileStat = await this.exportCollectionsToZippedJson(collections);
     }
     finally {
       this.currentProgressingStatus = null;
     }
 
+    return zipFileStat;
   }
 
   /**
@@ -351,7 +355,7 @@ class ExportService {
 
     await streamToPromise(archive);
 
-    logger.info(`zipped growi data into ${zipFile} (${archive.pointer()} bytes)`);
+    logger.info(`zipped GROWI data into ${zipFile} (${archive.pointer()} bytes)`);
 
     // delete json files
     for (const { from } of configs) {

+ 79 - 11
packages/app/src/server/service/file-uploader/aws.ts

@@ -6,6 +6,7 @@ import {
   PutObjectCommand,
   DeleteObjectCommand,
   GetObjectCommandOutput,
+  ListObjectsCommand,
 } from '@aws-sdk/client-s3';
 import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
 import urljoin from 'url-join';
@@ -15,6 +16,15 @@ import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:service:fileUploaderAws');
 
+/**
+ * File metadata in storage
+ * TODO: mv this to "./uploader"
+ */
+  interface FileMeta {
+  name: string;
+  size: number;
+}
+
 type AwsCredential = {
   accessKeyId: string,
   secretAccessKey: string
@@ -76,7 +86,7 @@ module.exports = (crowi) => {
     return true;
   };
 
-  lib.isValidUploadSettings = () => {
+  lib.isValidUploadSettings = function() {
     return configManager.getConfig('crowi', 'aws:s3AccessKeyId') != null
       && configManager.getConfig('crowi', 'aws:s3SecretAccessKey') != null
       && (
@@ -86,11 +96,11 @@ module.exports = (crowi) => {
       && configManager.getConfig('crowi', 'aws:s3Bucket') != null;
   };
 
-  lib.canRespond = () => {
+  lib.canRespond = function() {
     return !configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
   };
 
-  lib.respond = async(res, attachment) => {
+  lib.respond = async function(res, attachment) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
     }
@@ -124,12 +134,12 @@ module.exports = (crowi) => {
 
   };
 
-  lib.deleteFile = async(attachment) => {
+  lib.deleteFile = async function(attachment) {
     const filePath = getFilePathOnStorage(attachment);
     return lib.deleteFileByFilePath(filePath);
   };
 
-  lib.deleteFiles = async(attachments) => {
+  lib.deleteFiles = async function(attachments) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
     }
@@ -147,7 +157,7 @@ module.exports = (crowi) => {
     return s3.send(new DeleteObjectsCommand(totalParams));
   };
 
-  lib.deleteFileByFilePath = async(filePath) => {
+  lib.deleteFileByFilePath = async function(filePath) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
     }
@@ -169,7 +179,7 @@ module.exports = (crowi) => {
     return s3.send(new DeleteObjectCommand(params));
   };
 
-  lib.uploadFile = async(fileStream, attachment) => {
+  lib.uploadAttachment = async function(fileStream, attachment) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
     }
@@ -191,7 +201,22 @@ module.exports = (crowi) => {
     return s3.send(new PutObjectCommand(params));
   };
 
-  lib.findDeliveryFile = async(attachment) => {
+  lib.saveFile = async function({ filePath, contentType, data }) {
+    const s3 = S3Factory();
+    const awsConfig = getAwsConfig();
+
+    const params = {
+      Bucket: awsConfig.bucket,
+      ContentType: contentType,
+      Key: filePath,
+      Body: data,
+      ACL: 'public-read',
+    };
+
+    return s3.send(new PutObjectCommand(params));
+  };
+
+  lib.findDeliveryFile = async function(attachment) {
     if (!lib.getIsReadable()) {
       throw new Error('AWS is not configured.');
     }
@@ -224,11 +249,54 @@ module.exports = (crowi) => {
     return stream;
   };
 
-  lib.checkLimit = async(uploadFileSize) => {
-    const maxFileSize = crowi.configManager.getConfig('crowi', 'app:maxFileSize');
-    const totalLimit = crowi.configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
+  lib.checkLimit = async function(uploadFileSize) {
+    const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
+    const totalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
     return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
   };
 
+  /**
+   * List files in storage
+   */
+  lib.listFiles = async function() {
+    if (!lib.getIsReadable()) {
+      throw new Error('AWS is not configured.');
+    }
+
+    const files: FileMeta[] = [];
+    const s3 = S3Factory();
+    const awsConfig = getAwsConfig();
+    const params = {
+      Bucket: awsConfig.bucket,
+    };
+    let shouldContinue = true;
+    let nextMarker: string | undefined;
+
+    // handle pagination
+    while (shouldContinue) {
+      // eslint-disable-next-line no-await-in-loop
+      const { Contents = [], IsTruncated, NextMarker } = await s3.send(new ListObjectsCommand({
+        ...params,
+        Marker: nextMarker,
+      }));
+      files.push(...(
+        Contents.map(({ Key, Size }) => ({
+          name: Key as string,
+          size: Size as number,
+        }))
+      ));
+
+      if (!IsTruncated) {
+        shouldContinue = false;
+        nextMarker = undefined;
+      }
+      else {
+        nextMarker = NextMarker;
+      }
+    }
+
+    return files;
+  };
+
   return lib;
 };

+ 36 - 12
packages/app/src/server/service/file-uploader/gcs.js

@@ -50,16 +50,16 @@ module.exports = function(crowi) {
   }
 
   lib.isValidUploadSettings = function() {
-    return this.configManager.getConfig('crowi', 'gcs:apiKeyJsonPath') != null
-      && this.configManager.getConfig('crowi', 'gcs:bucket') != null;
+    return configManager.getConfig('crowi', 'gcs:apiKeyJsonPath') != null
+      && configManager.getConfig('crowi', 'gcs:bucket') != null;
   };
 
   lib.canRespond = function() {
-    return !this.configManager.getConfig('crowi', 'gcs:referenceFileWithRelayMode');
+    return !configManager.getConfig('crowi', 'gcs:referenceFileWithRelayMode');
   };
 
   lib.respond = async function(res, attachment) {
-    if (!this.getIsUploadable()) {
+    if (!lib.getIsUploadable()) {
       throw new Error('GCS is not configured.');
     }
     const temporaryUrl = attachment.getValidTemporaryUrl();
@@ -71,7 +71,7 @@ module.exports = function(crowi) {
     const myBucket = gcs.bucket(getGcsBucket());
     const filePath = getFilePathOnStorage(attachment);
     const file = myBucket.file(filePath);
-    const lifetimeSecForTemporaryUrl = this.configManager.getConfig('crowi', 'gcs:lifetimeSecForTemporaryUrl');
+    const lifetimeSecForTemporaryUrl = configManager.getConfig('crowi', 'gcs:lifetimeSecForTemporaryUrl');
 
     // issue signed url (default: expires 120 seconds)
     // https://cloud.google.com/storage/docs/access-control/signed-urls
@@ -104,7 +104,7 @@ module.exports = function(crowi) {
   };
 
   lib.deleteFilesByFilePaths = function(filePaths) {
-    if (!this.getIsUploadable()) {
+    if (!lib.getIsUploadable()) {
       throw new Error('GCS is not configured.');
     }
 
@@ -120,8 +120,8 @@ module.exports = function(crowi) {
     });
   };
 
-  lib.uploadFile = function(fileStream, attachment) {
-    if (!this.getIsUploadable()) {
+  lib.uploadAttachment = function(fileStream, attachment) {
+    if (!lib.getIsUploadable()) {
       throw new Error('GCS is not configured.');
     }
 
@@ -137,6 +137,13 @@ module.exports = function(crowi) {
     return myBucket.upload(fileStream.path, options);
   };
 
+  lib.saveFile = async function({ filePath, contentType, data }) {
+    const gcs = getGcsInstance();
+    const myBucket = gcs.bucket(getGcsBucket());
+
+    return myBucket.file(filePath).save(data, { resumable: false });
+  };
+
   /**
    * Find data substance
    *
@@ -144,7 +151,7 @@ module.exports = function(crowi) {
    * @return {stream.Readable} readable stream
    */
   lib.findDeliveryFile = async function(attachment) {
-    if (!this.getIsReadable()) {
+    if (!lib.getIsReadable()) {
       throw new Error('GCS is not configured.');
     }
 
@@ -178,11 +185,28 @@ module.exports = function(crowi) {
    * In detail, the followings are checked.
    * - per-file size limit (specified by MAX_FILE_SIZE)
    */
-  lib.checkLimit = async(uploadFileSize) => {
-    const maxFileSize = crowi.configManager.getConfig('crowi', 'app:maxFileSize');
-    const gcsTotalLimit = crowi.configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
+  lib.checkLimit = async function(uploadFileSize) {
+    const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
+    const gcsTotalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
     return lib.doCheckLimit(uploadFileSize, maxFileSize, gcsTotalLimit);
   };
 
+  /**
+   * List files in storage
+   */
+  lib.listFiles = async function() {
+    if (!lib.getIsReadable()) {
+      throw new Error('GCS is not configured.');
+    }
+
+    const gcs = getGcsInstance();
+    const bucket = gcs.bucket(getGcsBucket());
+    const [files] = await bucket.getFiles();
+
+    return files.map(({ name, metadata: { size } }) => {
+      return { name, size };
+    });
+  };
+
   return lib;
 };

+ 34 - 9
packages/app/src/server/service/file-uploader/gridfs.js

@@ -1,11 +1,15 @@
+import { Readable } from 'stream';
+
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:service:fileUploaderGridfs');
-const mongoose = require('mongoose');
 const util = require('util');
 
+const mongoose = require('mongoose');
+
 module.exports = function(crowi) {
   const Uploader = require('./uploader');
+  const { configManager } = crowi;
   const lib = new Uploader(crowi);
   const COLLECTION_NAME = 'attachmentFiles';
   const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
@@ -83,16 +87,13 @@ module.exports = function(crowi) {
    * - per-file size limit (specified by MAX_FILE_SIZE)
    * - mongodb(gridfs) size limit (specified by MONGO_GRIDFS_TOTAL_LIMIT)
    */
-  lib.checkLimit = async(uploadFileSize) => {
-    const maxFileSize = crowi.configManager.getConfig('crowi', 'app:maxFileSize');
-
-    // Use app:fileUploadTotalLimit if gridfs:totalLimit is null (default for gridfs:totalLimitd is null)
-    const gridfsTotalLimit = crowi.configManager.getConfig('crowi', 'gridfs:totalLimit')
-      || crowi.configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
-    return lib.doCheckLimit(uploadFileSize, maxFileSize, gridfsTotalLimit);
+  lib.checkLimit = async function(uploadFileSize) {
+    const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
+    const totalLimit = configManager.getFileUploadTotalLimit();
+    return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
   };
 
-  lib.uploadFile = async function(fileStream, attachment) {
+  lib.uploadAttachment = async function(fileStream, attachment) {
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
     return AttachmentFile.promisifiedWrite(
@@ -104,6 +105,20 @@ module.exports = function(crowi) {
     );
   };
 
+  lib.saveFile = async function({ filePath, contentType, data }) {
+    const readable = new Readable();
+    readable.push(data);
+    readable.push(null); // EOF
+
+    return AttachmentFile.promisifiedWrite(
+      {
+        filename: filePath,
+        contentType,
+      },
+      readable,
+    );
+  };
+
   /**
    * Find data substance
    *
@@ -127,5 +142,15 @@ module.exports = function(crowi) {
     return AttachmentFile.read({ _id: attachmentFile._id });
   };
 
+  /**
+   * List files in storage
+   */
+  lib.listFiles = async function() {
+    const attachmentFiles = await AttachmentFile.find();
+    return attachmentFiles.map(({ filename: name, length: size }) => ({
+      name, size,
+    }));
+  };
+
   return lib;
 };

+ 55 - 9
packages/app/src/server/service/file-uploader/local.js

@@ -1,15 +1,20 @@
+import { Readable } from 'stream';
+
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:service:fileUploaderLocal');
 
 const fs = require('fs');
+const fsPromises = require('fs/promises');
 const path = require('path');
+
 const mkdir = require('mkdirp');
 const streamToPromise = require('stream-to-promise');
 const urljoin = require('url-join');
 
 module.exports = function(crowi) {
   const Uploader = require('./uploader');
+  const { configManager } = crowi;
   const lib = new Uploader(crowi);
   const basePath = path.posix.join(crowi.publicDir, 'uploads');
 
@@ -28,6 +33,16 @@ module.exports = function(crowi) {
     return filePath;
   }
 
+  async function readdirRecursively(dirPath) {
+    const directories = await fsPromises.readdir(dirPath, { withFileTypes: true });
+    const files = await Promise.all(directories.map((directory) => {
+      const childDirPathOrFilePath = path.resolve(dirPath, directory.name);
+      return directory.isDirectory() ? readdirRecursively(childDirPathOrFilePath) : childDirPathOrFilePath;
+    }));
+
+    return files.flat();
+  }
+
   lib.isValidUploadSettings = function() {
     return true;
   };
@@ -39,7 +54,7 @@ module.exports = function(crowi) {
 
   lib.deleteFiles = async function(attachments) {
     attachments.map((attachment) => {
-      return this.deleteFile(attachment);
+      return lib.deleteFile(attachment);
     });
   };
 
@@ -56,7 +71,7 @@ module.exports = function(crowi) {
     return fs.unlinkSync(filePath);
   };
 
-  lib.uploadFile = async function(fileStream, attachment) {
+  lib.uploadAttachment = async function(fileStream, attachment) {
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
     const filePath = getFilePathOnStorage(attachment);
@@ -69,6 +84,20 @@ module.exports = function(crowi) {
     return streamToPromise(stream);
   };
 
+  lib.saveFile = async function({ filePath, contentType, data }) {
+    const absFilePath = path.posix.join(basePath, filePath);
+    const dirpath = path.posix.dirname(absFilePath);
+
+    // mkdir -p
+    mkdir.sync(dirpath);
+
+    const fileStream = new Readable();
+    fileStream.push(data);
+    fileStream.push(null); // EOF
+    const stream = fileStream.pipe(fs.createWriteStream(absFilePath));
+    return streamToPromise(stream);
+  };
+
   /**
    * Find data substance
    *
@@ -96,18 +125,18 @@ module.exports = function(crowi) {
    * In detail, the followings are checked.
    * - per-file size limit (specified by MAX_FILE_SIZE)
    */
-  lib.checkLimit = async(uploadFileSize) => {
-    const maxFileSize = crowi.configManager.getConfig('crowi', 'app:maxFileSize');
-    const totalLimit = crowi.configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
+  lib.checkLimit = async function(uploadFileSize) {
+    const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
+    const totalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
     return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
   };
 
   /**
    * Checks if Uploader can respond to the HTTP request.
    */
-  lib.canRespond = () => {
+  lib.canRespond = function() {
     // Check whether to use internal redirect of nginx or Apache.
-    return lib.configManager.getConfig('crowi', 'fileUpload:local:useInternalRedirect');
+    return configManager.getConfig('crowi', 'fileUpload:local:useInternalRedirect');
   };
 
   /**
@@ -115,16 +144,33 @@ module.exports = function(crowi) {
    * @param {Response} res
    * @param {Response} attachment
    */
-  lib.respond = (res, attachment) => {
+  lib.respond = function(res, attachment) {
     // Responce using internal redirect of nginx or Apache.
     const storagePath = getFilePathOnStorage(attachment);
     const relativePath = path.relative(crowi.publicDir, storagePath);
-    const internalPathRoot = lib.configManager.getConfig('crowi', 'fileUpload:local:internalRedirectPath');
+    const internalPathRoot = configManager.getConfig('crowi', 'fileUpload:local:internalRedirectPath');
     const internalPath = urljoin(internalPathRoot, relativePath);
     res.set('X-Accel-Redirect', internalPath);
     res.set('X-Sendfile', storagePath);
     return res.end();
   };
 
+  /**
+   * List files in storage
+   */
+  lib.listFiles = async function() {
+    // `mkdir -p` to avoid ENOENT error
+    await mkdir(basePath);
+    const filePaths = await readdirRecursively(basePath);
+    return Promise.all(
+      filePaths.map(
+        file => fsPromises.stat(file).then(({ size }) => ({
+          name: path.relative(basePath, file),
+          size,
+        })),
+      ),
+    );
+  };
+
   return lib;
 };

Разница между файлами не показана из-за своего большого размера
+ 7 - 1
packages/app/src/server/service/file-uploader/none.js


+ 48 - 9
packages/app/src/server/service/file-uploader/uploader.js

@@ -1,3 +1,9 @@
+import { randomUUID } from 'crypto';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:fileUploader');
+
 // file uploader virtual class
 // 各アップローダーで共通のメソッドはここで定義する
 
@@ -12,6 +18,30 @@ class Uploader {
     return !this.configManager.getConfig('crowi', 'app:fileUploadDisabled') && this.isValidUploadSettings();
   }
 
+  /**
+   * Returns whether write opration to the storage is permitted
+   * @returns Whether write opration to the storage is permitted
+   */
+  async isWritable() {
+    const filePath = `${randomUUID()}.growi`;
+    const data = 'This file was created during g2g transfer to check write permission. You can safely remove this file.';
+
+    try {
+      await this.saveFile({
+        filePath,
+        contentType: 'text/plain',
+        data,
+      });
+      // TODO: delete tmp file in background
+
+      return true;
+    }
+    catch (err) {
+      logger.error(err);
+      return false;
+    }
+  }
+
   // File reading is possible even if uploading is disabled
   getIsReadable() {
     return this.isValidUploadSettings();
@@ -33,6 +63,23 @@ class Uploader {
     throw new Error('Implemnt this');
   }
 
+  /**
+   * Get total file size
+   * @returns Total file size
+   */
+  async getTotalFileSize() {
+    const Attachment = this.crowi.model('Attachment');
+
+    // Get attachment total file size
+    const res = await Attachment.aggregate().group({
+      _id: null,
+      total: { $sum: '$fileSize' },
+    });
+
+    // res is [] if not using
+    return res.length === 0 ? 0 : res[0].total;
+  }
+
   /**
    * Check files size limits for all uploaders
    *
@@ -46,21 +93,13 @@ class Uploader {
     if (uploadFileSize > maxFileSize) {
       return { isUploadable: false, errorMessage: 'File size exceeds the size limit per file' };
     }
-    const Attachment = this.crowi.model('Attachment');
-    // Get attachment total file size
-    const res = await Attachment.aggregate().group({
-      _id: null,
-      total: { $sum: '$fileSize' },
-    });
-    // Return res is [] if not using
-    const usingFilesSize = res.length === 0 ? 0 : res[0].total;
 
+    const usingFilesSize = await this.getTotalFileSize();
     if (usingFilesSize + uploadFileSize > totalLimit) {
       return { isUploadable: false, errorMessage: 'Uploading files reaches limit' };
     }
 
     return { isUploadable: true };
-
   }
 
   /**

+ 676 - 0
packages/app/src/server/service/g2g-transfer.ts

@@ -0,0 +1,676 @@
+import { createReadStream, ReadStream } from 'fs';
+import { basename } from 'path';
+import { Readable } from 'stream';
+
+// eslint-disable-next-line no-restricted-imports
+import rawAxios, { type AxiosRequestConfig } from 'axios';
+import FormData from 'form-data';
+import { Types as MongooseTypes } from 'mongoose';
+
+import { G2G_PROGRESS_STATUS } from '~/interfaces/g2g-transfer';
+import GrowiArchiveImportOption from '~/models/admin/growi-archive-import-option';
+import TransferKeyModel from '~/server/models/transfer-key';
+import { generateOverwriteParams } from '~/server/routes/apiv3/import';
+import { type ImportSettings } from '~/server/service/import';
+import { createBatchStream } from '~/server/util/batch-stream';
+import axios from '~/utils/axios';
+import loggerFactory from '~/utils/logger';
+import { TransferKey } from '~/utils/vo/transfer-key';
+
+import { G2GTransferError, G2GTransferErrorCode } from '../models/vo/g2g-transfer-error';
+
+const logger = loggerFactory('growi:service:g2g-transfer');
+
+/**
+ * Header name for transfer key
+ */
+export const X_GROWI_TRANSFER_KEY_HEADER_NAME = 'x-growi-transfer-key';
+
+/**
+ * Keys for file upload related config
+ */
+const UPLOAD_CONFIG_KEYS = [
+  'app:fileUploadType',
+  'app:useOnlyEnvVarForFileUploadType',
+  'aws:referenceFileWithRelayMode',
+  'aws:lifetimeSecForTemporaryUrl',
+  'gcs:apiKeyJsonPath',
+  'gcs:bucket',
+  'gcs:uploadNamespace',
+  'gcs:referenceFileWithRelayMode',
+  'gcs:useOnlyEnvVarsForSomeOptions',
+] as const;
+
+/**
+ * File upload related configs
+ */
+type FileUploadConfigs = { [key in typeof UPLOAD_CONFIG_KEYS[number] ]: any; }
+
+/**
+ * Data used for comparing to/from GROWI information
+ */
+export type IDataGROWIInfo = {
+  /** GROWI version */
+  version: string
+  /** Max user count */
+  userUpperLimit: number | null // Handle null as Infinity
+  /** Whether file upload is disabled */
+  fileUploadDisabled: boolean;
+  /** Total file size allowed */
+  fileUploadTotalLimit: number | null // Handle null as Infinity
+  /** Attachment infromation */
+  attachmentInfo: {
+    /** File storage type */
+    type: string;
+    /** Whether the storage is writable */
+    writable: boolean;
+    /** Bucket name (S3 and GCS only) */
+    bucket?: string;
+    /** S3 custom endpoint */
+    customEndpoint?: string;
+    /** GCS namespace */
+    uploadNamespace?: string;
+  };
+}
+
+/**
+ * File metadata in storage
+ * TODO: mv this to "./file-uploader/uploader"
+ */
+interface FileMeta {
+  /** File name */
+  name: string;
+  /** File size in bytes */
+  size: number;
+}
+
+/**
+ * Return type for {@link Pusher.getTransferability}
+ */
+type Transferability = { canTransfer: true; } | { canTransfer: false; reason: string; };
+
+/**
+ * G2g transfer pusher
+ */
+interface Pusher {
+  /**
+   * Merge axios config with transfer key
+   * @param {TransferKey} tk Transfer key
+   * @param {AxiosRequestConfig} config Axios config
+   */
+  generateAxiosConfig(tk: TransferKey, config: AxiosRequestConfig): AxiosRequestConfig
+  /**
+   * Send to-growi a request to get GROWI info
+   * @param {TransferKey} tk Transfer key
+   */
+  askGROWIInfo(tk: TransferKey): Promise<IDataGROWIInfo>
+  /**
+   * Check if transfering is proceedable
+   * @param {IDataGROWIInfo} destGROWIInfo GROWI info from dest GROWI
+   */
+  getTransferability(destGROWIInfo: IDataGROWIInfo): Promise<Transferability>
+  /**
+   * List files in the storage
+   * @param {TransferKey} tk Transfer key
+   */
+  listFilesInStorage(tk: TransferKey): Promise<FileMeta[]>
+  /**
+   * Transfer all Attachment data to dest GROWI
+   * @param {TransferKey} tk Transfer key
+   */
+  transferAttachments(tk: TransferKey): Promise<void>
+  /**
+   * Start transfer data between GROWIs
+   * @param {TransferKey} tk TransferKey object
+   * @param {any} user User operating g2g transfer
+   * @param {IDataGROWIInfo} destGROWIInfo GROWI info of dest GROWI
+   * @param {string[]} collections Collection name string array
+   * @param {any} optionsMap Options map
+   */
+  startTransfer(
+    tk: TransferKey,
+    user: any,
+    collections: string[],
+    optionsMap: any,
+    destGROWIInfo: IDataGROWIInfo,
+  ): Promise<void>
+}
+
+/**
+ * G2g transfer receiver
+ */
+interface Receiver {
+  /**
+   * Check if key is not expired
+   * @throws {import('../models/vo/g2g-transfer-error').G2GTransferError}
+   * @param {string} key Transfer key
+   */
+  validateTransferKey(key: string): Promise<void>
+  /**
+   * Generate GROWIInfo
+   * @throws {import('../models/vo/g2g-transfer-error').G2GTransferError}
+   */
+  answerGROWIInfo(): Promise<IDataGROWIInfo>
+  /**
+   * DO NOT USE TransferKeyModel.create() directly, instead, use this method to create a TransferKey document.
+   * This method receives appSiteUrlOrigin to create a TransferKey document and returns generated transfer key string.
+   * UUID is the same value as the created document's _id.
+   * @param {string} appSiteUrlOrigin GROWI app site URL origin
+   * @returns {string} Transfer key string (e.g. http://localhost:3000__grw_internal_tranferkey__<uuid>)
+   */
+  createTransferKey(appSiteUrlOrigin: string): Promise<string>
+  /**
+   * Returns a map of collection name and ImportSettings
+   * @param {any[]} innerFileStats
+   * @param {{ [key: string]: GrowiArchiveImportOption; }} optionsMap Map of collection name and GrowiArchiveImportOption
+   * @param {string} operatorUserId User ID
+   * @returns {{ [key: string]: ImportSettings; }} Map of collection name and ImportSettings
+   */
+  getImportSettingMap(
+    innerFileStats: any[],
+    optionsMap: { [key: string]: GrowiArchiveImportOption; },
+    operatorUserId: string,
+  ): { [key: string]: ImportSettings; }
+  /**
+   * Import collections
+   * @param {string} collections Array of collection name
+   * @param {{ [key: string]: ImportSettings; }} importSettingsMap Map of collection name and ImportSettings
+   * @param {FileUploadConfigs} sourceGROWIUploadConfigs File upload configs from src GROWI
+   */
+  importCollections(
+    collections: string[],
+    importSettingsMap: { [key: string]: ImportSettings; },
+    sourceGROWIUploadConfigs: FileUploadConfigs,
+  ): Promise<void>
+  /**
+   * Returns file upload configs
+   */
+  getFileUploadConfigs(): Promise<FileUploadConfigs>
+    /**
+   * Update file upload configs
+   * @param fileUploadConfigs  File upload configs
+   */
+  updateFileUploadConfigs(fileUploadConfigs: FileUploadConfigs): Promise<void>
+  /**
+   * Upload attachment file
+   * @param {Readable} content Pushed attachment data from source GROWI
+   * @param {any} attachmentMap Map-ped Attachment instance
+   */
+  receiveAttachment(content: Readable, attachmentMap: any): Promise<void>
+}
+
+/**
+ * G2g transfer pusher
+ */
+export class G2GTransferPusherService implements Pusher {
+
+  crowi: any;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(crowi: any) {
+    this.crowi = crowi;
+  }
+
+  public generateAxiosConfig(tk: TransferKey, baseConfig: AxiosRequestConfig = {}): AxiosRequestConfig {
+    const { appSiteUrlOrigin, key } = tk;
+
+    return {
+      ...baseConfig,
+      baseURL: appSiteUrlOrigin,
+      headers: {
+        ...baseConfig.headers,
+        [X_GROWI_TRANSFER_KEY_HEADER_NAME]: key,
+      },
+      maxBodyLength: Infinity,
+    };
+  }
+
+  public async askGROWIInfo(tk: TransferKey): Promise<IDataGROWIInfo> {
+    try {
+      const { data: { growiInfo } } = await axios.get('/_api/v3/g2g-transfer/growi-info', this.generateAxiosConfig(tk));
+      return growiInfo;
+    }
+    catch (err) {
+      logger.error(err);
+      throw new G2GTransferError('Failed to retrieve GROWI info.', G2GTransferErrorCode.FAILED_TO_RETRIEVE_GROWI_INFO);
+    }
+  }
+
+  public async getTransferability(destGROWIInfo: IDataGROWIInfo): Promise<Transferability> {
+    const { fileUploadService, configManager } = this.crowi;
+
+    const version = this.crowi.version;
+    if (version !== destGROWIInfo.version) {
+      return {
+        canTransfer: false,
+        // TODO: i18n for reason
+        reason: `GROWI versions mismatch. src GROWI: ${version} / dest GROWI: ${destGROWIInfo.version}.`,
+      };
+    }
+
+    const activeUserCount = await this.crowi.model('User').countActiveUsers();
+    if ((destGROWIInfo.userUpperLimit ?? Infinity) < activeUserCount) {
+      return {
+        canTransfer: false,
+        // TODO: i18n for reason
+        // eslint-disable-next-line max-len
+        reason: `The number of active users (${activeUserCount} users) exceeds the limit of the destination GROWI (up to ${destGROWIInfo.userUpperLimit} users).`,
+      };
+    }
+
+    if (destGROWIInfo.fileUploadDisabled) {
+      return {
+        canTransfer: false,
+        // TODO: i18n for reason
+        reason: 'The file upload setting is disabled in the destination GROWI.',
+      };
+    }
+
+    if (configManager.getConfig('crowi', 'app:fileUploadType') === 'none') {
+      return {
+        canTransfer: false,
+        // TODO: i18n for reason
+        reason: 'File upload is not configured for src GROWI.',
+      };
+    }
+
+    if (destGROWIInfo.attachmentInfo.type === 'none') {
+      return {
+        canTransfer: false,
+        // TODO: i18n for reason
+        reason: 'File upload is not configured for dest GROWI.',
+      };
+    }
+
+    if (!destGROWIInfo.attachmentInfo.writable) {
+      return {
+        canTransfer: false,
+        // TODO: i18n for reason
+        reason: 'The storage of the destination GROWI is not writable.',
+      };
+    }
+
+    const totalFileSize = await fileUploadService.getTotalFileSize();
+    if ((destGROWIInfo.fileUploadTotalLimit ?? Infinity) < totalFileSize) {
+      return {
+        canTransfer: false,
+        // TODO: i18n for reason
+        // eslint-disable-next-line max-len
+        reason: `The total file size of attachments exceeds the file upload limit of the destination GROWI. Requires ${totalFileSize.toLocaleString()} bytes, but got ${(destGROWIInfo.fileUploadTotalLimit as number).toLocaleString()} bytes.`,
+      };
+    }
+
+    return { canTransfer: true };
+  }
+
+  public async listFilesInStorage(tk: TransferKey): Promise<FileMeta[]> {
+    try {
+      const { data: { files } } = await axios.get<{ files: FileMeta[] }>('/_api/v3/g2g-transfer/files', this.generateAxiosConfig(tk));
+      return files;
+    }
+    catch (err) {
+      logger.error(err);
+      throw new G2GTransferError('Failed to retrieve file metadata', G2GTransferErrorCode.FAILED_TO_RETRIEVE_FILE_METADATA);
+    }
+  }
+
+  public async transferAttachments(tk: TransferKey): Promise<void> {
+    const BATCH_SIZE = 100;
+    const { fileUploadService, socketIoService } = this.crowi;
+    const socket = socketIoService.getAdminSocket();
+    const Attachment = this.crowi.model('Attachment');
+    const filesFromSrcGROWI = await this.listFilesInStorage(tk);
+
+    /**
+     * Given these documents,
+     *
+     * | fileName | fileSize |
+     * | -- | -- |
+     * | a.png | 1024 |
+     * | b.png | 2048 |
+     * | c.png | 1024 |
+     * | d.png | 2048 |
+     *
+     * this filter
+     *
+     * ```jsonc
+     * {
+     *   $and: [
+     *     // a file transferred
+     *     {
+     *       $or: [
+     *         { fileName: { $ne: "a.png" } },
+     *         { fileSize: { $ne: 1024 } }
+     *       ]
+     *     },
+     *     // a file failed to transfer
+     *     {
+     *       $or: [
+     *         { fileName: { $ne: "b.png" } },
+     *         { fileSize: { $ne: 0 } }
+     *       ]
+     *     }
+     *   ]
+     * }
+     * ```
+     *
+     * results in
+     *
+     * | fileName | fileSize |
+     * | -- | -- |
+     * | b.png | 2048 |
+     * | c.png | 1024 |
+     * | d.png | 2048 |
+     */
+    const filter = filesFromSrcGROWI.length > 0 ? {
+      $and: filesFromSrcGROWI.map(({ name, size }) => ({
+        $or: [
+          { fileName: { $ne: basename(name) } },
+          { fileSize: { $ne: size } },
+        ],
+      })),
+    } : {};
+    const attachmentsCursor = await Attachment.find(filter).cursor();
+    const batchStream = createBatchStream(BATCH_SIZE);
+
+    for await (const attachmentBatch of attachmentsCursor.pipe(batchStream)) {
+      for await (const attachment of attachmentBatch) {
+        logger.debug(`processing attachment: ${attachment}`);
+        let fileStream;
+        try {
+          // get read stream of each attachment
+          fileStream = await fileUploadService.findDeliveryFile(attachment);
+        }
+        catch (err) {
+          logger.warn(`Error occured when getting Attachment(ID=${attachment.id}), skipping: `, err);
+          socket.emit('admin:g2gError', {
+            message: `Error occured when uploading Attachment(ID=${attachment.id})`,
+            key: `Error occured when uploading Attachment(ID=${attachment.id})`,
+            // TODO: emit error with params
+            // key: 'admin:g2g:error_upload_attachment',
+          });
+          continue;
+        }
+        // post each attachment file data to receiver
+        try {
+          await this.doTransferAttachment(tk, attachment, fileStream);
+        }
+        catch (err) {
+          logger.error(`Error occured when uploading attachment(ID=${attachment.id})`, err);
+          socket.emit('admin:g2gError', {
+            message: `Error occured when uploading Attachment(ID=${attachment.id})`,
+            key: `Error occured when uploading Attachment(ID=${attachment.id})`,
+            // TODO: emit error with params
+            // key: 'admin:g2g:error_upload_attachment',
+          });
+        }
+      }
+    }
+  }
+
+  // eslint-disable-next-line max-len
+  public async startTransfer(tk: TransferKey, user: any, collections: string[], optionsMap: any, destGROWIInfo: IDataGROWIInfo): Promise<void> {
+    const socket = this.crowi.socketIoService.getAdminSocket();
+
+    socket.emit('admin:g2gProgress', {
+      mongo: G2G_PROGRESS_STATUS.IN_PROGRESS,
+      attachments: G2G_PROGRESS_STATUS.PENDING,
+    });
+
+    const targetConfigKeys = UPLOAD_CONFIG_KEYS;
+
+    const uploadConfigs = Object.fromEntries(targetConfigKeys.map((key) => {
+      return [key, this.crowi.configManager.getConfig('crowi', key)];
+    }));
+
+    let zipFileStream: ReadStream;
+    try {
+      const zipFileStat = await this.crowi.exportService.export(collections);
+      const zipFilePath = zipFileStat.zipFilePath;
+
+      zipFileStream = createReadStream(zipFilePath);
+    }
+    catch (err) {
+      logger.error(err);
+      socket.emit('admin:g2gProgress', {
+        mongo: G2G_PROGRESS_STATUS.ERROR,
+        attachments: G2G_PROGRESS_STATUS.PENDING,
+      });
+      socket.emit('admin:g2gError', { message: 'Failed to generate GROWI archive file', key: 'admin:g2g:error_generate_growi_archive' });
+      throw err;
+    }
+
+    // Send a zip file to other GROWI via axios
+    try {
+      // Use FormData to immitate browser's form data object
+      const form = new FormData();
+
+      const appTitle = this.crowi.appService.getAppTitle();
+      form.append('transferDataZipFile', zipFileStream, `${appTitle}-${Date.now}.growi.zip`);
+      form.append('collections', JSON.stringify(collections));
+      form.append('optionsMap', JSON.stringify(optionsMap));
+      form.append('operatorUserId', user._id.toString());
+      form.append('uploadConfigs', JSON.stringify(uploadConfigs));
+      await rawAxios.post('/_api/v3/g2g-transfer/', form, this.generateAxiosConfig(tk, { headers: form.getHeaders() }));
+    }
+    catch (err) {
+      logger.error(err);
+      socket.emit('admin:g2gProgress', {
+        mongo: G2G_PROGRESS_STATUS.ERROR,
+        attachments: G2G_PROGRESS_STATUS.PENDING,
+      });
+      socket.emit('admin:g2gError', { message: 'Failed to send GROWI archive file to the destination GROWI', key: 'admin:g2g:error_send_growi_archive' });
+      throw err;
+    }
+
+    socket.emit('admin:g2gProgress', {
+      mongo: G2G_PROGRESS_STATUS.COMPLETED,
+      attachments: G2G_PROGRESS_STATUS.IN_PROGRESS,
+    });
+
+    try {
+      await this.transferAttachments(tk);
+    }
+    catch (err) {
+      logger.error(err);
+      socket.emit('admin:g2gProgress', {
+        mongo: G2G_PROGRESS_STATUS.COMPLETED,
+        attachments: G2G_PROGRESS_STATUS.ERROR,
+      });
+      socket.emit('admin:g2gError', { message: 'Failed to transfer attachments', key: 'admin:g2g:error_upload_attachment' });
+      throw err;
+    }
+
+    socket.emit('admin:g2gProgress', {
+      mongo: G2G_PROGRESS_STATUS.COMPLETED,
+      attachments: G2G_PROGRESS_STATUS.COMPLETED,
+    });
+  }
+
+  /**
+   * Transfer attachment to dest GROWI
+   * @param {TransferKey} tk Transfer key
+   * @param {any} attachment Attachment model instance
+   * @param {Readable} fileStream Attachment data(loaded from storage)
+   */
+  private async doTransferAttachment(tk: TransferKey, attachment: any, fileStream: Readable): Promise<void> {
+    // Use FormData to immitate browser's form data object
+    const form = new FormData();
+
+    form.append('content', fileStream, attachment.fileName);
+    form.append('attachmentMetadata', JSON.stringify(attachment));
+    await rawAxios.post('/_api/v3/g2g-transfer/attachment', form, this.generateAxiosConfig(tk, { headers: form.getHeaders() }));
+  }
+
+}
+
+/**
+ * G2g transfer receiver
+ */
+export class G2GTransferReceiverService implements Receiver {
+
+  crowi: any;
+
+  constructor(crowi: any) {
+    this.crowi = crowi;
+  }
+
+  public async validateTransferKey(key: string): Promise<void> {
+    const transferKey = await (TransferKeyModel as any).findOne({ key });
+
+    if (transferKey == null) {
+      throw new Error(`Transfer key "${key}" was expired or not found`);
+    }
+
+    try {
+      TransferKey.parse(transferKey.keyString);
+    }
+    catch (err) {
+      logger.error(err);
+      throw new Error(`Transfer key "${key}" is invalid`);
+    }
+  }
+
+  public async answerGROWIInfo(): Promise<IDataGROWIInfo> {
+    const { version, configManager, fileUploadService } = this.crowi;
+    const userUpperLimit = configManager.getConfig('crowi', 'security:userUpperLimit');
+    const fileUploadDisabled = configManager.getConfig('crowi', 'app:fileUploadDisabled');
+    const fileUploadTotalLimit = configManager.getFileUploadTotalLimit();
+    const isWritable = await fileUploadService.isWritable();
+
+    const attachmentInfo = {
+      type: configManager.getConfig('crowi', 'app:fileUploadType'),
+      writable: isWritable,
+      bucket: undefined,
+      customEndpoint: undefined, // for S3
+      uploadNamespace: undefined, // for GCS
+    };
+
+    // put storage location info to check storage identification
+    switch (attachmentInfo.type) {
+      case 'aws':
+        attachmentInfo.bucket = configManager.getConfig('crowi', 'aws:s3Bucket');
+        attachmentInfo.customEndpoint = configManager.getConfig('crowi', 'aws:s3CustomEndpoint');
+        break;
+      case 'gcs':
+        attachmentInfo.bucket = configManager.getConfig('crowi', 'gcs:bucket');
+        attachmentInfo.uploadNamespace = configManager.getConfig('crowi', 'gcs:uploadNamespace');
+        break;
+      default:
+    }
+
+    return {
+      userUpperLimit,
+      fileUploadDisabled,
+      fileUploadTotalLimit,
+      version,
+      attachmentInfo,
+    };
+  }
+
+  public async createTransferKey(appSiteUrlOrigin: string): Promise<string> {
+    const uuid = new MongooseTypes.ObjectId().toString();
+    const transferKeyString = TransferKey.generateKeyString(uuid, appSiteUrlOrigin);
+
+    // Save TransferKey document
+    let tkd;
+    try {
+      tkd = await TransferKeyModel.create({ _id: uuid, keyString: transferKeyString, key: uuid });
+    }
+    catch (err) {
+      logger.error(err);
+      throw err;
+    }
+
+    return tkd.keyString;
+  }
+
+  public getImportSettingMap(
+      innerFileStats: any[],
+      optionsMap: { [key: string]: GrowiArchiveImportOption; },
+      operatorUserId: string,
+  ): { [key: string]: ImportSettings; } {
+    const { importService } = this.crowi;
+
+    const importSettingsMap = {};
+    innerFileStats.forEach(({ fileName, collectionName }) => {
+      const options = new GrowiArchiveImportOption(null, optionsMap[collectionName]);
+
+      if (collectionName === 'configs' && options.mode !== 'flushAndInsert') {
+        throw new Error('`flushAndInsert` is only available as an import setting for configs collection');
+      }
+      if (collectionName === 'pages' && options.mode === 'insert') {
+        throw new Error('`insert` is not available as an import setting for pages collection');
+      }
+      if (collectionName === 'attachmentFiles.chunks') {
+        throw new Error('`attachmentFiles.chunks` must not be transferred. Please omit it from request body `collections`.');
+      }
+      if (collectionName === 'attachmentFiles.files') {
+        throw new Error('`attachmentFiles.files` must not be transferred. Please omit it from request body `collections`.');
+      }
+
+      const importSettings = importService.generateImportSettings(options.mode);
+      importSettings.jsonFileName = fileName;
+      importSettings.overwriteParams = generateOverwriteParams(collectionName, operatorUserId, options);
+      importSettingsMap[collectionName] = importSettings;
+    });
+
+    return importSettingsMap;
+  }
+
+  public async importCollections(
+      collections: string[],
+      importSettingsMap: { [key: string]: ImportSettings; },
+      sourceGROWIUploadConfigs: FileUploadConfigs,
+  ): Promise<void> {
+    const { configManager, importService, appService } = this.crowi;
+    /** whether to keep current file upload configs */
+    const shouldKeepUploadConfigs = configManager.getConfig('crowi', 'app:fileUploadType') !== 'none';
+
+    if (shouldKeepUploadConfigs) {
+      /** cache file upload configs */
+      const fileUploadConfigs = await this.getFileUploadConfigs();
+
+      // import mongo collections(overwrites file uplaod configs)
+      await importService.import(collections, importSettingsMap);
+
+      // restore file upload config from cache
+      await configManager.removeConfigsInTheSameNamespace('crowi', UPLOAD_CONFIG_KEYS);
+      await configManager.updateConfigsInTheSameNamespace('crowi', fileUploadConfigs);
+    }
+    else {
+      // import mongo collections(overwrites file uplaod configs)
+      await importService.import(collections, importSettingsMap);
+
+      // update file upload config
+      await configManager.updateConfigsInTheSameNamespace('crowi', sourceGROWIUploadConfigs);
+    }
+
+    await this.crowi.setUpFileUpload(true);
+    await appService.setupAfterInstall();
+  }
+
+  public async getFileUploadConfigs(): Promise<FileUploadConfigs> {
+    const { configManager } = this.crowi;
+    const fileUploadConfigs = Object.fromEntries(UPLOAD_CONFIG_KEYS.map((key) => {
+      return [key, configManager.getConfigFromDB('crowi', key)];
+    })) as FileUploadConfigs;
+
+    return fileUploadConfigs;
+  }
+
+  public async updateFileUploadConfigs(fileUploadConfigs: FileUploadConfigs): Promise<void> {
+    const { appService, configManager } = this.crowi;
+
+    await configManager.removeConfigsInTheSameNamespace('crowi', Object.keys(fileUploadConfigs));
+    await configManager.updateConfigsInTheSameNamespace('crowi', fileUploadConfigs);
+    await this.crowi.setUpFileUpload(true);
+    await appService.setupAfterInstall();
+  }
+
+  public async receiveAttachment(content: Readable, attachmentMap): Promise<void> {
+    const { fileUploadService } = this.crowi;
+    return fileUploadService.uploadAttachment(content, attachmentMap);
+  }
+
+}

+ 1 - 0
packages/app/src/server/service/growi-bridge.js

@@ -120,6 +120,7 @@ class GrowiBridgeService {
     return {
       meta,
       fileName: path.basename(zipFile),
+      zipFilePath: zipFile,
       fileStat,
       innerFileStats,
     };

+ 11 - 13
packages/app/src/server/service/import.js

@@ -24,7 +24,7 @@ const logger = loggerFactory('growi:services:ImportService'); // eslint-disable-
 const BULK_IMPORT_SIZE = 100;
 
 
-class ImportSettings {
+export class ImportSettings {
 
   constructor(mode) {
     this.mode = mode || 'insert';
@@ -183,13 +183,6 @@ class ImportService {
     // init status object
     this.currentProgressingStatus = new CollectionProgressingStatus(collections);
 
-    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-    const isImportPagesCollection = collections.includes('pages');
-    const shouldNormalizePages = isV5Compatible && isImportPagesCollection;
-
-    // set isV5Compatible to false
-    if (shouldNormalizePages) await this.crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': false });
-
     // process serially so as not to waste memory
     const promises = collections.map((collectionName) => {
       const importSettings = importSettingsMap[collectionName];
@@ -207,11 +200,16 @@ class ImportService {
       }
     }
 
-    // run normalizeAllPublicPages
-    if (shouldNormalizePages) await this.crowi.pageService.normalizeAllPublicPages();
-
     this.currentProgressingStatus = null;
     this.emitTerminateEvent();
+
+    await this.crowi.configManager.loadConfigs();
+
+    const currentIsV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    const isImportPagesCollection = collections.includes('pages');
+    const shouldNormalizePages = currentIsV5Compatible && isImportPagesCollection;
+
+    if (shouldNormalizePages) await this.crowi.pageService.normalizeAllPublicPages();
   }
 
   /**
@@ -509,14 +507,14 @@ class ImportService {
   /**
    * validate using meta.json
    * to pass validation, all the criteria must be met
-   *   - ${version of this growi} === ${version of growi that exported data}
+   *   - ${version of this GROWI} === ${version of GROWI that exported data}
    *
    * @memberOf ImportService
    * @param {object} meta meta data from meta.json
    */
   validate(meta) {
     if (meta.version !== this.crowi.version) {
-      throw new Error('the version of this growi and the growi that exported the data are not met');
+      throw new Error('The version of this GROWI and the uploaded GROWI data are not the same');
     }
 
     // TODO: check if all migrations are completed

+ 2 - 2
packages/app/src/server/util/createGrowiPagesFromImports.js

@@ -36,10 +36,10 @@ module.exports = (crowi) => {
       }
       else {
         if (!isCreatableName) {
-          errors.push(new Error(`${path} is not a creatable name in Growi`));
+          errors.push(new Error(`${path} is not a creatable name in GROWI`));
         }
         if (isPageNameTaken) {
-          errors.push(new Error(`${path} already exists in Growi`));
+          errors.push(new Error(`${path} already exists in GROWI`));
         }
       }
     }

+ 27 - 3
packages/app/src/services/renderer/renderer.tsx

@@ -29,6 +29,7 @@ import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 import { Table } from '~/components/ReactMarkdownComponents/Table';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
+import { RehypeSanitizeOption } from '~/interfaces/rehype';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import { registerGrowiFacade } from '~/utils/growi-facade';
 import loggerFactory from '~/utils/logger';
@@ -66,16 +67,21 @@ export type RendererOptions = Omit<ReactMarkdownOptions, 'remarkPlugins' | 'rehy
     | undefined
 };
 
+const commonSanitizeAttributes = { '*': ['class', 'className', 'style'] };
+
 const commonSanitizeOption: SanitizeOption = deepmerge(
   sanitizeDefaultSchema,
   {
     clobberPrefix: 'mdcont-',
-    attributes: {
-      '*': ['class', 'className', 'style'],
-    },
+    attributes: commonSanitizeAttributes,
   },
 );
 
+const injectCustomSanitizeOption = (config: RendererConfig) => {
+  commonSanitizeOption.tagNames = config.tagWhiteList;
+  commonSanitizeOption.attributes = deepmerge(commonSanitizeAttributes, config.attrWhiteList ?? {});
+};
+
 const isSanitizePlugin = (pluggable: Pluggable): pluggable is SanitizePlugin => {
   if (!Array.isArray(pluggable) || pluggable.length < 2) {
     return false;
@@ -148,6 +154,10 @@ export const generateViewOptions = (
     remarkPlugins.push(breaks);
   }
 
+  if (config.xssOption === RehypeSanitizeOption.CUSTOM) {
+    injectCustomSanitizeOption(config);
+  }
+
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       commonSanitizeOption,
@@ -190,6 +200,11 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   // add remark plugins
   // remarkPlugins.push();
 
+  if (config.xssOption === RehypeSanitizeOption.CUSTOM) {
+    injectCustomSanitizeOption(config);
+  }
+
+
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       commonSanitizeOption,
@@ -234,6 +249,11 @@ export const generateSimpleViewOptions = (
     remarkPlugins.push(breaks);
   }
 
+  if (config.xssOption === RehypeSanitizeOption.CUSTOM) {
+    injectCustomSanitizeOption(config);
+  }
+
+
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       commonSanitizeOption,
@@ -281,6 +301,10 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     remarkPlugins.push(breaks);
   }
 
+  if (config.xssOption === RehypeSanitizeOption.CUSTOM) {
+    injectCustomSanitizeOption(config);
+  }
+
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       commonSanitizeOption,

+ 9 - 2
packages/app/src/services/xss/xssOption.ts

@@ -1,7 +1,14 @@
+import { defaultSchema as sanitizeDefaultSchema } from 'rehype-sanitize';
+import type { RehypeSanitizeOption } from '~/interfaces/rehype';
+
+type tagWhiteList = typeof sanitizeDefaultSchema.tagNames;
+type attrWhiteList = typeof sanitizeDefaultSchema.attributes;
+
 export type XssOptionConfig = {
   isEnabledXssPrevention: boolean,
-  tagWhiteList: any[],
-  attrWhiteList: any[],
+  xssOption: RehypeSanitizeOption,
+  tagWhiteList: tagWhiteList,
+  attrWhiteList: attrWhiteList,
 }
 
 export default class XssOption {

+ 14 - 13
packages/app/src/stores/page.tsx

@@ -32,19 +32,20 @@ export const useSWRxPage = (
   const swrResponse = useSWRImmutable<IPagePopulatedToShowRevision|null, Error>(
     pageId != null ? ['/page', pageId, shareLinkId, revisionId] : null,
     // TODO: upgrade SWR to v2 and use useSWRMutation
-    //        in order to trigger mutation manually
-    (endpoint, pageId, shareLinkId, revisionId) => apiv3Get<{ page: IPagePopulatedToShowRevision }>(endpoint, { pageId, shareLinkId, revisionId })
-      .then(result => result.data.page)
-      .catch((errs) => {
-        if (!Array.isArray(errs)) { throw Error('error is not array') }
-        const statusCode = errs[0].status;
-        if (statusCode === 403 || statusCode === 404) {
-          // for NotFoundPage
-          return null;
-        }
-        throw Error('failed to get page');
-      }),
-    config,
+    //        in order to avoid complicated fetcher settings
+    Object.assign({
+      fetcher: (endpoint, pageId, shareLinkId, revisionId) => apiv3Get<{ page: IPagePopulatedToShowRevision }>(endpoint, { pageId, shareLinkId, revisionId })
+        .then(result => result.data.page)
+        .catch((errs) => {
+          if (!Array.isArray(errs)) { throw Error('error is not array') }
+          const statusCode = errs[0].status;
+          if (statusCode === 403 || statusCode === 404) {
+            // for NotFoundPage
+            return null;
+          }
+          throw Error('failed to get page');
+        }),
+    }, config ?? {}),
   );
 
   useEffect(() => {

+ 15 - 0
packages/app/src/styles/_installer.scss

@@ -0,0 +1,15 @@
+#installer-form-container > .grw-custom-nav-tab {
+  .nav-title {
+    width: 100%;
+    li {
+      width: 100%;
+      text-align: center;
+    }
+  }
+  .nav-link {
+    color: white;
+  }
+  .grw-nav-slide-hr {
+    border-color: white;
+  }
+}

+ 1 - 0
packages/app/src/styles/style-app.scss

@@ -55,6 +55,7 @@
 @import 'page-path';
 @import 'search';
 @import 'tag';
+@import 'installer';
 // @import 'user';
 
 

+ 22 - 6
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -13,12 +13,11 @@
   $color-list-active: var(--color-list-active,var(--color-reversal));
   $bgcolor-list-hover: var(--bgcolor-list-hover,var(--bgcolor-global));
   $bgcolor-list-active: var(--bgcolor-list-active,var(--primary));
-  $bgcolor-subnav: var(--bgcolor-subnav);
   $color-table: var(--color-table,white);
   $bgcolor-table: var(--bgcolor-table,#343a40);
-  $border-color-table: var(--border-color-table,hsl.lighten(var(--bgcolor-table),7.5%),lighten(#343a40, 7.5%));
+  $border-color-table: var(--border-color-table,lighten(#343a40, 7.5%));
   $color-table-hover: var(--color-table-hover,rgba(white, 0.075));
-  $bgcolor-table-hover: var(--bgcolor-table-hover,hsl.lighten(var(--bgcolor-table),7.5%),lighten(#343a40, 7.5%));
+  $bgcolor-table-hover: var(--bgcolor-table-hover,lighten(#343a40, 7.5%));
   $bgcolor-sidebar-list-group: var(--bgcolor-sidebar-list-group,var(--bgcolor-list));
   $color-tags: var(--color-tags,#949494);
   $bgcolor-tags: var(--bgcolor-tags,var(--dark));
@@ -28,7 +27,7 @@
   $bgcolor-dropdown: var(--bgcolor-dropdown,var(--bgcolor-global));
   $color-dropdown-link: var(--color-dropdown-link,var(--color-global));
   $color-dropdown-link-hover: var(--color-dropdown-link-hover,var(--light));
-  $bgcolor-dropdown-link-hover: var(--bgcolor-dropdown-link-hover,var(--primary));
+  $bgcolor-dropdown-link-hover: var(--bgcolor-dropdown-link-hover,hsl.lighten(var(--bgcolor-global), 15%));
   $color-dropdown-link-active: var(--color-dropdown-link-active,var(--light));
   $bgcolor-dropdown-link-active: var(--bgcolor-dropdown-link-active,var(--primary));
 
@@ -106,17 +105,24 @@
     background-color: hsl.darken(var(--bgcolor-global),5%);
   }
 
+  .rbt-input-multi .rbt-input-main {
+    color: black;
+  }
   /*
   * Table
   */
   .table {
     @extend .table-dark;
+    thead th {
+      vertical-align: bottom;
+      border-bottom: 2px solid #d6dadf;
+    }
   }
 
   /*
   * Card
   */
-  .card:not([class*=‘bg-’]):not(.well):not(.card-disabled) {
+  .card:not([class*='bg-']):not(.well):not(.card-disabled) {
     @extend .bg-dark;
   }
 
@@ -184,6 +190,12 @@
 
     .nologin-dialog {
       background-color: rgba(black, 0.5);
+      .link-switch {
+        color: #7b9bd5;
+        @include hover() {
+          color: lighten(#7b9bd5,10%);
+        }
+      }
     }
 
     .input-group {
@@ -247,7 +259,6 @@
     @include mixins-buttons.button-svg-icon-variant($dark, $dark);
     color: $gray-400;
     box-shadow: none !important;
-
   }
 
   /*
@@ -522,6 +533,10 @@
     }
   }
 
+  mark.rbt-highlight-text {
+    color: var(--color-global);
+  }
+
   /*
   * GROWI popular tags
   */
@@ -574,5 +589,6 @@
   * skeleton
   */
   .grw-skeleton {
+    background-color: hsl.lighten(var(--bgcolor-subnav),10%);
   }
 }

+ 8 - 2
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -84,7 +84,7 @@
   * card
   */
   .card.card-disabled {
-    background-color: var(--background-color);
+    background-color: var(--bgcolor-global);
     border-color: $gray-200;
   }
 
@@ -113,6 +113,12 @@
 
     .nologin-dialog {
       background-color: rgba(white, 0.5);
+      .link-switch {
+        color: #1939b8;
+        @include hover() {
+          color: lighten(#1939b8,20%);
+        }
+      }
     }
 
     .dropdown-with-icon {
@@ -459,6 +465,6 @@
   * skeleton
   */
   .grw-skeleton {
-    background-color: hsl.lighten(var(--bgcolor-navbar),10%);
+    background-color: hsl.darken(var(--bgcolor-subnav),10%);
   }
 }

+ 10 - 8
packages/app/src/styles/theme/_apply-colors.scss

@@ -187,6 +187,9 @@ ul.pagination {
   .search-typeahead {
     background-color: hsl.alpha(var(--bgcolor-global),10%);
   }
+  input.form-control {
+    border: none;
+  }
 }
 
 .grw-sidebar {
@@ -375,14 +378,6 @@ ul.pagination {
   }
 }
 
-.grw-page-accessories-modal {
-  .modal-header {
-    .close {
-      color: var(--secondary);
-    }
-  }
-}
-
 /*
  * cards
  */
@@ -693,3 +688,10 @@ Emoji picker modal
 .emoji-picker-modal {
   background-color: transparent !important;
 }
+
+/*
+* revision-history-diff
+*/
+.revision-history-diff {
+  background-color: white;
+}

+ 0 - 6
packages/app/src/styles/theme/_hsl-reboot-bootstrap-theme-colors.scss

@@ -12,12 +12,6 @@ $hsl-colors: (
 @each $color, $value in $hsl-colors {
   .bg-#{$color} {
     background-color: $value !important;
-    a,
-    button {
-      @include hover-focus() {
-        background-color: hsl.darken($value, 10%) !important;
-      }
-    }
   }
 }
 

+ 6 - 22
packages/app/src/utils/axios.ts

@@ -1,7 +1,7 @@
 // eslint-disable-next-line no-restricted-imports
 import axios from 'axios';
-import parseISO from 'date-fns/parseISO';
-import isIsoDate from 'is-iso-date';
+import dayjs from 'dayjs';
+import qs from 'qs';
 
 const customAxios = axios.create({
   headers: {
@@ -10,26 +10,10 @@ const customAxios = axios.create({
   },
 });
 
-// add an interceptor to convert ISODate
-const convertDates = (body: any): void => {
-  if (body === null || body === undefined || typeof body !== 'object') {
-    return body;
-  }
-
-  for (const key of Object.keys(body)) {
-    const value = body[key];
-    if (isIsoDate(value)) {
-      body[key] = parseISO(value);
-    }
-    else if (typeof value === 'object') {
-      // eslint-disable-next-line @typescript-eslint/no-unused-vars
-      convertDates(value);
-    }
-  }
-};
-customAxios.interceptors.response.use((response) => {
-  convertDates(response.data);
-  return response;
+// serialize Date config: https://github.com/axios/axios/issues/1548#issuecomment-548306666
+customAxios.interceptors.request.use((config) => {
+  config.paramsSerializer = params => qs.stringify(params, { serializeDate: (date: Date) => dayjs(date).format('YYYY-MM-DDTHH:mm:ssZ') });
+  return config;
 });
 
 export default customAxios;

+ 58 - 0
packages/app/src/utils/vo/transfer-key.ts

@@ -0,0 +1,58 @@
+/**
+ * VO for TransferKey which has appSiteUrlOrigin and key as its public member
+ */
+export class TransferKey {
+
+  private static _internalSeperator = '__grw_internal_tranferkey__';
+
+  public appSiteUrlOrigin: string;
+
+  public key: string;
+
+  constructor(appSiteUrlOrigin: string, key: string) {
+    this.appSiteUrlOrigin = appSiteUrlOrigin;
+    this.key = key;
+  }
+
+  get getKeyString(): string {
+    return TransferKey.generateKeyString(this.key, this.appSiteUrlOrigin);
+  }
+
+  /**
+   * Parse a transfer key string generated by the generateKeyString static method
+   * @param {string} keyString Transfer key string
+   * @returns {TransferKey}
+   */
+  static parse(keyString: string): TransferKey {
+    const generalErrorPhrase = 'Failed to parse TransferKey from string';
+
+    const splitted = keyString.split(TransferKey._internalSeperator);
+
+    if (splitted.length !== 2) {
+      throw Error(generalErrorPhrase);
+    }
+    const key = splitted[0];
+    const appSiteUrl = splitted[1];
+
+    let appSiteUrlOrigin: string;
+    try {
+      appSiteUrlOrigin = new URL(appSiteUrl).origin;
+    }
+    catch (e) {
+      throw Error(generalErrorPhrase + (e as Error));
+    }
+
+    return new TransferKey(appSiteUrlOrigin, key);
+  }
+
+  /**
+   * Generates transfer key string (e.g. https://example.com:8080__grw_internal_tranferkey__key)
+   * @param {string} key Key generated by GROWI
+   * @param {string} appSiteUrlOrigin GROWI app site URL origin
+   * @returns {string} Transfer key string
+   */
+  static generateKeyString(key: string, appSiteUrlOrigin: string): string {
+    return `${key}${TransferKey._internalSeperator}${appSiteUrlOrigin}`;
+  }
+
+}

+ 1 - 1
packages/core/src/models/vo/error-apiv3.ts

@@ -4,7 +4,7 @@ export class ErrorV3 extends Error {
 
   args?: any;
 
-  constructor(message = '', code = '', stack = undefined, args = undefined) {
+  constructor(message = '', code = '', stack = undefined, args: any = undefined) {
     super(); // do not provide message to the super constructor
     this.message = message;
     this.code = code;

+ 2 - 1
packages/preset-themes/package.json

@@ -9,9 +9,10 @@
   ],
   "scripts": {
     "build": "yarn build:libs & yarn build:themes",
-    "build:w": "yarn build:libs -w & yarn build:themes -w",
     "build:libs": "vite -c vite.libs.config.ts build",
     "build:themes": "vite -c vite.themes.config.ts build",
+    "dev": "yarn build:libs --mode dev -w & yarn build:themes --mode dev -w",
+    "dev:nowatch": "yarn build:libs --mode dev & yarn build:themes --mode dev",
     "lint:eslint": "eslint --quiet \"**/*.{js,jsx,ts,tsx}\"",
     "lint:styles": "stylelint src/**/*.scss",
     "lint": "run-p lint:*",

+ 0 - 162
packages/preset-themes/src/styles/_mixins.scss

@@ -1,162 +0,0 @@
-@use './bootstrap/init' as bs;
-
-@mixin variable-font-size($basesize) {
-  font-size: $basesize * 0.6;
-
-  @include bs.media-breakpoint-only(sm) {
-    font-size: #{$basesize * 0.7};
-  }
-  @include bs.media-breakpoint-only(md) {
-    font-size: #{$basesize * 0.8};
-  }
-  @include bs.media-breakpoint-only(lg) {
-    font-size: #{$basesize * 0.9};
-  }
-  @include bs.media-breakpoint-up(xl) {
-    font-size: $basesize;
-  }
-}
-
-@mixin expand-editor($editor-margin-top) {
-  $header-plus-footer: $editor-margin-top + $grw-editor-navbar-bottom-height;
-
-  $editor-margin: $header-plus-footer //
-    + 25px //   add .btn-open-dropzone height
-    + 30px; //  add .navbar-editor height
-
-  .main {
-    width: 100%;
-    height: calc(100vh - #{$editor-margin-top});
-    margin-top: 0px !important;
-
-    .grw-container-convertible {
-      max-width: unset;
-      padding: 0;
-      margin: 0;
-    }
-
-    &,
-    .content-main,
-    .tab-content {
-      display: flex;
-      flex: 1;
-      flex-direction: column;
-
-      .tab-pane {
-        height: calc(100vh - #{$header-plus-footer});
-        min-height: calc(100vh - #{$header-plus-footer}); // for IE11
-      }
-
-      #page-editor {
-        // right(preview)
-        &,
-        & > .row,
-        .page-editor-preview-container,
-        .page-editor-preview-body {
-          height: calc(100vh - #{$header-plus-footer});
-          min-height: calc(100vh - #{$header-plus-footer}); // for IE11
-        }
-
-        // left(editor)
-        .page-editor-editor-container {
-          height: calc(100vh - #{$header-plus-footer});
-          min-height: calc(100vh - #{$header-plus-footer}); // for IE11
-
-          .react-codemirror2,
-          .CodeMirror,
-          .CodeMirror-scroll,
-          .textarea-editor {
-            height: calc(100vh - #{$editor-margin});
-          }
-        }
-      }
-
-      #page-editor-with-hackmd {
-        &,
-        .hackmd-preinit,
-        .hackmd-error,
-        #iframe-hackmd-container > iframe {
-          width: 100%;
-          height: calc(100vh - #{$header-plus-footer});
-          min-height: calc(100vh - #{$header-plus-footer}); // for IE11
-        }
-      }
-    }
-  }
-}
-
-@mixin apply-navigation-transition() {
-  transition-timing-function: cubic-bezier(0.25, 1, 0.5, 1);
-  transition-duration: 300ms;
-}
-
-@mixin border-vertical($beforeOrAfter, $borderLength, $zIndex: initial, $isBtnGroup: false) {
-  position: relative;
-  @if $isBtnGroup {
-    &:not(:first-child) {
-      margin-left: 0;
-      border-left: none;
-    }
-    &:not(:last-child) {
-      border-right: none;
-    }
-  }
-  &:not(:first-child) {
-    &::#{$beforeOrAfter} {
-      position: absolute;
-      top: calc((100% - #{$borderLength}) / 2);
-      left: 0;
-      z-index: $zIndex;
-      width: 100%;
-      height: $borderLength;
-      margin-left: -0.5px;
-      content: '';
-      border-left: 1px solid transparent;
-      transition: border-color 0.15s ease-in-out;
-    }
-  }
-}
-
-@keyframes fadeout {
-  100% {
-    opacity: 0;
-  }
-}
-
-@mixin blink-bgcolor($bgcolor) {
-  position: relative;
-  z-index: 1;
-
-  &::after {
-    position: absolute;
-    top: 15%;
-    left: 0;
-    z-index: -1;
-    width: 100%;
-    height: 70%;
-    content: '';
-    background-color: $bgcolor;
-    border-radius: 2px;
-    animation: fadeout 1s ease-in 1.5s forwards;
-  }
-}
-
-@mixin overlay-processing-style($additionalSelector, $contentFontSize: inherit, $contentPadding: inherit) {
-  .overlay.#{$additionalSelector} {
-    background: rgba(255, 255, 255, 0.5);
-    .overlay-content {
-      padding: $contentPadding;
-      font-size: $contentFontSize;
-      color: bs.$gray-700;
-      background: rgba(200, 200, 200, 0.5);
-    }
-  }
-}
-
-@mixin insertSimpleLineIcons($code) {
-  &:before {
-    margin-right: 0.2em;
-    font-family: 'simple-line-icons';
-    content: $code;
-  }
-}

+ 1 - 2
packages/preset-themes/src/styles/antarctic.scss

@@ -161,8 +161,7 @@
 
   // login and register
   .nologin {
-    a#login.link-switch,
-    a#register.link-switch {
+    .nologin-dialog a.link-switch {
       color: rgba(black, 0.5);
     }
 

+ 3 - 3
packages/preset-themes/src/styles/christmas.scss

@@ -160,9 +160,9 @@
     .nologin-header,
     .nologin-dialog {
       background-color: rgba(#ccc, 0.5);
-    }
-    .link-switch {
-      color: #bd3425;
+      a.link-switch {
+        color: #bd3425;
+      }
     }
 
     .grw-external-auth-form {

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