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

Merge branch 'master' into imprv/ssr

Yuki Takei 3 лет назад
Родитель
Сommit
a4650c1d20
66 измененных файлов с 3053 добавлено и 934 удалено
  1. 1 0
      packages/app/config/logger/config.dev.js
  2. 1 0
      packages/app/package.json
  3. 12 1
      packages/app/public/static/locales/en_US/admin.json
  4. 10 0
      packages/app/public/static/locales/en_US/commons.json
  5. 1 0
      packages/app/public/static/locales/en_US/translation.json
  6. 11 0
      packages/app/public/static/locales/ja_JP/admin.json
  7. 10 0
      packages/app/public/static/locales/ja_JP/commons.json
  8. 1 0
      packages/app/public/static/locales/ja_JP/translation.json
  9. 12 1
      packages/app/public/static/locales/zh_CN/admin.json
  10. 10 0
      packages/app/public/static/locales/zh_CN/commons.json
  11. 2 0
      packages/app/public/static/locales/zh_CN/translation.json
  12. 15 0
      packages/app/src/client/services/g2g-transfer.ts
  13. 31 38
      packages/app/src/components/Admin/App/AwsSetting.tsx
  14. 158 29
      packages/app/src/components/Admin/App/FileUploadSetting.tsx
  15. 37 34
      packages/app/src/components/Admin/App/GcsSetting.tsx
  16. 4 1
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  17. 0 250
      packages/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.jsx
  18. 232 0
      packages/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx
  19. 0 261
      packages/app/src/components/Admin/ExportArchiveDataPage.jsx
  20. 198 0
      packages/app/src/components/Admin/ExportArchiveDataPage.tsx
  21. 284 0
      packages/app/src/components/Admin/G2GDataTransfer.tsx
  22. 237 0
      packages/app/src/components/Admin/G2GDataTransferExportForm.tsx
  23. 43 0
      packages/app/src/components/Admin/G2GDataTransferStatusIcon.tsx
  24. 4 2
      packages/app/src/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx
  25. 1 25
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  26. 38 0
      packages/app/src/components/Common/CustomCopyToClipBoard.tsx
  27. 42 0
      packages/app/src/components/DataTransferForm.tsx
  28. 23 0
      packages/app/src/interfaces/g2g-transfer.ts
  29. 6 0
      packages/app/src/interfaces/transfer-key.ts
  30. 54 0
      packages/app/src/pages/admin/data-transfer.page.tsx
  31. 25 4
      packages/app/src/pages/installer.page.tsx
  32. 13 0
      packages/app/src/server/crowi/index.js
  33. 29 0
      packages/app/src/server/models/transfer-key.ts
  34. 5 1
      packages/app/src/server/models/user.js
  35. 34 0
      packages/app/src/server/models/vo/g2g-transfer-error.ts
  36. 332 0
      packages/app/src/server/routes/apiv3/g2g-transfer.ts
  37. 16 12
      packages/app/src/server/routes/apiv3/import.js
  38. 4 1
      packages/app/src/server/routes/apiv3/index.js
  39. 3 3
      packages/app/src/server/routes/apiv3/overwrite-params/attachmentFiles.chunks.js
  40. 8 15
      packages/app/src/server/routes/apiv3/overwrite-params/pages.js
  41. 4 4
      packages/app/src/server/routes/apiv3/overwrite-params/revisions.js
  42. 1 1
      packages/app/src/server/service/attachment.js
  43. 36 2
      packages/app/src/server/service/config-manager.ts
  44. 6 2
      packages/app/src/server/service/export.js
  45. 79 11
      packages/app/src/server/service/file-uploader/aws.ts
  46. 36 12
      packages/app/src/server/service/file-uploader/gcs.js
  47. 34 9
      packages/app/src/server/service/file-uploader/gridfs.js
  48. 55 9
      packages/app/src/server/service/file-uploader/local.js
  49. 7 1
      packages/app/src/server/service/file-uploader/none.js
  50. 48 9
      packages/app/src/server/service/file-uploader/uploader.js
  51. 676 0
      packages/app/src/server/service/g2g-transfer.ts
  52. 1 0
      packages/app/src/server/service/growi-bridge.js
  53. 11 13
      packages/app/src/server/service/import.js
  54. 2 2
      packages/app/src/server/util/createGrowiPagesFromImports.js
  55. 15 0
      packages/app/src/styles/_installer.scss
  56. 1 0
      packages/app/src/styles/style-app.scss
  57. 6 0
      packages/app/src/styles/theme/_apply-colors-dark.scss
  58. 6 0
      packages/app/src/styles/theme/_apply-colors-light.scss
  59. 58 0
      packages/app/src/utils/vo/transfer-key.ts
  60. 0 162
      packages/preset-themes/src/styles/_mixins.scss
  61. 1 2
      packages/preset-themes/src/styles/antarctic.scss
  62. 3 3
      packages/preset-themes/src/styles/christmas.scss
  63. 4 5
      packages/preset-themes/src/styles/hufflepuff.scss
  64. 3 4
      packages/preset-themes/src/styles/spring.scss
  65. 3 4
      packages/preset-themes/src/styles/wood.scss
  66. 10 1
      yarn.lock

+ 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

+ 1 - 0
packages/app/package.json

@@ -112,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,

+ 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;

+ 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,
+}

+ 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>
   );

+ 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;

+ 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;
-};
+}

+ 4 - 1
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);

+ 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);

+ 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) {

+ 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`));
         }
       }
     }

+ 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';
 
 

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

@@ -190,6 +190,12 @@
 
     .nologin-dialog {
       background-color: rgba(black, 0.5);
+      .link-switch {
+        color: #7b9bd5;
+        @include hover() {
+          color: lighten(#7b9bd5,10%);
+        }
+      }
     }
 
     .input-group {

+ 6 - 0
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -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 {

+ 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}`;
+  }
+
+}

+ 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 {

+ 4 - 5
packages/preset-themes/src/styles/hufflepuff.scss

@@ -154,10 +154,9 @@
     .nologin-header,
     .nologin-dialog {
       background-color: rgba(black, 0.1);
-    }
-
-    .link-switch {
-      color: #{hsl.darken(var(--color-global),10%)};
+      a.link-switch  {
+        color: #{hsl.darken(var(--color-global),10%)};
+      }
     }
 
     .grw-external-auth-form {
@@ -353,7 +352,7 @@
     }
 
     .link-switch {
-      color: var(--color-global);
+      color: var(--color-global)!important;
     }
 
     .grw-external-auth-form {

+ 3 - 4
packages/preset-themes/src/styles/spring.scss

@@ -145,10 +145,9 @@
     .nologin-header,
     .nologin-dialog {
       background-color: rgba(black, 0.1);
-    }
-
-    .link-switch {
-      color: var(--color-global);
+      a.link-switch {
+        color: var(--color-global);
+      }
     }
 
     .grw-external-auth-form {

+ 3 - 4
packages/preset-themes/src/styles/wood.scss

@@ -175,10 +175,9 @@
     .nologin-header,
     .nologin-dialog {
       background-color: rgba(black, 0.1);
-    }
-
-    .link-switch {
-      color: rgba(black, 0.5);
+      a.link-switch {
+        color: rgba(black, 0.5);
+      }
     }
 
     .grw-external-auth-form {

+ 10 - 1
yarn.lock

@@ -6914,7 +6914,7 @@ columnify@^1.5.4:
     strip-ansi "^3.0.0"
     wcwidth "^1.0.0"
 
-combined-stream@^1.0.6, combined-stream@~1.0.6:
+combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6:
   version "1.0.8"
   resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
   integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
@@ -10274,6 +10274,15 @@ form-data@^2.5.0:
     combined-stream "^1.0.6"
     mime-types "^2.1.12"
 
+form-data@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
+  integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
+  dependencies:
+    asynckit "^0.4.0"
+    combined-stream "^1.0.8"
+    mime-types "^2.1.12"
+
 form-data@~2.3.2:
   version "2.3.3"
   resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"

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