فهرست منبع

Merge branch 'master' into feat/add-validation-for-users

itizawa 5 سال پیش
والد
کامیت
b115780e83
74فایلهای تغییر یافته به همراه2102 افزوده شده و 418 حذف شده
  1. 67 0
      .github/workflows/codeql-analysis.yml
  2. 9 1
      CHANGES.md
  3. 6 1
      package.json
  4. 5 0
      resource/locales/en_US/admin/admin.json
  5. 15 2
      resource/locales/en_US/translation.json
  6. 5 0
      resource/locales/ja_JP/admin/admin.json
  7. 15 2
      resource/locales/ja_JP/translation.json
  8. 5 0
      resource/locales/zh_CN/admin/admin.json
  9. 19 6
      resource/locales/zh_CN/translation.json
  10. 60 12
      src/client/js/components/Admin/App/AwsSetting.jsx
  11. 47 1
      src/client/js/components/Admin/App/GcsSettings.jsx
  12. 60 0
      src/client/js/components/ComparePathsTable.jsx
  13. 59 0
      src/client/js/components/DuplicatedPathsTable.jsx
  14. 7 8
      src/client/js/components/EmptyTrashModal.jsx
  15. 0 1
      src/client/js/components/Navbar/GrowiNavbar.jsx
  16. 0 2
      src/client/js/components/Page/PageManagement.jsx
  17. 9 6
      src/client/js/components/Page/RevisionLoader.jsx
  18. 7 5
      src/client/js/components/Page/TrashPageAlert.jsx
  19. 9 6
      src/client/js/components/PageAccessoriesModalControl.jsx
  20. 6 8
      src/client/js/components/PageDeleteModal.jsx
  21. 119 22
      src/client/js/components/PageDuplicateModal.jsx
  22. 2 6
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  23. 99 102
      src/client/js/components/PageEditor/LinkEditModal.jsx
  24. 30 0
      src/client/js/components/PageEditor/PreviewWithSuspense.jsx
  25. 1 1
      src/client/js/components/PageList.jsx
  26. 23 0
      src/client/js/components/PageManagement/ApiErrorMessageList.jsx
  27. 96 15
      src/client/js/components/PageRenameModal.jsx
  28. 2 2
      src/client/js/components/PageTimeline.jsx
  29. 5 8
      src/client/js/components/PutbackPageModal.jsx
  30. 32 10
      src/client/js/components/SearchTypeahead.jsx
  31. 7 6
      src/client/js/components/SlackNotification.jsx
  32. 21 0
      src/client/js/services/AdminAppContainer.js
  33. 14 16
      src/client/js/services/PageContainer.js
  34. 1 0
      src/client/styles/scss/_linkedit-preview.scss
  35. 9 0
      src/client/styles/scss/_page-duplicate-modal.scss
  36. 8 6
      src/client/styles/scss/_search.scss
  37. 5 0
      src/client/styles/scss/molecules/compare-paths-table.scss
  38. 5 0
      src/client/styles/scss/molecules/duplicated-paths-table.scss
  39. 3 0
      src/client/styles/scss/style-app.scss
  40. 4 6
      src/client/styles/scss/theme/_apply-colors-dark.scss
  41. 6 6
      src/client/styles/scss/theme/_apply-colors-light.scss
  42. 0 4
      src/client/styles/scss/theme/_apply-colors.scss
  43. 19 0
      src/lib/util/path-utils.js
  44. 15 0
      src/lib/util/to-array-from-csv.js
  45. 17 1
      src/server/crowi/index.js
  46. 32 0
      src/server/middlewares/auto-reconnect-to-search.js
  47. 24 0
      src/server/models/attachment.js
  48. 25 2
      src/server/models/page.js
  49. 1 1
      src/server/routes/admin.js
  50. 21 5
      src/server/routes/apiv3/app-settings.js
  51. 1 1
      src/server/routes/apiv3/export.js
  52. 56 26
      src/server/routes/apiv3/healthcheck.js
  53. 2 2
      src/server/routes/apiv3/notification-setting.js
  54. 62 0
      src/server/routes/apiv3/page.js
  55. 500 18
      src/server/routes/apiv3/pages.js
  56. 2 2
      src/server/routes/apiv3/response.js
  57. 1 1
      src/server/routes/apiv3/search.js
  58. 1 1
      src/server/routes/apiv3/share-links.js
  59. 1 1
      src/server/routes/apiv3/user-group.js
  60. 1 1
      src/server/routes/apiv3/users.js
  61. 3 4
      src/server/routes/index.js
  62. 17 51
      src/server/routes/page.js
  63. 24 0
      src/server/service/config-loader.js
  64. 37 0
      src/server/service/file-uploader/aws.js
  65. 37 0
      src/server/service/file-uploader/gcs.js
  66. 1 1
      src/server/service/import.js
  67. 56 0
      src/server/service/page.js
  68. 80 0
      src/server/service/search-reconnect-context/reconnect-context.js
  69. 17 6
      src/server/service/search.js
  70. 50 0
      src/server/service/user-notification/index.js
  71. 2 0
      src/server/views/login/error.html
  72. 30 0
      src/test/libs/to-array-from-csv.test.js
  73. 37 1
      src/test/util/path-utils.test.js
  74. 28 22
      yarn.lock

+ 67 - 0
.github/workflows/codeql-analysis.yml

@@ -0,0 +1,67 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: "CodeQL"
+
+on:
+  push:
+    branches: [ master, dev/*, release/current ]
+  pull_request:
+    # The branches below must be a subset of the branches above
+    branches: [ master ]
+  # schedule:
+    - cron: '28 20 * * 6'
+
+jobs:
+  analyze:
+    name: Analyze
+    runs-on: ubuntu-latest
+
+    strategy:
+      fail-fast: false
+      matrix:
+        language: [ 'javascript' ]
+        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
+        # Learn more:
+        # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
+
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v2
+
+    # Initializes the CodeQL tools for scanning.
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v1
+      with:
+        languages: ${{ matrix.language }}
+        # If you wish to specify custom queries, you can do so here or in a config file.
+        # By default, queries listed here will override any specified in a config file.
+        # Prefix the list here with "+" to use these queries and those in the config file.
+        # queries: ./path/to/local/query, your-org/your-repo/queries@main
+
+    # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).
+    # If this step fails, then you should remove it and run the build manually (see below)
+    - name: Autobuild
+      uses: github/codeql-action/autobuild@v1
+
+    # ℹ️ Command-line programs to run using the OS shell.
+    # 📚 https://git.io/JvXDl
+
+    # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
+    #    and modify them (or add more) to build your code if your project
+    #    uses a compiled language
+
+    #- run: |
+    #   make bootstrap
+    #   make release
+
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v1

+ 9 - 1
CHANGES.md

@@ -2,7 +2,15 @@
 
 
 ## v4.2.3-RC
 ## v4.2.3-RC
 
 
-* 
+* Feature: Auto reconnecting to search service
+* Improvement: New style of params for Healthcheck API
+* Fix: Referencing attachments when `FILE_UPLOAD_DISABLED` is true
+* Fix: The message of timeline for restricted pages
+* Fix: Parameter validation for Import/Export Archive API
+* Fix: Prevent regexp for Search Tags API
+* Fix: Add `Content-Security-Policy` when referencing attachments
+* Fix: Sanitize at presentation time
+* Fix: Remove page path string from message for page lists and timeline when there is no contents
 
 
 ## v4.2.2
 ## v4.2.2
 
 

+ 6 - 1
package.json

@@ -231,7 +231,7 @@
     "react-codemirror2": "^6.0.0",
     "react-codemirror2": "^6.0.0",
     "react-copy-to-clipboard": "^5.0.1",
     "react-copy-to-clipboard": "^5.0.1",
     "react-dom": "^16.8.3",
     "react-dom": "^16.8.3",
-    "react-dropzone": "^10.1.3",
+    "react-dropzone": "^11.2.4",
     "react-frame-component": "^4.0.0",
     "react-frame-component": "^4.0.0",
     "react-hotkeys": "^2.0.0",
     "react-hotkeys": "^2.0.0",
     "react-i18next": "^11.1.0",
     "react-i18next": "^11.1.0",
@@ -267,6 +267,11 @@
     "@alias/logger": "src/lib/service/logger",
     "@alias/logger": "src/lib/service/logger",
     "debug": "src/lib/service/logger/alias-for-debug"
     "debug": "src/lib/service/logger/alias-for-debug"
   },
   },
+  "jest": {
+    "moduleNameMapper": {
+      "@commons/(.*)": "<rootDir>/src/lib/$1"
+    }
+  },
   "engines": {
   "engines": {
     "node": "^12 || ^14",
     "node": "^12 || ^14",
     "npm": ">=6.11.3 <7",
     "npm": ">=6.11.3 <7",

+ 5 - 0
resource/locales/en_US/admin/admin.json

@@ -43,6 +43,11 @@
     "confirm_to_initialize_mail_settings": "You can't restore to the current settings. Are you sure you want to initialize e-mail settings?",
     "confirm_to_initialize_mail_settings": "You can't restore to the current settings. Are you sure you want to initialize e-mail settings?",
     "file_upload_settings":"File Upload Settings",
     "file_upload_settings":"File Upload Settings",
     "file_upload_method":"File Upload Method",
     "file_upload_method":"File Upload Method",
+    "file_delivery_method":"File Delivery Method",
+    "file_delivery_method_redirect":"Redirect",
+    "file_delivery_method_relay":"Internal System Relay",
+    "file_delivery_method_redirect_info":"Redirect: It redirects to a signed URL without GROWI server, it gives excellent performance.",
+    "file_delivery_method_relay_info":"Internal System Relay: The GROWI server delivers to clients, it provides complete security.",
     "fixed_by_env_var": "This is fixed by the env var <code>FILE_UPLOAD={{fileUploadType}}</code>.",
     "fixed_by_env_var": "This is fixed by the env var <code>FILE_UPLOAD={{fileUploadType}}</code>.",
     "gcs_label": "GCP(GCS)",
     "gcs_label": "GCP(GCS)",
     "aws_label": "AWS(S3)",
     "aws_label": "AWS(S3)",

+ 15 - 2
resource/locales/en_US/translation.json

@@ -143,6 +143,9 @@
   "No bookmarks yet": "No bookmarks yet",
   "No bookmarks yet": "No bookmarks yet",
   "Recent Created": "Recent Created",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
   "Recent Changes": "Recent Changes",
+  "original_path":"Original path",
+  "new_path":"New path",
+  "duplicated_path":"duplicated_path",
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "Home",
     "home": "Home",
     "settings": "Settings",
     "settings": "Settings",
@@ -161,7 +164,7 @@
     "page_not_exist_alert": "This page does not exist. Please create a new page."
     "page_not_exist_alert": "This page does not exist. Please create a new page."
   },
   },
   "custom_navigation": {
   "custom_navigation": {
-    "no_page_list": "There are no pages under <a href='{{path}}'><strong>{{ path }}</strong></a>."
+    "no_page_list": "There are no pages under this page."
   },
   },
   "installer": {
   "installer": {
     "setup": "Setup",
     "setup": "Setup",
@@ -318,6 +321,9 @@
     "label": {
     "label": {
       "Move/Rename page": "Move/Rename page",
       "Move/Rename page": "Move/Rename page",
       "New page name": "New page name",
       "New page name": "New page name",
+      "Fail to get subordinated pages": "Fail to get subordinated pages",
+      "Fail to get exist path": "Fail to get exist path",
+      "Rename without exist path": "Rename without exist path",
       "Current page name": "Current page name",
       "Current page name": "Current page name",
       "Recursively": "Recursively",
       "Recursively": "Recursively",
       "Do not update metadata": "Do not update metadata",
       "Do not update metadata": "Do not update metadata",
@@ -348,7 +354,14 @@
     "label": {
     "label": {
       "Duplicate page": "Duplicate page",
       "Duplicate page": "Duplicate page",
       "New page name": "New page name",
       "New page name": "New page name",
-      "Current page name": "Current page name"
+      "Fail to get subordinated pages": "Fail to get subordinated pages",
+      "Current page name": "Current page name",
+      "Recursively": "Recursively",
+      "Duplicate without exist path": "Duplicate without exist path",
+      "Same page already exists": "Same page already exists"
+    },
+    "help": {
+      "recursive": "Duplicate children of under this path recursively"
     }
     }
   },
   },
   "modal_putback": {
   "modal_putback": {

+ 5 - 0
resource/locales/ja_JP/admin/admin.json

@@ -43,6 +43,11 @@
     "confirm_to_initialize_mail_settings": "一度初期化した設定は戻せません。本当に初期化しますか?",
     "confirm_to_initialize_mail_settings": "一度初期化した設定は戻せません。本当に初期化しますか?",
     "file_upload_settings":"ファイルアップロード設定",
     "file_upload_settings":"ファイルアップロード設定",
     "file_upload_method":"ファイルアップロード方法",
     "file_upload_method":"ファイルアップロード方法",
+    "file_delivery_method":"ファイルの配信方法",
+    "file_delivery_method_redirect":"リダイレクト",
+    "file_delivery_method_relay":"内部システム中継",
+    "file_delivery_method_redirect_info":"リダイレクト: GROWIサーバーを介さずに署名付きURLにリダイレクトされるため、優れたパフォーマンスを出します。",
+    "file_delivery_method_relay_info":"内部システム中継: GROWIサーバーがクライアントに配信するため、完全なセキュリティーを提供します。",
     "gcs_label": "GCP(GCS)",
     "gcs_label": "GCP(GCS)",
     "aws_label": "AWS(S3)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "local_label": "Local",

+ 15 - 2
resource/locales/ja_JP/translation.json

@@ -146,6 +146,9 @@
   "No bookmarks yet": "No bookmarks yet",
   "No bookmarks yet": "No bookmarks yet",
   "Recent Created": "最新の作成",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
   "Recent Changes": "最新の変更",
+  "original_path":"元のパス",
+  "new_path":"新しいパス",
+  "duplicated_path":"重複したパス",
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "ホーム",
     "home": "ホーム",
     "settings": "設定",
     "settings": "設定",
@@ -164,7 +167,7 @@
     "page_not_exist_alert": "このページは存在しません。新たに作成する必要があります。"
     "page_not_exist_alert": "このページは存在しません。新たに作成する必要があります。"
   },
   },
   "custom_navigation": {
   "custom_navigation": {
-    "no_page_list": "<a href='{{path}}'><strong>{{ path }}</strong></a>の配下にはページが存在しません。"
+    "no_page_list": "このページの配下にはページが存在しません。"
   },
   },
   "installer": {
   "installer": {
     "setup": "セットアップ",
     "setup": "セットアップ",
@@ -320,6 +323,9 @@
     "label": {
     "label": {
       "Move/Rename page": "ページを移動/名前変更する",
       "Move/Rename page": "ページを移動/名前変更する",
       "New page name": "移動先のページ名",
       "New page name": "移動先のページ名",
+      "Fail to get subordinated pages": "配下ページの取得に失敗しました",
+      "Fail to get exist path": "存在するパスの取得に失敗しました",
+      "Rename without exist path": "存在するパス以外を名前変更する",
       "Current page name": "現在のページ名",
       "Current page name": "現在のページ名",
       "Recursively": "再帰的に移動/名前変更",
       "Recursively": "再帰的に移動/名前変更",
       "Do not update metadata": "メタデータを更新しない",
       "Do not update metadata": "メタデータを更新しない",
@@ -350,7 +356,14 @@
     "label": {
     "label": {
       "Duplicate page": "ページを複製する",
       "Duplicate page": "ページを複製する",
       "New page name": "複製後のページ名",
       "New page name": "複製後のページ名",
-      "Current page name": "現在のページ名"
+      "Fail to get subordinated pages": "配下ページの取得に失敗しました",
+      "Current page name": "現在のページ名",
+      "Recursively": "再帰的に複製",
+      "Duplicate without exist path": "存在するパス以外を複製する",
+      "Same page already exists": "同じページがすでに存在します"
+    },
+    "help": {
+      "recursive": "配下のページも複製します"
     }
     }
   },
   },
   "modal_putback": {
   "modal_putback": {

+ 5 - 0
resource/locales/zh_CN/admin/admin.json

@@ -43,6 +43,11 @@
     "confirm_to_initialize_mail_settings": "当前设置将被清空且不可恢复。确认重置?",
     "confirm_to_initialize_mail_settings": "当前设置将被清空且不可恢复。确认重置?",
     "file_upload_settings":"文件上传设置",
     "file_upload_settings":"文件上传设置",
     "file_upload_method":"文件上传方法",
     "file_upload_method":"文件上传方法",
+    "file_delivery_method":"File Delivery Method",
+    "file_delivery_method_redirect":"Redirect",
+    "file_delivery_method_relay":"Internal System Relay",
+    "file_delivery_method_redirect_info":"Redirect: It redirects to a signed URL without GROWI server, it gives excellent performance.",
+    "file_delivery_method_relay_info":"Internal System Relay: The GROWI server delivers to clients, it provides complete security.",
     "gcs_label": "GCP(GCS)",
     "gcs_label": "GCP(GCS)",
     "aws_label": "AWS(S3)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "local_label": "Local",

+ 19 - 6
resource/locales/zh_CN/translation.json

@@ -151,7 +151,10 @@
   "Disassociate": "解除关联",
   "Disassociate": "解除关联",
   "No bookmarks yet": "暂无书签",
   "No bookmarks yet": "暂无书签",
 	"Recent Created": "最新创建",
 	"Recent Created": "最新创建",
-	"Recent Changes": "最新修改",
+  "Recent Changes": "最新修改",
+  "original_path":"Original path",
+  "new_path":"New path",
+  "duplicated_path":"duplicated_path",
 	"form_validation": {
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
 		"required": "%s 是必需的",
@@ -162,7 +165,7 @@
     "page_not_exist_alert": "该页面不存在,请创建一个新页面"
     "page_not_exist_alert": "该页面不存在,请创建一个新页面"
   },
   },
   "custom_navigation": {
   "custom_navigation": {
-    "no_page_list": "There are no pages under <a href='{{path}}'><strong>{{ path }}</strong></a>."
+    "no_page_list": "There are no pages under this page."
   },
   },
 	"installer": {
 	"installer": {
 		"setup": "安装",
 		"setup": "安装",
@@ -298,7 +301,10 @@
 	"modal_rename": {
 	"modal_rename": {
 		"label": {
 		"label": {
 			"Move/Rename page": "页面 移动/重命名",
 			"Move/Rename page": "页面 移动/重命名",
-			"New page name": "新建页面名称",
+      "New page name": "新建页面名称",
+      "Fail to get subordinated pages": "Fail to get subordinated pages",
+      "Fail to get exist path": "Fail to get exist path",
+      "Rename without exist path": "Rename without exist path",
 			"Current page name": "当前页面名称",
 			"Current page name": "当前页面名称",
 			"Recursively": "递归地",
 			"Recursively": "递归地",
 			"Do not update metadata": "不更新元数据",
 			"Do not update metadata": "不更新元数据",
@@ -328,9 +334,16 @@
 	"modal_duplicate": {
 	"modal_duplicate": {
 		"label": {
 		"label": {
 			"Duplicate page": "Duplicate page",
 			"Duplicate page": "Duplicate page",
-			"New page name": "New page name",
-			"Current page name": "Current page name"
-		}
+      "New page name": "New page name",
+      "Fail to get subordinated pages": "Fail to get subordinated pages",
+			"Current page name": "Current page name",
+      "Recursively": "Recursively",
+      "Duplicate without exist path": "Duplicate without exist path",
+      "Same page already exists": "Same page already exists"
+    },
+    "help": {
+      "recursive": "Duplicate children of under this path recursively"
+    }
 	},
 	},
 	"modal_putback": {
 	"modal_putback": {
 		"label": {
 		"label": {

+ 60 - 12
src/client/js/components/Admin/App/AwsSetting.jsx

@@ -9,9 +9,55 @@ import AdminAppContainer from '../../../services/AdminAppContainer';
 
 
 function AwsSetting(props) {
 function AwsSetting(props) {
   const { t, adminAppContainer } = props;
   const { t, adminAppContainer } = props;
+  const { s3ReferenceFileWithRelayMode } = adminAppContainer.state;
 
 
   return (
   return (
     <React.Fragment>
     <React.Fragment>
+
+      <div className="row form-group my-3">
+        <label className="text-left text-md-right col-md-3 col-form-label">
+          {t('admin:app_setting.file_delivery_method')}
+        </label>
+
+        <div className="col-md-6">
+          <div className="dropdown">
+            <button
+              className="btn btn-outline-secondary dropdown-toggle"
+              type="button"
+              id="ddS3ReferenceFileWithRelayMode"
+              data-toggle="dropdown"
+              aria-haspopup="true"
+              aria-expanded="true"
+            >
+              {s3ReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_relay')}
+              {!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) }}
+              >
+                {t('admin:app_setting.file_delivery_method_relay')}
+              </button>
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { adminAppContainer.changeS3ReferenceFileWithRelayMode(false) }}
+              >
+                { t('admin:app_setting.file_delivery_method_redirect')}
+              </button>
+            </div>
+
+            <p className="form-text text-muted small">
+              {t('admin:app_setting.file_delivery_method_redirect_info')}
+              <br />
+              {t('admin:app_setting.file_delivery_method_relay_info')}
+            </p>
+          </div>
+        </div>
+      </div>
+
       <div className="row form-group">
       <div className="row form-group">
         <label className="text-left text-md-right col-md-3 col-form-label">
         <label className="text-left text-md-right col-md-3 col-form-label">
           {t('admin:app_setting.region')}
           {t('admin:app_setting.region')}
@@ -22,8 +68,8 @@ function AwsSetting(props) {
             placeholder={`${t('eg')} ap-northeast-1`}
             placeholder={`${t('eg')} ap-northeast-1`}
             defaultValue={adminAppContainer.state.s3Region || ''}
             defaultValue={adminAppContainer.state.s3Region || ''}
             onChange={(e) => {
             onChange={(e) => {
-                adminAppContainer.changeS3Region(e.target.value);
-              }}
+              adminAppContainer.changeS3Region(e.target.value);
+            }}
           />
           />
         </div>
         </div>
       </div>
       </div>
@@ -39,8 +85,8 @@ function AwsSetting(props) {
             placeholder={`${t('eg')} http://localhost:9000`}
             placeholder={`${t('eg')} http://localhost:9000`}
             defaultValue={adminAppContainer.state.s3CustomEndpoint || ''}
             defaultValue={adminAppContainer.state.s3CustomEndpoint || ''}
             onChange={(e) => {
             onChange={(e) => {
-                adminAppContainer.changeS3CustomEndpoint(e.target.value);
-              }}
+              adminAppContainer.changeS3CustomEndpoint(e.target.value);
+            }}
           />
           />
           <p className="form-text text-muted">{t('admin:app_setting.custom_endpoint_change')}</p>
           <p className="form-text text-muted">{t('admin:app_setting.custom_endpoint_change')}</p>
         </div>
         </div>
@@ -57,15 +103,15 @@ function AwsSetting(props) {
             placeholder={`${t('eg')} crowi`}
             placeholder={`${t('eg')} crowi`}
             defaultValue={adminAppContainer.state.s3Bucket || ''}
             defaultValue={adminAppContainer.state.s3Bucket || ''}
             onChange={(e) => {
             onChange={(e) => {
-                adminAppContainer.changeS3Bucket(e.target.value);
-              }}
+              adminAppContainer.changeS3Bucket(e.target.value);
+            }}
           />
           />
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row form-group">
       <div className="row form-group">
         <label className="text-left text-md-right col-md-3 col-form-label">
         <label className="text-left text-md-right col-md-3 col-form-label">
-            Access key ID
+          Access key ID
         </label>
         </label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
@@ -73,15 +119,15 @@ function AwsSetting(props) {
             type="text"
             type="text"
             defaultValue={adminAppContainer.state.s3AccessKeyId || ''}
             defaultValue={adminAppContainer.state.s3AccessKeyId || ''}
             onChange={(e) => {
             onChange={(e) => {
-                adminAppContainer.changeS3AccessKeyId(e.target.value);
-              }}
+              adminAppContainer.changeS3AccessKeyId(e.target.value);
+            }}
           />
           />
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row form-group">
       <div className="row form-group">
         <label className="text-left text-md-right col-md-3 col-form-label">
         <label className="text-left text-md-right col-md-3 col-form-label">
-            Secret access key
+          Secret access key
         </label>
         </label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
@@ -89,11 +135,13 @@ function AwsSetting(props) {
             type="text"
             type="text"
             defaultValue={adminAppContainer.state.s3SecretAccessKey || ''}
             defaultValue={adminAppContainer.state.s3SecretAccessKey || ''}
             onChange={(e) => {
             onChange={(e) => {
-                adminAppContainer.changeS3SecretAccessKey(e.target.value);
-              }}
+              adminAppContainer.changeS3SecretAccessKey(e.target.value);
+            }}
           />
           />
         </div>
         </div>
       </div>
       </div>
+
+
     </React.Fragment>
     </React.Fragment>
   );
   );
 }
 }

+ 47 - 1
src/client/js/components/Admin/App/GcsSettings.jsx

@@ -11,10 +11,55 @@ import AdminAppContainer from '../../../services/AdminAppContainer';
 
 
 function GcsSetting(props) {
 function GcsSetting(props) {
   const { t, adminAppContainer } = props;
   const { t, adminAppContainer } = props;
-  const { gcsUseOnlyEnvVars } = adminAppContainer.state;
+  const { gcsReferenceFileWithRelayMode, gcsUseOnlyEnvVars } = adminAppContainer.state;
 
 
   return (
   return (
     <>
     <>
+
+      <div className="row form-group my-3">
+        <label className="text-left text-md-right col-md-3 col-form-label">
+          {t('admin:app_setting.file_delivery_method')}
+        </label>
+
+        <div className="col-md-6">
+          <div className="dropdown">
+            <button
+              className="btn btn-outline-secondary dropdown-toggle"
+              type="button"
+              id="ddGcsReferenceFileWithRelayMode"
+              data-toggle="dropdown"
+              aria-haspopup="true"
+              aria-expanded="true"
+            >
+              {gcsReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_relay')}
+              {!gcsReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_redirect')}
+            </button>
+            <div className="dropdown-menu" aria-labelledby="ddGcsReferenceFileWithRelayMode">
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { adminAppContainer.changeGcsReferenceFileWithRelayMode(true) }}
+              >
+                {t('admin:app_setting.file_delivery_method_relay')}
+              </button>
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { adminAppContainer.changeGcsReferenceFileWithRelayMode(false) }}
+              >
+                { t('admin:app_setting.file_delivery_method_redirect')}
+              </button>
+            </div>
+
+            <p className="form-text text-muted small">
+              {t('admin:app_setting.file_delivery_method_redirect_info')}
+              <br />
+              {t('admin:app_setting.file_delivery_method_relay_info')}
+            </p>
+          </div>
+        </div>
+      </div>
+
       {gcsUseOnlyEnvVars && (
       {gcsUseOnlyEnvVars && (
         <p
         <p
           className="alert alert-info"
           className="alert alert-info"
@@ -98,6 +143,7 @@ function GcsSetting(props) {
           </tr>
           </tr>
         </tbody>
         </tbody>
       </table>
       </table>
+
     </>
     </>
   );
   );
 
 

+ 60 - 0
src/client/js/components/ComparePathsTable.jsx

@@ -0,0 +1,60 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+import PageContainer from '../services/PageContainer';
+import { convertToNewAffiliationPath } from '../../../lib/util/path-utils';
+
+function ComparePathsTable(props) {
+  const {
+    subordinatedPages, pageContainer, newPagePath, t,
+  } = props;
+  const { path } = pageContainer.state;
+
+  return (
+    <table className="table table-bordered grw-compare-paths-table">
+      <thead>
+        <tr className="d-flex">
+          <th className="w-50">{t('original_path')}</th>
+          <th className="w-50">{t('new_path')}</th>
+        </tr>
+      </thead>
+      <tbody className="overflow-auto d-block">
+        {subordinatedPages.map((subordinatedPage) => {
+          const convertedPath = convertToNewAffiliationPath(path, newPagePath, subordinatedPage.path);
+          return (
+            <tr key={subordinatedPage._id} className="d-flex">
+              <td className="text-break w-50">
+                <a href={subordinatedPage.path}>
+                  {subordinatedPage.path}
+                </a>
+              </td>
+              <td className="text-break w-50">
+                {convertedPath}
+              </td>
+            </tr>
+          );
+        })}
+      </tbody>
+    </table>
+  );
+}
+
+
+/**
+ * Wrapper component for using unstated
+ */
+const PageDuplicateModallWrapper = withUnstatedContainers(ComparePathsTable, [PageContainer]);
+
+ComparePathsTable.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  subordinatedPages: PropTypes.array.isRequired,
+  newPagePath: PropTypes.string.isRequired,
+};
+
+
+export default withTranslation()(PageDuplicateModallWrapper);

+ 59 - 0
src/client/js/components/DuplicatedPathsTable.jsx

@@ -0,0 +1,59 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+import PageContainer from '../services/PageContainer';
+import { convertToNewAffiliationPath } from '../../../lib/util/path-utils';
+
+function DuplicatedPathsTable(props) {
+  const {
+    pageContainer, oldPagePath, existingPaths, t,
+  } = props;
+  const { path } = pageContainer.state;
+
+  return (
+    <table className="table table-bordered grw-duplicated-paths-table">
+      <thead>
+        <tr className="d-flex">
+          <th className="w-50">{t('original_path')}</th>
+          <th className="w-50 text-danger">{t('duplicated_path')}</th>
+        </tr>
+      </thead>
+      <tbody className="overflow-auto d-block">
+        {existingPaths.map((existPath) => {
+          const convertedPath = convertToNewAffiliationPath(oldPagePath, path, existPath);
+          return (
+            <tr key={existPath} className="d-flex">
+              <td className="text-break w-50">
+                <a href={convertedPath}>
+                  {convertedPath}
+                </a>
+              </td>
+              <td className="text-break text-danger w-50">
+                {existPath}
+              </td>
+            </tr>
+          );
+        })}
+      </tbody>
+    </table>
+  );
+}
+
+
+/**
+ * Wrapper component for using unstated
+ */
+const PageDuplicateModallWrapper = withUnstatedContainers(DuplicatedPathsTable, [PageContainer]);
+
+DuplicatedPathsTable.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  existingPaths: PropTypes.array.isRequired,
+  oldPagePath: PropTypes.string.isRequired,
+};
+
+
+export default withTranslation()(PageDuplicateModallWrapper);

+ 7 - 8
src/client/js/components/EmptyTrashModal.jsx

@@ -9,25 +9,24 @@ import { withTranslation } from 'react-i18next';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 
 import AppContainer from '../services/AppContainer';
 import AppContainer from '../services/AppContainer';
-import ApiErrorMessage from './PageManagement/ApiErrorMessage';
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 
 const EmptyTrashModal = (props) => {
 const EmptyTrashModal = (props) => {
   const {
   const {
     t, isOpen, onClose, appContainer,
     t, isOpen, onClose, appContainer,
   } = props;
   } = props;
-  const [errorCode, setErrorCode] = useState(null);
-  const [errorMessage, setErrorMessage] = useState(null);
+
+  const [errs, setErrs] = useState(null);
 
 
   async function emptyTrash() {
   async function emptyTrash() {
-    setErrorCode(null);
-    setErrorMessage(null);
+    setErrs(null);
+
     try {
     try {
       await appContainer.apiv3Delete('/pages/empty-trash');
       await appContainer.apiv3Delete('/pages/empty-trash');
       window.location.reload();
       window.location.reload();
     }
     }
     catch (err) {
     catch (err) {
-      setErrorCode(err.code);
-      setErrorMessage(err.message);
+      setErrs(err);
     }
     }
   }
   }
 
 
@@ -44,7 +43,7 @@ const EmptyTrashModal = (props) => {
         { t('modal_empty.notice')}
         { t('modal_empty.notice')}
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
-        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} />
+        <ApiErrorMessageList errs={errs} />
         <button type="button" className="btn btn-danger" onClick={emptyButtonHandler}>
         <button type="button" className="btn btn-danger" onClick={emptyButtonHandler}>
           <i className="icon-trash mr-2" aria-hidden="true"></i> Empty
           <i className="icon-trash mr-2" aria-hidden="true"></i> Empty
         </button>
         </button>

+ 0 - 1
src/client/js/components/Navbar/GrowiNavbar.jsx

@@ -53,7 +53,6 @@ class GrowiNavbar extends React.Component {
         </span>
         </span>
         <UncontrolledTooltip
         <UncontrolledTooltip
           placement="bottom"
           placement="bottom"
-          trigger="click"
           target="confidentialTooltip"
           target="confidentialTooltip"
           className="d-md-none"
           className="d-md-none"
         >
         >

+ 0 - 2
src/client/js/components/Page/PageManagement.jsx

@@ -24,7 +24,6 @@ const PageManagement = (props) => {
 
 
   const { currentUser } = appContainer;
   const { currentUser } = appContainer;
   const isTopPagePath = isTopPage(path);
   const isTopPagePath = isTopPage(path);
-
   const [isPageRenameModalShown, setIsPageRenameModalShown] = useState(false);
   const [isPageRenameModalShown, setIsPageRenameModalShown] = useState(false);
   const [isPageDuplicateModalShown, setIsPageDuplicateModalShown] = useState(false);
   const [isPageDuplicateModalShown, setIsPageDuplicateModalShown] = useState(false);
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
@@ -42,7 +41,6 @@ const PageManagement = (props) => {
   function openPageDuplicateModalHandler() {
   function openPageDuplicateModalHandler() {
     setIsPageDuplicateModalShown(true);
     setIsPageDuplicateModalShown(true);
   }
   }
-
   function closePageDuplicateModalHandler() {
   function closePageDuplicateModalHandler() {
     setIsPageDuplicateModalShown(false);
     setIsPageDuplicateModalShown(false);
   }
   }

+ 9 - 6
src/client/js/components/Page/RevisionLoader.jsx

@@ -22,7 +22,7 @@ class RevisionLoader extends React.Component {
       markdown: '',
       markdown: '',
       isLoading: false,
       isLoading: false,
       isLoaded: false,
       isLoaded: false,
-      error: null,
+      errors: null,
     };
     };
 
 
     this.loadData = this.loadData.bind(this);
     this.loadData = this.loadData.bind(this);
@@ -49,15 +49,15 @@ class RevisionLoader extends React.Component {
 
 
       this.setState({
       this.setState({
         markdown: res.data.revision.body,
         markdown: res.data.revision.body,
-        error: null,
+        errors: null,
       });
       });
 
 
       if (this.props.onRevisionLoaded != null) {
       if (this.props.onRevisionLoaded != null) {
         this.props.onRevisionLoaded(res.data.revision);
         this.props.onRevisionLoaded(res.data.revision);
       }
       }
     }
     }
-    catch (error) {
-      this.setState({ error });
+    catch (errors) {
+      this.setState({ errors });
     }
     }
     finally {
     finally {
       this.setState({ isLoaded: true, isLoading: false });
       this.setState({ isLoaded: true, isLoading: false });
@@ -94,8 +94,11 @@ class RevisionLoader extends React.Component {
 
 
     // ----- after load -----
     // ----- after load -----
     let markdown = this.state.markdown;
     let markdown = this.state.markdown;
-    if (this.state.error != null) {
-      markdown = `<span class="text-muted"><em>${this.state.error}</em></span>`;
+    if (this.state.errors != null) {
+      const errorMessages = this.state.errors.map((error) => {
+        return `<span class="text-muted"><em>${error.message}</em></span>`;
+      });
+      markdown = errorMessages.join('');
     }
     }
 
 
     return (
     return (

+ 7 - 5
src/client/js/components/Page/TrashPageAlert.jsx

@@ -72,7 +72,7 @@ const TrashPageAlert = (props) => {
         </button>
         </button>
         <button
         <button
           type="button"
           type="button"
-          className="btn btn-danger rounded-pill btn-sm mr-2"
+          className="btn btn-danger rounded-pill btn-sm"
           disabled={!isAbleToDeleteCompletely}
           disabled={!isAbleToDeleteCompletely}
           onClick={openPageDeleteModalHandler}
           onClick={openPageDeleteModalHandler}
         >
         >
@@ -107,13 +107,15 @@ const TrashPageAlert = (props) => {
 
 
   return (
   return (
     <>
     <>
-      <div className="alert alert-warning py-3 px-4 d-flex align-items-center">
-        <div>
+      <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
+        <div className="flex-grow-1">
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
           {isDeleted && <span><br /><UserPicture user={{ username: lastUpdateUsername }} /> Deleted by {lastUpdateUsername} at {updatedAt}</span>}
           {isDeleted && <span><br /><UserPicture user={{ username: lastUpdateUsername }} /> Deleted by {lastUpdateUsername} at {updatedAt}</span>}
         </div>
         </div>
-        { pageContainer.isAbleToShowEmptyTrashButton && renderEmptyButton()}
-        { pageContainer.isAbleToShowTrashPageManagementButtons && renderTrashPageManagementButtons()}
+        <div className="pt-1 d-flex align-items-end align-items-lg-center">
+          <span>{ pageContainer.isAbleToShowEmptyTrashButton && renderEmptyButton()}</span>
+          { pageContainer.isAbleToShowTrashPageManagementButtons && renderTrashPageManagementButtons()}
+        </div>
       </div>
       </div>
       {renderModals()}
       {renderModals()}
     </>
     </>

+ 9 - 6
src/client/js/components/PageAccessoriesModalControl.jsx

@@ -26,29 +26,34 @@ const PageAccessoriesModalControl = (props) => {
         name: 'pagelist',
         name: 'pagelist',
         Icon: <PageListIcon />,
         Icon: <PageListIcon />,
         disabled: isSharedUser,
         disabled: isSharedUser,
+        i18n: t('page_list'),
       },
       },
       {
       {
         name: 'timeline',
         name: 'timeline',
         Icon: <TimeLineIcon />,
         Icon: <TimeLineIcon />,
         disabled: isSharedUser,
         disabled: isSharedUser,
+        i18n: t('Timeline View'),
       },
       },
       {
       {
         name: 'pageHistory',
         name: 'pageHistory',
         Icon: <HistoryIcon />,
         Icon: <HistoryIcon />,
         disabled: isGuestUser || isSharedUser,
         disabled: isGuestUser || isSharedUser,
+        i18n: t('History'),
       },
       },
       {
       {
         name: 'attachment',
         name: 'attachment',
         Icon: <AttachmentIcon />,
         Icon: <AttachmentIcon />,
         disabled: false,
         disabled: false,
+        i18n: t('attachment_data'),
       },
       },
       {
       {
         name: 'shareLink',
         name: 'shareLink',
         Icon: <ShareLinkIcon />,
         Icon: <ShareLinkIcon />,
         disabled: isGuestUser || isSharedUser,
         disabled: isGuestUser || isSharedUser,
+        i18n: t('share_links.share_link_management'),
       },
       },
     ];
     ];
-  }, [isGuestUser, isSharedUser]);
+  }, [t, isGuestUser, isSharedUser]);
 
 
   return (
   return (
     <div className="grw-page-accessories-control d-flex flex-nowrap align-items-center justify-content-end justify-content-lg-between">
     <div className="grw-page-accessories-control d-flex flex-nowrap align-items-center justify-content-end justify-content-lg-between">
@@ -64,11 +69,9 @@ const PageAccessoriesModalControl = (props) => {
                 {accessory.Icon}
                 {accessory.Icon}
               </button>
               </button>
             </div>
             </div>
-            {accessory.disabled && (
-              <UncontrolledTooltip placement="top" target={`shareLink-btn-wrapper-for-tooltip-for-${accessory.name}`} fade={false}>
-                {t('Not available for guest')}
-              </UncontrolledTooltip>
-            )}
+            <UncontrolledTooltip placement="top" target={`shareLink-btn-wrapper-for-tooltip-for-${accessory.name}`} fade={false}>
+              {accessory.disabled ? t('Not available for guest') : accessory.i18n}
+            </UncontrolledTooltip>
           </Fragment>
           </Fragment>
         );
         );
       })}
       })}

+ 6 - 8
src/client/js/components/PageDeleteModal.jsx

@@ -10,7 +10,7 @@ import { withTranslation } from 'react-i18next';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import PageContainer from '../services/PageContainer';
 import PageContainer from '../services/PageContainer';
 
 
-import ApiErrorMessage from './PageManagement/ApiErrorMessage';
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 
 const deleteIconAndKey = {
 const deleteIconAndKey = {
   completely: {
   completely: {
@@ -32,8 +32,8 @@ const PageDeleteModal = (props) => {
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
   const [isDeleteCompletely, setIsDeleteCompletely] = useState(isDeleteCompletelyModal && isAbleToDeleteCompletely);
   const [isDeleteCompletely, setIsDeleteCompletely] = useState(isDeleteCompletelyModal && isAbleToDeleteCompletely);
   const deleteMode = isDeleteCompletely ? 'completely' : 'temporary';
   const deleteMode = isDeleteCompletely ? 'completely' : 'temporary';
-  const [errorCode, setErrorCode] = useState(null);
-  const [errorMessage, setErrorMessage] = useState(null);
+
+  const [errs, setErrs] = useState(null);
 
 
   function changeIsDeleteRecursivelyHandler() {
   function changeIsDeleteRecursivelyHandler() {
     setIsDeleteRecursively(!isDeleteRecursively);
     setIsDeleteRecursively(!isDeleteRecursively);
@@ -47,8 +47,7 @@ const PageDeleteModal = (props) => {
   }
   }
 
 
   async function deletePage() {
   async function deletePage() {
-    setErrorCode(null);
-    setErrorMessage(null);
+    setErrs(null);
 
 
     try {
     try {
       const response = await pageContainer.deletePage(isDeleteRecursively, isDeleteCompletely);
       const response = await pageContainer.deletePage(isDeleteRecursively, isDeleteCompletely);
@@ -56,8 +55,7 @@ const PageDeleteModal = (props) => {
       window.location.href = encodeURI(trashPagePath);
       window.location.href = encodeURI(trashPagePath);
     }
     }
     catch (err) {
     catch (err) {
-      setErrorCode(err.code);
-      setErrorMessage(err.message);
+      setErrs(err);
     }
     }
   }
   }
 
 
@@ -124,7 +122,7 @@ const PageDeleteModal = (props) => {
         {!isDeleteCompletelyModal && renderDeleteCompletelyForm()}
         {!isDeleteCompletelyModal && renderDeleteCompletelyForm()}
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
-        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} />
+        <ApiErrorMessageList errs={errs} />
         <button type="button" className={`btn btn-${deleteIconAndKey[deleteMode].color}`} onClick={deleteButtonHandler}>
         <button type="button" className={`btn btn-${deleteIconAndKey[deleteMode].color}`} onClick={deleteButtonHandler}>
           <i className={`icon-${deleteIconAndKey[deleteMode].icon}`} aria-hidden="true"></i>
           <i className={`icon-${deleteIconAndKey[deleteMode].icon}`} aria-hidden="true"></i>
           { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
           { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }

+ 119 - 22
src/client/js/components/PageDuplicateModal.jsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import {
 import {
@@ -6,13 +6,18 @@ import {
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
-
+import { debounce } from 'throttle-debounce';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
+import { toastError } from '../util/apiNotification';
 
 
 import AppContainer from '../services/AppContainer';
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 import PageContainer from '../services/PageContainer';
 import PagePathAutoComplete from './PagePathAutoComplete';
 import PagePathAutoComplete from './PagePathAutoComplete';
-import ApiErrorMessage from './PageManagement/ApiErrorMessage';
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import ComparePathsTable from './ComparePathsTable';
+import DuplicatePathsTable from './DuplicatedPathsTable';
+
+const LIMIT_FOR_LIST = 10;
 
 
 const PageDuplicateModal = (props) => {
 const PageDuplicateModal = (props) => {
   const { t, appContainer, pageContainer } = props;
   const { t, appContainer, pageContainer } = props;
@@ -23,16 +28,43 @@ const PageDuplicateModal = (props) => {
   const { crowi } = appContainer.config;
   const { crowi } = appContainer.config;
 
 
   const [pageNameInput, setPageNameInput] = useState(path);
   const [pageNameInput, setPageNameInput] = useState(path);
-  const [errorCode, setErrorCode] = useState(null);
-  const [errorMessage, setErrorMessage] = useState(null);
+
+  const [errs, setErrs] = useState(null);
+
+  const [subordinatedPages, setSubordinatedPages] = useState([]);
+  const [isDuplicateRecursively, setIsDuplicateRecursively] = useState(true);
+  const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(true);
+  const [existingPaths, setExistingPaths] = useState([]);
+
+  const checkExistPaths = async(newParentPath) => {
+    try {
+      const res = await appContainer.apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
+      const { existPaths } = res.data;
+      setExistingPaths(existPaths);
+    }
+    catch (err) {
+      setErrs(err);
+      toastError(t('modal_rename.label.Fail to get exist path'));
+    }
+  };
+
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  const checkExistPathsDebounce = useCallback(
+    debounce(1000, checkExistPaths), [],
+  );
+
+  useEffect(() => {
+    if (pageNameInput !== path) {
+      checkExistPathsDebounce(pageNameInput, subordinatedPages);
+    }
+  }, [pageNameInput, subordinatedPages, path, checkExistPathsDebounce]);
 
 
   /**
   /**
    * change pageNameInput for PagePathAutoComplete
    * change pageNameInput for PagePathAutoComplete
    * @param {string} value
    * @param {string} value
    */
    */
   function ppacInputChangeHandler(value) {
   function ppacInputChangeHandler(value) {
-    setErrorCode(null);
-    setErrorMessage(null);
+    setErrs(null);
     setPageNameInput(value);
     setPageNameInput(value);
   }
   }
 
 
@@ -41,22 +73,45 @@ const PageDuplicateModal = (props) => {
    * @param {string} value
    * @param {string} value
    */
    */
   function inputChangeHandler(value) {
   function inputChangeHandler(value) {
-    setErrorCode(null);
-    setErrorMessage(null);
+    setErrs(null);
     setPageNameInput(value);
     setPageNameInput(value);
   }
   }
 
 
+  function changeIsDuplicateRecursivelyHandler() {
+    setIsDuplicateRecursively(!isDuplicateRecursively);
+  }
+
+  const getSubordinatedList = useCallback(async() => {
+    try {
+      const res = await appContainer.apiv3Get('/pages/subordinated-list', { path, limit: LIMIT_FOR_LIST });
+      const { subordinatedPaths } = res.data;
+      setSubordinatedPages(subordinatedPaths);
+    }
+    catch (err) {
+      setErrs(err);
+      toastError(t('modal_duplicate.label.Fail to get subordinated pages'));
+    }
+  }, [appContainer, path, t]);
+
+  useEffect(() => {
+    if (props.isOpen) {
+      getSubordinatedList();
+    }
+  }, [props.isOpen, getSubordinatedList]);
+
+  function changeIsDuplicateRecursivelyWithoutExistPathHandler() {
+    setIsDuplicateRecursivelyWithoutExistPath(!isDuplicateRecursivelyWithoutExistPath);
+  }
+
   async function duplicate() {
   async function duplicate() {
+    setErrs(null);
+
     try {
     try {
-      setErrorCode(null);
-      setErrorMessage(null);
-      const res = await appContainer.apiPost('/pages.duplicate', { page_id: pageId, new_path: pageNameInput });
-      const { page } = res;
-      window.location.href = encodeURI(`${page.path}?duplicated=${path}`);
+      await appContainer.apiv3Post('/pages/duplicate', { pageId, pageNameInput, isRecursively: isDuplicateRecursively });
+      window.location.href = encodeURI(`${pageNameInput}?duplicated=${path}`);
     }
     }
     catch (err) {
     catch (err) {
-      setErrorCode(err.code);
-      setErrorMessage(err.message);
+      setErrs(err);
     }
     }
   }
   }
 
 
@@ -70,9 +125,8 @@ const PageDuplicateModal = (props) => {
         { t('modal_duplicate.label.Duplicate page') }
         { t('modal_duplicate.label.Duplicate page') }
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
-        <div className="form-group">
-          <label>{ t('modal_duplicate.label.Current page name') }</label><br />
-          <code>{ path }</code>
+        <div className="form-group"><label>{t('modal_duplicate.label.Current page name')}</label><br />
+          <code>{path}</code>
         </div>
         </div>
         <div className="form-group">
         <div className="form-group">
           <label htmlFor="duplicatePageName">{ t('modal_duplicate.label.New page name') }</label><br />
           <label htmlFor="duplicatePageName">{ t('modal_duplicate.label.New page name') }</label><br />
@@ -101,13 +155,56 @@ const PageDuplicateModal = (props) => {
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>
+        <div className="custom-control custom-checkbox custom-checkbox-warning mb-3">
+          <input
+            className="custom-control-input"
+            name="recursively"
+            id="cbDuplicateRecursively"
+            type="checkbox"
+            checked={isDuplicateRecursively}
+            onChange={changeIsDuplicateRecursivelyHandler}
+          />
+          <label className="custom-control-label" htmlFor="cbDuplicateRecursively">
+            { t('modal_duplicate.label.Recursively') }
+            <p className="form-text text-muted mt-0">{ t('modal_duplicate.help.recursive') }</p>
+          </label>
+
+          <div>
+            {isDuplicateRecursively && existingPaths.length !== 0 && (
+            <div className="custom-control custom-checkbox custom-checkbox-warning">
+              <input
+                className="custom-control-input"
+                name="withoutExistRecursively"
+                id="cbDuplicatewithoutExistRecursively"
+                type="checkbox"
+                checked={isDuplicateRecursivelyWithoutExistPath}
+                onChange={changeIsDuplicateRecursivelyWithoutExistPathHandler}
+              />
+              <label className="custom-control-label" htmlFor="cbDuplicatewithoutExistRecursively">
+                { t('modal_duplicate.label.Duplicate without exist path') }
+              </label>
+            </div>
+            )}
+          </div>
+          <div>
+            {isDuplicateRecursively && <ComparePathsTable subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
+            {isDuplicateRecursively && existingPaths.length !== 0 && <DuplicatePathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
+          </div>
+        </div>
+
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
-        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} targetPath={pageNameInput} />
-        <button type="button" className="btn btn-primary" onClick={duplicate}>Duplicate page</button>
+        <ApiErrorMessageList errs={errs} targetPath={pageNameInput} />
+        <button
+          type="button"
+          className="btn btn-primary"
+          onClick={duplicate}
+          disabled={(isDuplicateRecursively && !isDuplicateRecursivelyWithoutExistPath && existingPaths.length !== 0)}
+        >
+          { t('modal_duplicate.label.Duplicate page') }
+        </button>
       </ModalFooter>
       </ModalFooter>
     </Modal>
     </Modal>
-
   );
   );
 };
 };
 
 

+ 2 - 6
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -56,9 +56,7 @@ require('../../util/codemirror/autorefresh.ext');
 
 
 
 
 const MARKDOWN_TABLE_ACTIVATED_CLASS = 'markdown-table-activated';
 const MARKDOWN_TABLE_ACTIVATED_CLASS = 'markdown-table-activated';
-// TODO: activate by GW-3443
-// const MARKDOWN_LINK_ACTIVATED_CLASS = 'markdown-link-activated';
-const MARKDOWN_LINK_ACTIVATED_CLASS = '';
+const MARKDOWN_LINK_ACTIVATED_CLASS = 'markdown-link-activated';
 
 
 export default class CodeMirrorEditor extends AbstractEditor {
 export default class CodeMirrorEditor extends AbstractEditor {
 
 
@@ -768,9 +766,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         color={null}
         color={null}
         size="sm"
         size="sm"
         title="Link"
         title="Link"
-        // TODO: activate by GW-3443
-        // onClick={this.showLinkEditHandler}
-        onClick={this.createReplaceSelectionHandler('[', ']()')}
+        onClick={this.showLinkEditHandler}
       >
       >
         <EditorIcon icon="Link" />
         <EditorIcon icon="Link" />
       </Button>,
       </Button>,

+ 99 - 102
src/client/js/components/PageEditor/LinkEditModal.jsx

@@ -9,11 +9,9 @@ import {
   PopoverBody,
   PopoverBody,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { debounce } from 'throttle-debounce';
-
 import path from 'path';
 import path from 'path';
 import validator from 'validator';
 import validator from 'validator';
-import Preview from './Preview';
+import PreviewWithSuspense from './PreviewWithSuspense';
 import PagePreviewIcon from '../Icons/PagePreviewIcon';
 import PagePreviewIcon from '../Icons/PagePreviewIcon';
 
 
 import AppContainer from '../../services/AppContainer';
 import AppContainer from '../../services/AppContainer';
@@ -39,7 +37,6 @@ class LinkEditModal extends React.PureComponent {
       markdown: '',
       markdown: '',
       previewError: '',
       previewError: '',
       permalink: '',
       permalink: '',
-      linkText: '',
       isPreviewOpen: false,
       isPreviewOpen: false,
     };
     };
 
 
@@ -56,18 +53,9 @@ class LinkEditModal extends React.PureComponent {
     this.toggleIsUsePamanentLink = this.toggleIsUsePamanentLink.bind(this);
     this.toggleIsUsePamanentLink = this.toggleIsUsePamanentLink.bind(this);
     this.save = this.save.bind(this);
     this.save = this.save.bind(this);
     this.generateLink = this.generateLink.bind(this);
     this.generateLink = this.generateLink.bind(this);
-    this.renderPreview = this.renderPreview.bind(this);
     this.getRootPath = this.getRootPath.bind(this);
     this.getRootPath = this.getRootPath.bind(this);
     this.toggleIsPreviewOpen = this.toggleIsPreviewOpen.bind(this);
     this.toggleIsPreviewOpen = this.toggleIsPreviewOpen.bind(this);
-    this.generateAndSetPreviewDebounced = debounce(200, this.generateAndSetPreview.bind(this));
-  }
-
-  componentDidUpdate(prevProps, prevState) {
-    const { linkInputValue: prevLinkInputValue } = prevState;
-    const { linkInputValue } = this.state;
-    if (linkInputValue !== prevLinkInputValue) {
-      this.generateAndSetPreviewDebounced(linkInputValue);
-    }
+    this.setMarkdown = this.setMarkdown.bind(this);
   }
   }
 
 
   // defaultMarkdownLink is an instance of Linker
   // defaultMarkdownLink is an instance of Linker
@@ -158,21 +146,8 @@ class LinkEditModal extends React.PureComponent {
     this.setState({ isUsePermanentLink: !this.state.isUsePermanentLink, isUseRelativePath: false });
     this.setState({ isUsePermanentLink: !this.state.isUsePermanentLink, isUseRelativePath: false });
   }
   }
 
 
-  renderPreview() {
-    if (this.state.markdown !== '') {
-      return (
-        <div className="linkedit-preview">
-          <Preview markdown={this.state.markdown} />
-        </div>
-      );
-    }
-    if (this.state.previewError !== '') {
-      return this.state.previewError;
-    }
-    return 'Page preview here.';
-  }
-
-  async generateAndSetPreview(path) {
+  async setMarkdown() {
+    const path = this.state.linkInputValue;
     let markdown = '';
     let markdown = '';
     let previewError = '';
     let previewError = '';
     let permalink = '';
     let permalink = '';
@@ -192,10 +167,13 @@ class LinkEditModal extends React.PureComponent {
         previewError = err.message;
         previewError = err.message;
       }
       }
     }
     }
+    else {
+      previewError = `'${path}' is not a GROWI page.`;
+    }
     this.setState({ markdown, previewError, permalink });
     this.setState({ markdown, previewError, permalink });
   }
   }
 
 
-  renderLinkPreview() {
+  getLinkForPreview() {
     const linker = this.generateLink();
     const linker = this.generateLink();
 
 
     if (this.isUsePermanentLink && this.permalink != null) {
     if (this.isUsePermanentLink && this.permalink != null) {
@@ -206,16 +184,21 @@ class LinkEditModal extends React.PureComponent {
       linker.label = linker.link;
       linker.label = linker.link;
     }
     }
 
 
-    const linkText = linker.generateMarkdownText();
+    return linker;
+  }
+
+  renderLinkPreview() {
+    const linker = this.getLinkForPreview();
     return (
     return (
-      <div className="d-flex justify-content-between mb-3">
+      <div className="d-flex justify-content-between mb-3 flex-column flex-sm-row">
         <div className="card card-disabled w-100 p-1 mb-0">
         <div className="card card-disabled w-100 p-1 mb-0">
           <p className="text-left text-muted mb-1 small">Markdown</p>
           <p className="text-left text-muted mb-1 small">Markdown</p>
-          <p className="text-center text-truncate text-muted">{linkText}</p>
+          <p className="text-center text-truncate text-muted">{linker.generateMarkdownText()}</p>
         </div>
         </div>
-        <div className="d-flex align-items-center">
+        <div className="d-flex align-items-center justify-content-center">
           <span className="lead mx-3">
           <span className="lead mx-3">
-            <i className="fa fa-caret-right"></i>
+            <i className="d-none d-sm-block fa fa-caret-right"></i>
+            <i className="d-sm-none fa fa-caret-down"></i>
           </span>
           </span>
         </div>
         </div>
         <div className="card w-100 p-1 mb-0">
         <div className="card w-100 p-1 mb-0">
@@ -257,8 +240,10 @@ class LinkEditModal extends React.PureComponent {
   }
   }
 
 
   save() {
   save() {
+    const linker = this.getLinkForPreview();
+
     if (this.props.onSave != null) {
     if (this.props.onSave != null) {
-      this.props.onSave(this.state.linkText);
+      this.props.onSave(linker.generateMarkdownText());
     }
     }
 
 
     this.hide();
     this.hide();
@@ -289,7 +274,11 @@ class LinkEditModal extends React.PureComponent {
     return type === Linker.types.markdownLink ? path.dirname(pagePath) : pagePath;
     return type === Linker.types.markdownLink ? path.dirname(pagePath) : pagePath;
   }
   }
 
 
-  toggleIsPreviewOpen() {
+  async toggleIsPreviewOpen() {
+    // open popover
+    if (this.state.isPreviewOpen === false) {
+      this.setMarkdown();
+    }
     this.setState({ isPreviewOpen: !this.state.isPreviewOpen });
     this.setState({ isPreviewOpen: !this.state.isPreviewOpen });
   }
   }
 
 
@@ -309,14 +298,15 @@ class LinkEditModal extends React.PureComponent {
                 inputName="link"
                 inputName="link"
                 placeholder="Input page path or URL"
                 placeholder="Input page path or URL"
                 keywordOnInit={this.state.linkInputValue}
                 keywordOnInit={this.state.linkInputValue}
+                behaviorOfResetBtn="clear"
               />
               />
-              <div className="input-group-append">
+              <div className="d-none d-sm-block input-group-append">
                 <button type="button" id="preview-btn" className="btn btn-info btn-page-preview">
                 <button type="button" id="preview-btn" className="btn btn-info btn-page-preview">
                   <PagePreviewIcon />
                   <PagePreviewIcon />
                 </button>
                 </button>
                 <Popover trigger="focus" placement="right" isOpen={this.state.isPreviewOpen} target="preview-btn" toggle={this.toggleIsPreviewOpen}>
                 <Popover trigger="focus" placement="right" isOpen={this.state.isPreviewOpen} target="preview-btn" toggle={this.toggleIsPreviewOpen}>
                   <PopoverBody>
                   <PopoverBody>
-                    {this.renderPreview()}
+                    <PreviewWithSuspense setMarkdown={this.setMarkdown} markdown={this.state.markdown} error={this.state.previewError} />
                   </PopoverBody>
                   </PopoverBody>
                 </Popover>
                 </Popover>
               </div>
               </div>
@@ -334,6 +324,7 @@ class LinkEditModal extends React.PureComponent {
                 value={this.state.labelInputValue}
                 value={this.state.labelInputValue}
                 onChange={e => this.handleChangeLabelInput(e.target.value)}
                 onChange={e => this.handleChangeLabelInput(e.target.value)}
                 disabled={this.state.linkerType === Linker.types.growiLink}
                 disabled={this.state.linkerType === Linker.types.growiLink}
+                placeholder={this.state.linkInputValue}
               />
               />
             </div>
             </div>
           </div>
           </div>
@@ -348,73 +339,79 @@ class LinkEditModal extends React.PureComponent {
         <form className="form-group mb-0">
         <form className="form-group mb-0">
           <div className="form-group row">
           <div className="form-group row">
             <label className="col-sm-3">Path format</label>
             <label className="col-sm-3">Path format</label>
-            <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
-              <input
-                className="custom-control-input"
-                id="relativePath"
-                type="checkbox"
-                checked={this.state.isUseRelativePath}
-                onChange={this.toggleIsUseRelativePath}
-                disabled={!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink}
-              />
-              <label className="custom-control-label" htmlFor="relativePath">
-                Use relative path
-              </label>
-            </div>
-            <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
-              <input
-                className="custom-control-input"
-                id="permanentLink"
-                type="checkbox"
-                checked={this.state.isUsePermanentLink}
-                onChange={this.toggleIsUsePamanentLink}
-                disabled={this.state.permalink === '' || this.state.linkerType === Linker.types.growiLink}
-              />
-              <label className="custom-control-label" htmlFor="permanentLink">
-                Use permanent link
-              </label>
+            <div className="col-sm-9">
+              <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
+                <input
+                  className="custom-control-input"
+                  id="relativePath"
+                  type="checkbox"
+                  checked={this.state.isUseRelativePath}
+                  onChange={this.toggleIsUseRelativePath}
+                  disabled={!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink}
+                />
+                <label className="custom-control-label" htmlFor="relativePath">
+                  Use relative path
+                </label>
+              </div>
+              <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
+                <input
+                  className="custom-control-input"
+                  id="permanentLink"
+                  type="checkbox"
+                  checked={this.state.isUsePermanentLink}
+                  onChange={this.toggleIsUsePamanentLink}
+                  disabled={this.state.permalink === '' || this.state.linkerType === Linker.types.growiLink}
+                />
+                <label className="custom-control-label" htmlFor="permanentLink">
+                  Use permanent link
+                </label>
+              </div>
             </div>
             </div>
           </div>
           </div>
           <div className="form-group row mb-0">
           <div className="form-group row mb-0">
             <label className="col-sm-3">Notation</label>
             <label className="col-sm-3">Notation</label>
-            <div className="custom-control custom-radio custom-control-inline">
-              <input
-                type="radio"
-                className="custom-control-input"
-                id="markdownType"
-                value={Linker.types.markdownLink}
-                checked={this.state.linkerType === Linker.types.markdownLink}
-                onChange={e => this.handleSelecteLinkerType(e.target.value)}
-              />
-              <label className="custom-control-label" htmlFor="markdownType">
-                Markdown
-              </label>
-            </div>
-            <div className="custom-control custom-radio custom-control-inline">
-              <input
-                type="radio"
-                className="custom-control-input"
-                id="growiType"
-                value={Linker.types.growiLink}
-                checked={this.state.linkerType === Linker.types.growiLink}
-                onChange={e => this.handleSelecteLinkerType(e.target.value)}
-              />
-              <label className="custom-control-label" htmlFor="growiType">
-                Growi original
-              </label>
-            </div>
-            <div className="custom-control custom-radio custom-control-inline">
-              <input
-                type="radio"
-                className="custom-control-input"
-                id="pukiwikiType"
-                value={Linker.types.pukiwikiLink}
-                checked={this.state.linkerType === Linker.types.pukiwikiLink}
-                onChange={e => this.handleSelecteLinkerType(e.target.value)}
-              />
-              <label className="custom-control-label" htmlFor="pukiwikiType">
-                Pukiwiki
-              </label>
+            <div className="col-sm-9">
+              <div className="custom-control custom-radio custom-control-inline">
+                <input
+                  type="radio"
+                  className="custom-control-input"
+                  id="markdownType"
+                  value={Linker.types.markdownLink}
+                  checked={this.state.linkerType === Linker.types.markdownLink}
+                  onChange={e => this.handleSelecteLinkerType(e.target.value)}
+                />
+                <label className="custom-control-label" htmlFor="markdownType">
+                  Markdown
+                </label>
+              </div>
+              <div className="custom-control custom-radio custom-control-inline">
+                <input
+                  type="radio"
+                  className="custom-control-input"
+                  id="growiType"
+                  value={Linker.types.growiLink}
+                  checked={this.state.linkerType === Linker.types.growiLink}
+                  onChange={e => this.handleSelecteLinkerType(e.target.value)}
+                />
+                <label className="custom-control-label" htmlFor="growiType">
+                  Growi original
+                </label>
+              </div>
+              {this.isApplyPukiwikiLikeLinkerPlugin && (
+                <div className="custom-control custom-radio custom-control-inline">
+                  <input
+                    type="radio"
+                    className="custom-control-input"
+                    id="pukiwikiType"
+                    value={Linker.types.pukiwikiLink}
+                    checked={this.state.linkerType === Linker.types.pukiwikiLink}
+                    onChange={e => this.handleSelecteLinkerType(e.target.value)}
+                  />
+                  <label className="custom-control-label" htmlFor="pukiwikiType">
+                    Pukiwiki
+                  </label>
+                </div>
+              )}
             </div>
             </div>
           </div>
           </div>
         </form>
         </form>

+ 30 - 0
src/client/js/components/PageEditor/PreviewWithSuspense.jsx

@@ -0,0 +1,30 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Preview from './Preview';
+
+import { withLoadingSppiner } from '../SuspenseUtils';
+
+function PagePreview(props) {
+  if (props.markdown === '') {
+    if (props.error !== '') {
+      return props.error;
+    }
+    throw (async() => {
+      await props.setMarkdown();
+    })();
+  }
+
+  return (
+    <div className="linkedit-preview">
+      <Preview markdown={props.markdown} />
+    </div>
+  );
+}
+
+PagePreview.propTypes = {
+  setMarkdown: PropTypes.func,
+  markdown: PropTypes.string,
+  error: PropTypes.string,
+};
+
+export default withLoadingSppiner(PagePreview);

+ 1 - 1
src/client/js/components/PageList.jsx

@@ -60,7 +60,7 @@ const PageList = (props) => {
     return (
     return (
       <div className="mt-2">
       <div className="mt-2">
         {/* eslint-disable-next-line react/no-danger */}
         {/* eslint-disable-next-line react/no-danger */}
-        <p dangerouslySetInnerHTML={{ __html: t('custom_navigation.no_page_list', { path }) }} />
+        <p>{t('custom_navigation.no_page_list')}</p>
       </div>
       </div>
     );
     );
   }
   }

+ 23 - 0
src/client/js/components/PageManagement/ApiErrorMessageList.jsx

@@ -0,0 +1,23 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import ApiErrorMessage from './ApiErrorMessage';
+import toArrayIfNot from '../../../../lib/util/toArrayIfNot';
+
+function ApiErrorMessageList(props) {
+  const errs = toArrayIfNot(props.errs);
+
+  return (
+    <>
+      {errs.map(err => <ApiErrorMessage key={err.code} errorCode={err.code} errorMessage={err.message} targetPath={props.targetPath} />)}
+    </>
+  );
+
+}
+
+ApiErrorMessageList.propTypes = {
+  errs:         PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
+  targetPath:   PropTypes.string,
+};
+
+export default ApiErrorMessageList;

+ 96 - 15
src/client/js/components/PageRenameModal.jsx

@@ -1,4 +1,6 @@
-import React, { useState } from 'react';
+import React, {
+  useState, useEffect, useCallback,
+} from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import {
 import {
@@ -7,11 +9,16 @@ import {
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
+import { debounce } from 'throttle-debounce';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
+import { toastError } from '../util/apiNotification';
 
 
 import AppContainer from '../services/AppContainer';
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 import PageContainer from '../services/PageContainer';
-import ApiErrorMessage from './PageManagement/ApiErrorMessage';
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import ComparePathsTable from './ComparePathsTable';
+import DuplicatedPathsTable from './DuplicatedPathsTable';
+
 
 
 const PageRenameModal = (props) => {
 const PageRenameModal = (props) => {
   const {
   const {
@@ -23,17 +30,25 @@ const PageRenameModal = (props) => {
   const { crowi } = appContainer.config;
   const { crowi } = appContainer.config;
 
 
   const [pageNameInput, setPageNameInput] = useState(path);
   const [pageNameInput, setPageNameInput] = useState(path);
-  const [errorCode, setErrorCode] = useState(null);
-  const [errorMessage, setErrorMessage] = useState(null);
 
 
+  const [errs, setErrs] = useState(null);
+
+  const [subordinatedPages, setSubordinatedPages] = useState([]);
+  const [existingPaths, setExistingPaths] = useState([]);
   const [isRenameRecursively, SetIsRenameRecursively] = useState(true);
   const [isRenameRecursively, SetIsRenameRecursively] = useState(true);
   const [isRenameRedirect, SetIsRenameRedirect] = useState(false);
   const [isRenameRedirect, SetIsRenameRedirect] = useState(false);
   const [isRenameMetadata, SetIsRenameMetadata] = useState(false);
   const [isRenameMetadata, SetIsRenameMetadata] = useState(false);
+  const [subordinatedError] = useState(null);
+  const [isRenameRecursivelyWithoutExistPath, setIsRenameRecursivelyWithoutExistPath] = useState(true);
 
 
   function changeIsRenameRecursivelyHandler() {
   function changeIsRenameRecursivelyHandler() {
     SetIsRenameRecursively(!isRenameRecursively);
     SetIsRenameRecursively(!isRenameRecursively);
   }
   }
 
 
+  function changeIsRenameRecursivelyWithoutExistPathHandler() {
+    setIsRenameRecursivelyWithoutExistPath(!isRenameRecursivelyWithoutExistPath);
+  }
+
   function changeIsRenameRedirectHandler() {
   function changeIsRenameRedirectHandler() {
     SetIsRenameRedirect(!isRenameRedirect);
     SetIsRenameRedirect(!isRenameRedirect);
   }
   }
@@ -42,21 +57,61 @@ const PageRenameModal = (props) => {
     SetIsRenameMetadata(!isRenameMetadata);
     SetIsRenameMetadata(!isRenameMetadata);
   }
   }
 
 
+  const updateSubordinatedList = useCallback(async() => {
+    try {
+      const res = await appContainer.apiv3Get('/pages/subordinated-list', { path });
+      const { subordinatedPaths } = res.data;
+      setSubordinatedPages(subordinatedPaths);
+    }
+    catch (err) {
+      setErrs(err);
+      toastError(t('modal_rename.label.Fail to get subordinated pages'));
+    }
+  }, [appContainer, path, t]);
+
+  useEffect(() => {
+    if (props.isOpen) {
+      updateSubordinatedList();
+    }
+  }, [props.isOpen, updateSubordinatedList]);
+
+
+  const checkExistPaths = async(newParentPath) => {
+    try {
+      const res = await appContainer.apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
+      const { existPaths } = res.data;
+      setExistingPaths(existPaths);
+    }
+    catch (err) {
+      setErrs(err);
+      toastError(t('modal_rename.label.Fail to get exist path'));
+    }
+  };
+
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  const checkExistPathsDebounce = useCallback(
+    debounce(1000, checkExistPaths), [],
+  );
+
+  useEffect(() => {
+    if (pageNameInput !== path) {
+      checkExistPathsDebounce(pageNameInput, subordinatedPages);
+    }
+  }, [pageNameInput, subordinatedPages, path, checkExistPathsDebounce]);
+
   /**
   /**
    * change pageNameInput
    * change pageNameInput
    * @param {string} value
    * @param {string} value
    */
    */
   function inputChangeHandler(value) {
   function inputChangeHandler(value) {
-    setErrorCode(null);
-    setErrorMessage(null);
+    setErrs(null);
     setPageNameInput(value);
     setPageNameInput(value);
   }
   }
 
 
   async function rename() {
   async function rename() {
-    try {
-      setErrorCode(null);
-      setErrorMessage(null);
+    setErrs(null);
 
 
+    try {
       const response = await pageContainer.rename(
       const response = await pageContainer.rename(
         pageNameInput,
         pageNameInput,
         isRenameRecursively,
         isRenameRecursively,
@@ -64,7 +119,7 @@ const PageRenameModal = (props) => {
         isRenameMetadata,
         isRenameMetadata,
       );
       );
 
 
-      const { page } = response;
+      const { page } = response.data;
       const url = new URL(page.path, 'https://dummy');
       const url = new URL(page.path, 'https://dummy');
       url.searchParams.append('renamedFrom', path);
       url.searchParams.append('renamedFrom', path);
       if (isRenameRedirect) {
       if (isRenameRedirect) {
@@ -74,13 +129,12 @@ const PageRenameModal = (props) => {
       window.location.href = `${url.pathname}${url.search}`;
       window.location.href = `${url.pathname}${url.search}`;
     }
     }
     catch (err) {
     catch (err) {
-      setErrorCode(err.code);
-      setErrorMessage(err.message);
+      setErrs(err);
     }
     }
   }
   }
 
 
   return (
   return (
-    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose} className="grw-create-page">
+    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose}>
       <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
       <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
         { t('modal_rename.label.Move/Rename page') }
         { t('modal_rename.label.Move/Rename page') }
       </ModalHeader>
       </ModalHeader>
@@ -119,6 +173,26 @@ const PageRenameModal = (props) => {
             { t('modal_rename.label.Recursively') }
             { t('modal_rename.label.Recursively') }
             <p className="form-text text-muted mt-0">{ t('modal_rename.help.recursive') }</p>
             <p className="form-text text-muted mt-0">{ t('modal_rename.help.recursive') }</p>
           </label>
           </label>
+          {existingPaths.length !== 0 && (
+          <div
+            className="custom-control custom-checkbox custom-checkbox-warning"
+            style={{ display: isRenameRecursively ? '' : 'none' }}
+          >
+            <input
+              className="custom-control-input"
+              name="withoutExistRecursively"
+              id="cbRenamewithoutExistRecursively"
+              type="checkbox"
+              checked={isRenameRecursivelyWithoutExistPath}
+              onChange={changeIsRenameRecursivelyWithoutExistPathHandler}
+            />
+            <label className="custom-control-label" htmlFor="cbRenamewithoutExistRecursively">
+              { t('modal_rename.label.Rename without exist path') }
+            </label>
+          </div>
+          )}
+          {isRenameRecursively && <ComparePathsTable subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
+          {isRenameRecursively && existingPaths.length !== 0 && <DuplicatedPathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
         </div>
         </div>
 
 
         <div className="custom-control custom-checkbox custom-checkbox-success">
         <div className="custom-control custom-checkbox custom-checkbox-success">
@@ -150,10 +224,17 @@ const PageRenameModal = (props) => {
             <p className="form-text text-muted mt-0">{ t('modal_rename.help.metadata') }</p>
             <p className="form-text text-muted mt-0">{ t('modal_rename.help.metadata') }</p>
           </label>
           </label>
         </div>
         </div>
+        <div> {subordinatedError} </div>
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
-        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} targetPath={pageNameInput} />
-        <button type="button" className="btn btn-primary" onClick={rename}>Rename</button>
+        <ApiErrorMessageList errs={errs} targetPath={pageNameInput} />
+        <button
+          type="button"
+          className="btn btn-primary"
+          onClick={rename}
+          disabled={(isRenameRecursively && !isRenameRecursivelyWithoutExistPath && existingPaths.length !== 0)}
+        >Rename
+        </button>
       </ModalFooter>
       </ModalFooter>
     </Modal>
     </Modal>
   );
   );

+ 2 - 2
src/client/js/components/PageTimeline.jsx

@@ -62,12 +62,12 @@ class PageTimeline extends React.Component {
   render() {
   render() {
     const { t } = this.props;
     const { t } = this.props;
     const { pages } = this.state;
     const { pages } = this.state;
-    const { path } = this.props.pageContainer.state;
+
     if (pages == null || pages.length === 0) {
     if (pages == null || pages.length === 0) {
       return (
       return (
         <div className="mt-2">
         <div className="mt-2">
           {/* eslint-disable-next-line react/no-danger */}
           {/* eslint-disable-next-line react/no-danger */}
-          <p dangerouslySetInnerHTML={{ __html: t('custom_navigation.no_page_list', { path }) }} />
+          <p>{t('custom_navigation.no_page_list')}</p>
         </div>
         </div>
       );
       );
     }
     }

+ 5 - 8
src/client/js/components/PutbackPageModal.jsx

@@ -11,15 +11,14 @@ import { withUnstatedContainers } from './UnstatedUtils';
 
 
 import PageContainer from '../services/PageContainer';
 import PageContainer from '../services/PageContainer';
 
 
-import ApiErrorMessage from './PageManagement/ApiErrorMessage';
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 
 const PutBackPageModal = (props) => {
 const PutBackPageModal = (props) => {
   const {
   const {
     t, isOpen, onClose, pageContainer, path,
     t, isOpen, onClose, pageContainer, path,
   } = props;
   } = props;
 
 
-  const [errorCode, setErrorCode] = useState(null);
-  const [errorMessage, setErrorMessage] = useState(null);
+  const [errs, setErrs] = useState(null);
 
 
   const [isPutbackRecursively, setIsPutbackRecursively] = useState(true);
   const [isPutbackRecursively, setIsPutbackRecursively] = useState(true);
 
 
@@ -28,8 +27,7 @@ const PutBackPageModal = (props) => {
   }
   }
 
 
   async function putbackPage() {
   async function putbackPage() {
-    setErrorCode(null);
-    setErrorMessage(null);
+    setErrs(null);
 
 
     try {
     try {
       const response = await pageContainer.revertRemove(isPutbackRecursively);
       const response = await pageContainer.revertRemove(isPutbackRecursively);
@@ -37,8 +35,7 @@ const PutBackPageModal = (props) => {
       window.location.href = encodeURI(putbackPagePath);
       window.location.href = encodeURI(putbackPagePath);
     }
     }
     catch (err) {
     catch (err) {
-      setErrorCode(err.code);
-      setErrorMessage(err.message);
+      setErrs(err);
     }
     }
   }
   }
 
 
@@ -73,7 +70,7 @@ const PutBackPageModal = (props) => {
         </div>
         </div>
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
-        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} />
+        <ApiErrorMessageList errs={errs} />
         <button type="button" className="btn btn-info" onClick={putbackPageButtonHandler}>
         <button type="button" className="btn btn-info" onClick={putbackPageButtonHandler}>
           <i className="icon-action-undo mr-2" aria-hidden="true"></i> { t('Put Back') }
           <i className="icon-action-undo mr-2" aria-hidden="true"></i> { t('Put Back') }
         </button>
         </button>

+ 32 - 10
src/client/js/components/SearchTypeahead.jsx

@@ -24,12 +24,14 @@ class SearchTypeahead extends React.Component {
     };
     };
 
 
     this.restoreInitialData = this.restoreInitialData.bind(this);
     this.restoreInitialData = this.restoreInitialData.bind(this);
+    this.clearKeyword = this.clearKeyword.bind(this);
+    this.changeKeyword = this.changeKeyword.bind(this);
     this.search = this.search.bind(this);
     this.search = this.search.bind(this);
     this.onInputChange = this.onInputChange.bind(this);
     this.onInputChange = this.onInputChange.bind(this);
     this.onKeyDown = this.onKeyDown.bind(this);
     this.onKeyDown = this.onKeyDown.bind(this);
     this.dispatchSubmit = this.dispatchSubmit.bind(this);
     this.dispatchSubmit = this.dispatchSubmit.bind(this);
     this.getEmptyLabel = this.getEmptyLabel.bind(this);
     this.getEmptyLabel = this.getEmptyLabel.bind(this);
-    this.getRestoreFormButton = this.getRestoreFormButton.bind(this);
+    this.getResetFormButton = this.getResetFormButton.bind(this);
     this.renderMenuItemChildren = this.renderMenuItemChildren.bind(this);
     this.renderMenuItemChildren = this.renderMenuItemChildren.bind(this);
     this.getTypeahead = this.getTypeahead.bind(this);
     this.getTypeahead = this.getTypeahead.bind(this);
   }
   }
@@ -51,11 +53,24 @@ class SearchTypeahead extends React.Component {
   }
   }
 
 
   /**
   /**
-   * Initialize keyword
+   * Initialize keywordyword
    */
    */
   restoreInitialData() {
   restoreInitialData() {
+    this.changeKeyword(this.props.keywordOnInit);
+  }
+
+  /**
+   * clear keyword
+   */
+  clearKeyword(text) {
+    this.changeKeyword('');
+  }
+
+  /**
+   * change keyword
+   */
+  changeKeyword(text) {
     // see https://github.com/ericgio/react-bootstrap-typeahead/issues/266#issuecomment-414987723
     // see https://github.com/ericgio/react-bootstrap-typeahead/issues/266#issuecomment-414987723
-    const text = this.props.keywordOnInit;
     const instance = this.typeahead.getInstance();
     const instance = this.typeahead.getInstance();
     instance.clear();
     instance.clear();
     instance.setState({ text });
     instance.setState({ text });
@@ -149,11 +164,16 @@ class SearchTypeahead extends React.Component {
   /**
   /**
    * Get restore form button to initialize button
    * Get restore form button to initialize button
    */
    */
-  getRestoreFormButton() {
-    const isHidden = (this.state.input === this.props.keywordOnInit);
-
-    return isHidden ? <span /> : (
-      <button type="button" className="btn btn-link search-clear" onMouseDown={this.restoreInitialData}>
+  getResetFormButton() {
+    const isClearBtn = this.props.behaviorOfResetBtn === 'clear';
+    const initialKeyword = isClearBtn ? '' : this.props.keywordOnInit;
+    const isHidden = this.state.input === initialKeyword;
+    const resetForm = isClearBtn ? this.clearKeyword : this.restoreInitialData;
+
+    return isHidden ? (
+      <span />
+    ) : (
+      <button type="button" className="btn btn-link search-clear" onMouseDown={resetForm}>
         <i className="icon-close" />
         <i className="icon-close" />
       </button>
       </button>
     );
     );
@@ -179,7 +199,7 @@ class SearchTypeahead extends React.Component {
       inputProps.name = this.props.inputName;
       inputProps.name = this.props.inputName;
     }
     }
 
 
-    const restoreFormButton = this.getRestoreFormButton();
+    const resetFormButton = this.getResetFormButton();
 
 
     return (
     return (
       <div className="search-typeahead">
       <div className="search-typeahead">
@@ -203,7 +223,7 @@ class SearchTypeahead extends React.Component {
           caseSensitive={false}
           caseSensitive={false}
           defaultSelected={defaultSelected}
           defaultSelected={defaultSelected}
         />
         />
-        {restoreFormButton}
+        {resetFormButton}
       </div>
       </div>
     );
     );
   }
   }
@@ -232,6 +252,7 @@ SearchTypeahead.propTypes = {
   placeholder:     PropTypes.string,
   placeholder:     PropTypes.string,
   keywordOnInit:   PropTypes.string,
   keywordOnInit:   PropTypes.string,
   helpElement:     PropTypes.object,
   helpElement:     PropTypes.object,
+  behaviorOfResetBtn: PropTypes.oneOf(['restore', 'clear']),
 };
 };
 
 
 /**
 /**
@@ -243,6 +264,7 @@ SearchTypeahead.defaultProps = {
   onChange:        noop,
   onChange:        noop,
   placeholder:     '',
   placeholder:     '',
   keywordOnInit:   '',
   keywordOnInit:   '',
+  behaviorOfResetBtn: 'restore',
   onInputChange: () => {},
   onInputChange: () => {},
 };
 };
 
 

+ 7 - 6
src/client/js/components/SlackNotification.jsx

@@ -2,6 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
+import { UncontrolledPopover, PopoverHeader, PopoverBody } from 'reactstrap';
 /**
 /**
  *
  *
  * @author Yuki Takei <yuki@weseek.co.jp>
  * @author Yuki Takei <yuki@weseek.co.jp>
@@ -15,7 +16,7 @@ class SlackNotification extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
-
+    this.idForSlackPopover = `${this.props.id}ForSlackPopover`;
     this.updateCheckboxHandler = this.updateCheckboxHandler.bind(this);
     this.updateCheckboxHandler = this.updateCheckboxHandler.bind(this);
     this.updateSlackChannelsHandler = this.updateSlackChannelsHandler.bind(this);
     this.updateSlackChannelsHandler = this.updateSlackChannelsHandler.bind(this);
   }
   }
@@ -55,16 +56,16 @@ class SlackNotification extends React.Component {
           </label>
           </label>
           <input
           <input
             className="grw-form-control-slack-notification form-control align-top pl-0"
             className="grw-form-control-slack-notification form-control align-top pl-0"
+            id={this.idForSlackPopover}
             type="text"
             type="text"
             value={this.props.slackChannels}
             value={this.props.slackChannels}
             placeholder="Input channels"
             placeholder="Input channels"
-            data-toggle="popover"
-            title={t('slack_notification.popover_title')}
-            data-content={t('slack_notification.popover_desc')}
-            data-trigger="focus"
-            data-placement="top"
             onChange={this.updateSlackChannelsHandler}
             onChange={this.updateSlackChannelsHandler}
           />
           />
+          <UncontrolledPopover trigger="focus" placement="top" target={this.idForSlackPopover}>
+            <PopoverHeader>{t('slack_notification.popover_title')}</PopoverHeader>
+            <PopoverBody>{t('slack_notification.popover_desc')}</PopoverBody>
+          </UncontrolledPopover>
         </div>
         </div>
       </div>
       </div>
     );
     );

+ 21 - 0
src/client/js/services/AdminAppContainer.js

@@ -46,12 +46,14 @@ export default class AdminAppContainer extends Container {
       envGcsBucket: '',
       envGcsBucket: '',
       gcsUploadNamespace: '',
       gcsUploadNamespace: '',
       envGcsUploadNamespace: '',
       envGcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
 
 
       s3Region: '',
       s3Region: '',
       s3CustomEndpoint: '',
       s3CustomEndpoint: '',
       s3Bucket: '',
       s3Bucket: '',
       s3AccessKeyId: '',
       s3AccessKeyId: '',
       s3SecretAccessKey: '',
       s3SecretAccessKey: '',
+      s3ReferenceFileWithRelayMode: false,
 
 
       isEnabledPlugins: true,
       isEnabledPlugins: true,
     };
     };
@@ -99,10 +101,13 @@ export default class AdminAppContainer extends Container {
       s3Bucket: appSettingsParams.s3Bucket,
       s3Bucket: appSettingsParams.s3Bucket,
       s3AccessKeyId: appSettingsParams.s3AccessKeyId,
       s3AccessKeyId: appSettingsParams.s3AccessKeyId,
       s3SecretAccessKey: appSettingsParams.s3SecretAccessKey,
       s3SecretAccessKey: appSettingsParams.s3SecretAccessKey,
+      s3ReferenceFileWithRelayMode: appSettingsParams.s3ReferenceFileWithRelayMode,
+
       gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars,
       gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars,
       gcsApiKeyJsonPath: appSettingsParams.gcsApiKeyJsonPath,
       gcsApiKeyJsonPath: appSettingsParams.gcsApiKeyJsonPath,
       gcsBucket: appSettingsParams.gcsBucket,
       gcsBucket: appSettingsParams.gcsBucket,
       gcsUploadNamespace: appSettingsParams.gcsUploadNamespace,
       gcsUploadNamespace: appSettingsParams.gcsUploadNamespace,
+      gcsReferenceFileWithRelayMode: appSettingsParams.gcsReferenceFileWithRelayMode,
       envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
       envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
       envGcsBucket: appSettingsParams.envGcsBucket,
       envGcsBucket: appSettingsParams.envGcsBucket,
       envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
       envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
@@ -238,6 +243,13 @@ export default class AdminAppContainer extends Container {
     this.setState({ s3SecretAccessKey });
     this.setState({ s3SecretAccessKey });
   }
   }
 
 
+  /**
+   * Change s3ReferenceFileWithRelayMode
+   */
+  changeS3ReferenceFileWithRelayMode(s3ReferenceFileWithRelayMode) {
+    this.setState({ s3ReferenceFileWithRelayMode });
+  }
+
   /**
   /**
    * Change gcsApiKeyJsonPath
    * Change gcsApiKeyJsonPath
    */
    */
@@ -259,6 +271,13 @@ export default class AdminAppContainer extends Container {
     this.setState({ gcsUploadNamespace });
     this.setState({ gcsUploadNamespace });
   }
   }
 
 
+  /**
+   * Change gcsReferenceFileWithRelayMode
+   */
+  changeGcsReferenceFileWithRelayMode(gcsReferenceFileWithRelayMode) {
+    this.setState({ gcsReferenceFileWithRelayMode });
+  }
+
   /**
   /**
    * Change secret key
    * Change secret key
    */
    */
@@ -367,6 +386,7 @@ export default class AdminAppContainer extends Container {
       requestParams.gcsApiKeyJsonPath = this.state.gcsApiKeyJsonPath;
       requestParams.gcsApiKeyJsonPath = this.state.gcsApiKeyJsonPath;
       requestParams.gcsBucket = this.state.gcsBucket;
       requestParams.gcsBucket = this.state.gcsBucket;
       requestParams.gcsUploadNamespace = this.state.gcsUploadNamespace;
       requestParams.gcsUploadNamespace = this.state.gcsUploadNamespace;
+      requestParams.gcsReferenceFileWithRelayMode = this.state.gcsReferenceFileWithRelayMode;
     }
     }
 
 
     if (fileUploadType === 'aws') {
     if (fileUploadType === 'aws') {
@@ -375,6 +395,7 @@ export default class AdminAppContainer extends Container {
       requestParams.s3Bucket = this.state.s3Bucket;
       requestParams.s3Bucket = this.state.s3Bucket;
       requestParams.s3AccessKeyId = this.state.s3AccessKeyId;
       requestParams.s3AccessKeyId = this.state.s3AccessKeyId;
       requestParams.s3SecretAccessKey = this.state.s3SecretAccessKey;
       requestParams.s3SecretAccessKey = this.state.s3SecretAccessKey;
+      requestParams.s3ReferenceFileWithRelayMode = this.state.s3ReferenceFileWithRelayMode;
     }
     }
 
 
     const response = await this.appContainer.apiv3.put('/app-settings/file-upload-setting', requestParams);
     const response = await this.appContainer.apiv3.put('/app-settings/file-upload-setting', requestParams);

+ 14 - 16
src/client/js/services/PageContainer.js

@@ -468,11 +468,10 @@ export default class PageContainer extends Container {
       body: markdown,
       body: markdown,
     });
     });
 
 
-    const res = await this.appContainer.apiPost('/pages.create', params);
-    if (!res.ok) {
-      throw new Error(res.error);
-    }
-    return res;
+    const res = await this.appContainer.apiv3Post('/pages/', params);
+    const { page, tags } = res.data;
+
+    return { page, tags };
   }
   }
 
 
   async updatePage(pageId, revisionId, markdown, tmpParams) {
   async updatePage(pageId, revisionId, markdown, tmpParams) {
@@ -523,19 +522,18 @@ export default class PageContainer extends Container {
     });
     });
   }
   }
 
 
-  rename(pageNameInput, isRenameRecursively, isRenameRedirect, isRenameMetadata) {
+  rename(newPagePath, isRecursively, isRenameRedirect, isRemainMetadata) {
     const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
     const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-    const isRecursively = isRenameRecursively ? true : null;
-    const isRedirect = isRenameRedirect ? true : null;
-    const isRemain = isRenameMetadata ? true : null;
+    const { pageId, revisionId, path } = this.state;
 
 
-    return this.appContainer.apiPost('/pages.rename', {
-      recursively: isRecursively,
-      page_id: this.state.pageId,
-      revision_id: this.state.revisionId,
-      new_path: pageNameInput,
-      create_redirect: isRedirect,
-      remain_metadata: isRemain,
+    return this.appContainer.apiv3Put('/pages/rename', {
+      revisionId,
+      pageId,
+      isRecursively,
+      isRenameRedirect,
+      isRemainMetadata,
+      newPagePath,
+      path,
       socketClientId: socketIoContainer.getSocketClientId(),
       socketClientId: socketIoContainer.getSocketClientId(),
     });
     });
   }
   }

+ 1 - 0
src/client/styles/scss/_linkedit-preview.scss

@@ -5,6 +5,7 @@
     margin: 0px -10px 0px -10px;
     margin: 0px -10px 0px -10px;
     .wiki {
     .wiki {
       overflow-y: scroll;
       overflow-y: scroll;
+      font-size: 0.5rem;
     }
     }
   }
   }
 }
 }

+ 9 - 0
src/client/styles/scss/_page-duplicate-modal.scss

@@ -0,0 +1,9 @@
+.grw-duplicate-page {
+  .duplicate-name {
+    list-style: none;
+  }
+
+  .duplicate-exist {
+    color: #c7254e;
+  }
+}

+ 8 - 6
src/client/styles/scss/_search.scss

@@ -32,12 +32,6 @@
   .rbt-menu {
   .rbt-menu {
     max-height: none !important;
     max-height: none !important;
     margin-top: 3px;
     margin-top: 3px;
-    @extend .dropdown-menu-right;
-    @extend .dropdown-menu-md-left;
-    @include media-breakpoint-down(sm) {
-      left: auto !important;
-      width: 90vw;
-    }
 
 
     li a span {
     li a span {
       .page-path {
       .page-path {
@@ -82,6 +76,14 @@
       border-top-right-radius: 40px;
       border-top-right-radius: 40px;
       border-bottom-right-radius: 40px;
       border-bottom-right-radius: 40px;
     }
     }
+    .rbt-menu {
+      @extend .dropdown-menu-right;
+      @extend .dropdown-menu-md-left;
+      @include media-breakpoint-down(sm) {
+        left: auto !important;
+        width: 90vw;
+      }
+    }
   }
   }
 
 
   // using react-bootstrap-typeahead
   // using react-bootstrap-typeahead

+ 5 - 0
src/client/styles/scss/molecules/compare-paths-table.scss

@@ -0,0 +1,5 @@
+.grw-compare-paths-table {
+  tbody {
+    max-height: 200px;
+  }
+}

+ 5 - 0
src/client/styles/scss/molecules/duplicated-paths-table.scss

@@ -0,0 +1,5 @@
+.grw-duplicated-paths-table {
+  tbody {
+    max-height: 200px;
+  }
+}

+ 3 - 0
src/client/styles/scss/style-app.scss

@@ -25,6 +25,8 @@
 @import 'molecules/copy-dropdown';
 @import 'molecules/copy-dropdown';
 @import 'molecules/page-editor-mode-manager';
 @import 'molecules/page-editor-mode-manager';
 @import 'molecules/slack-notification';
 @import 'molecules/slack-notification';
+@import 'molecules/duplicated-paths-table.scss';
+@import 'molecules/compare-paths-table.scss';
 
 
 // growi component
 // growi component
 @import 'admin';
 @import 'admin';
@@ -45,6 +47,7 @@
 @import 'modal';
 @import 'modal';
 @import 'navbar';
 @import 'navbar';
 @import 'on-edit';
 @import 'on-edit';
+@import 'page-duplicate-modal';
 @import 'page_list';
 @import 'page_list';
 @import 'page-accessories-control';
 @import 'page-accessories-control';
 @import 'page-accessories-modal';
 @import 'page-accessories-modal';

+ 4 - 6
src/client/styles/scss/theme/_apply-colors-dark.scss

@@ -47,12 +47,6 @@ textarea.form-control {
   // border: 1px solid darken($border, 30%);
   // border: 1px solid darken($border, 30%);
 }
 }
 
 
-.grw-slack-notification {
-  .form-control {
-    background: $bgcolor-global;
-  }
-}
-
 .form-control[disabled],
 .form-control[disabled],
 .form-control[readonly] {
 .form-control[readonly] {
   color: lighten($color-global, 10%);
   color: lighten($color-global, 10%);
@@ -317,6 +311,10 @@ ul.pagination {
   background-color: transparent;
   background-color: transparent;
   $color-slack: #4b144c;
   $color-slack: #4b144c;
 
 
+  .form-control {
+    background: $bgcolor-global;
+  }
+
   .custom-control-label {
   .custom-control-label {
     &::before {
     &::before {
       background-color: $secondary;
       background-color: $secondary;

+ 6 - 6
src/client/styles/scss/theme/_apply-colors-light.scss

@@ -38,12 +38,6 @@ $border-color: $border-color-global;
   background-color: $bgcolor-global;
   background-color: $bgcolor-global;
 }
 }
 
 
-.grw-slack-notification {
-  .form-control {
-    background: white;
-  }
-}
-
 .form-control::placeholder {
 .form-control::placeholder {
   color: darken($bgcolor-global, 20%);
   color: darken($bgcolor-global, 20%);
 }
 }
@@ -191,6 +185,8 @@ $border-color: $border-color-global;
  * GROWI on-edit
  * GROWI on-edit
  */
  */
 .grw-editor-navbar-bottom {
 .grw-editor-navbar-bottom {
+  background-color: $gray-50;
+
   #slack-mark-white {
   #slack-mark-white {
     display: none;
     display: none;
   }
   }
@@ -222,6 +218,10 @@ $border-color: $border-color-global;
   background-color: white;
   background-color: white;
   $color-slack: #4b144c;
   $color-slack: #4b144c;
 
 
+  .form-control {
+    background: white;
+  }
+
   .custom-control-label {
   .custom-control-label {
     &::before {
     &::before {
       background-color: $gray-200;
       background-color: $gray-200;

+ 0 - 4
src/client/styles/scss/theme/_apply-colors.scss

@@ -433,10 +433,6 @@ body.on-edit {
       border-top-color: $border-color-theme;
       border-top-color: $border-color-theme;
     }
     }
   }
   }
-
-  .grw-editor-navbar-bottom {
-    background-color: $gray-50;
-  }
 }
 }
 
 
 /*
 /*

+ 19 - 0
src/lib/util/path-utils.js

@@ -1,3 +1,5 @@
+const escapeStringRegexp = require('escape-string-regexp');
+
 /**
 /**
  * Whether path is the top page
  * Whether path is the top page
  * @param {string} path
  * @param {string} path
@@ -47,9 +49,26 @@ const userPageRoot = (user) => {
   return `/user/${user.username}`;
   return `/user/${user.username}`;
 };
 };
 
 
+/**
+ * return user path
+ * @param {string} parentPath
+ * @param {string} childPath
+ * @param {string} newPath
+ *
+ * @return {string}
+ */
+const convertToNewAffiliationPath = (oldPath, newPath, childPath) => {
+  if (newPath === null) {
+    throw new Error('Please input the new page path');
+  }
+  const pathRegExp = new RegExp(`^${escapeStringRegexp(oldPath)}`, 'i');
+  return childPath.replace(pathRegExp, newPath);
+};
+
 module.exports = {
 module.exports = {
   isTopPage,
   isTopPage,
   isTrashPage,
   isTrashPage,
   isUserPage,
   isUserPage,
   userPageRoot,
   userPageRoot,
+  convertToNewAffiliationPath,
 };
 };

+ 15 - 0
src/lib/util/to-array-from-csv.js

@@ -0,0 +1,15 @@
+// converts csv item to array
+const toArrayFromCsv = (text) => {
+  let array = [];
+
+  if (text == null) {
+    return array;
+  }
+
+  array = text.split(',').map(el => el.trim());
+  array = array.filter(el => el !== '');
+
+  return array;
+};
+
+module.exports = toArrayFromCsv;

+ 17 - 1
src/server/crowi/index.js

@@ -41,6 +41,7 @@ function Crowi(rootdir) {
   this.mailService = null;
   this.mailService = null;
   this.passportService = null;
   this.passportService = null;
   this.globalNotificationService = null;
   this.globalNotificationService = null;
+  this.userNotificationService = null;
   this.slackNotificationService = null;
   this.slackNotificationService = null;
   this.xssService = null;
   this.xssService = null;
   this.aclService = null;
   this.aclService = null;
@@ -120,6 +121,7 @@ Crowi.prototype.init = async function() {
   // globalNotification depends on slack and mailer
   // globalNotification depends on slack and mailer
   await Promise.all([
   await Promise.all([
     this.setUpGlobalNotification(),
     this.setUpGlobalNotification(),
+    this.setUpUserNotification(),
   ]);
   ]);
 };
 };
 
 
@@ -132,7 +134,7 @@ Crowi.prototype.initForTest = async function() {
   // // slack depends on setUpSlacklNotification
   // // slack depends on setUpSlacklNotification
   await Promise.all([
   await Promise.all([
     this.setUpApp(),
     this.setUpApp(),
-    // this.setUpXss(),
+    this.setUpXss(),
     // this.setUpSlacklNotification(),
     // this.setUpSlacklNotification(),
     // this.setUpGrowiBridge(),
     // this.setUpGrowiBridge(),
   ]);
   ]);
@@ -316,6 +318,10 @@ Crowi.prototype.getGlobalNotificationService = function() {
   return this.globalNotificationService;
   return this.globalNotificationService;
 };
 };
 
 
+Crowi.prototype.getUserNotificationService = function() {
+  return this.userNotificationService;
+};
+
 Crowi.prototype.getRestQiitaAPIService = function() {
 Crowi.prototype.getRestQiitaAPIService = function() {
   return this.restQiitaAPIService;
   return this.restQiitaAPIService;
 };
 };
@@ -481,6 +487,16 @@ Crowi.prototype.setUpGlobalNotification = async function() {
   }
   }
 };
 };
 
 
+/**
+ * setup UserNotificationService
+ */
+Crowi.prototype.setUpUserNotification = async function() {
+  const UserNotificationService = require('../service/user-notification');
+  if (this.userNotificationService == null) {
+    this.userNotificationService = new UserNotificationService(this);
+  }
+};
+
 /**
 /**
  * setup SlackNotificationService
  * setup SlackNotificationService
  */
  */

+ 32 - 0
src/server/middlewares/auto-reconnect-to-search.js

@@ -0,0 +1,32 @@
+const loggerFactory = require('@alias/logger');
+
+const { ReconnectContext, nextTick } = require('../service/search-reconnect-context/reconnect-context');
+
+const logger = loggerFactory('growi:middlewares:auto-reconnect-to-search');
+
+module.exports = (crowi) => {
+  const { searchService } = crowi;
+  const reconnectContext = new ReconnectContext();
+
+  const reconnectHandler = async() => {
+    try {
+      logger.info('Auto reconnection is started.');
+      await searchService.reconnectClient();
+    }
+    catch (err) {
+      logger.error('Auto reconnection failed.');
+    }
+
+    return searchService.isReachable;
+  };
+
+  return (req, res, next) => {
+    if (searchService != null && !searchService.isReachable) {
+      // NON-BLOCKING CALL
+      // for the latency of the response
+      nextTick(reconnectContext, reconnectHandler);
+    }
+
+    return next();
+  };
+};

+ 24 - 0
src/server/models/attachment.js

@@ -8,6 +8,7 @@ const path = require('path');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const uniqueValidator = require('mongoose-unique-validator');
 const uniqueValidator = require('mongoose-unique-validator');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const mongoosePaginate = require('mongoose-paginate-v2');
+const { addSeconds } = require('date-fns');
 
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
 
@@ -28,6 +29,8 @@ module.exports = function(crowi) {
     fileFormat: { type: String, required: true },
     fileFormat: { type: String, required: true },
     fileSize: { type: Number, default: 0 },
     fileSize: { type: Number, default: 0 },
     createdAt: { type: Date, default: Date.now },
     createdAt: { type: Date, default: Date.now },
+    temporaryUrlCached: { type: String },
+    temporaryUrlExpiredAt: { type: Date },
   });
   });
   attachmentSchema.plugin(uniqueValidator);
   attachmentSchema.plugin(uniqueValidator);
   attachmentSchema.plugin(mongoosePaginate);
   attachmentSchema.plugin(mongoosePaginate);
@@ -66,5 +69,26 @@ module.exports = function(crowi) {
   };
   };
 
 
 
 
+  attachmentSchema.methods.getValidTemporaryUrl = function() {
+    if (this.temporaryUrlExpiredAt == null) {
+      return null;
+    }
+    // return null when expired url
+    if (this.temporaryUrlExpiredAt.getTime() < new Date().getTime()) {
+      return null;
+    }
+    return this.temporaryUrlCached;
+  };
+
+  attachmentSchema.methods.cashTemporaryUrlByProvideSec = function(temporaryUrl, provideSec) {
+    if (temporaryUrl == null) {
+      throw new Error('url is required.');
+    }
+    this.temporaryUrlCached = temporaryUrl;
+    this.temporaryUrlExpiredAt = addSeconds(new Date(), provideSec);
+
+    return this.save();
+  };
+
   return mongoose.model('Attachment', attachmentSchema);
   return mongoose.model('Attachment', attachmentSchema);
 };
 };

+ 25 - 2
src/server/models/page.js

@@ -258,6 +258,17 @@ class PageQueryBuilder {
     return this;
     return this;
   }
   }
 
 
+  addConditionToListByPathsArray(paths) {
+    this.query = this.query
+      .and({
+        path: {
+          $in: paths,
+        },
+      });
+
+    return this;
+  }
+
   populateDataToList(userPublicFields) {
   populateDataToList(userPublicFields) {
     this.query = this.query
     this.query = this.query
       .populate({
       .populate({
@@ -1283,12 +1294,24 @@ module.exports = function(crowi) {
     // find manageable descendants
     // find manageable descendants
     const pages = await this.findManageableListWithDescendants(targetPage, user, options);
     const pages = await this.findManageableListWithDescendants(targetPage, user, options);
 
 
-    await Promise.all(pages.map((page) => {
+    // TODO GW-4634 use stream
+    const promise = pages.map((page) => {
       const newPagePath = page.path.replace(pathRegExp, newPagePathPrefix);
       const newPagePath = page.path.replace(pathRegExp, newPagePathPrefix);
       return this.rename(page, newPagePath, user, options);
       return this.rename(page, newPagePath, user, options);
-    }));
+    });
+
+    await Promise.allSettled(promise);
+
     targetPage.path = newPagePathPrefix;
     targetPage.path = newPagePathPrefix;
     return targetPage;
     return targetPage;
+
+  };
+
+  pageSchema.statics.findListByPathsArray = async function(paths) {
+    const queryBuilder = new PageQueryBuilder(this.find());
+    queryBuilder.addConditionToListByPathsArray(paths);
+
+    return await queryBuilder.query.exec();
   };
   };
 
 
   // TODO: transplant to service/page.js because page deletion affects various models data
   // TODO: transplant to service/page.js because page deletion affects various models data

+ 1 - 1
src/server/routes/admin.js

@@ -325,7 +325,7 @@ module.exports = function(crowi, app) {
 
 
   api.validators.export.download = function() {
   api.validators.export.download = function() {
     const validator = [
     const validator = [
-      // https://regex101.com/r/mD4eZs/4
+      // https://regex101.com/r/mD4eZs/6
       // prevent from pass traversal attack
       // prevent from pass traversal attack
       param('fileName').not().matches(/(\.\.\/|\.\.\\)/),
       param('fileName').not().matches(/(\.\.\/|\.\.\\)/),
     ];
     ];

+ 21 - 5
src/server/routes/apiv3/app-settings.js

@@ -93,21 +93,24 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *          fileUploadType:
  *          fileUploadType:
  *            type: string
  *            type: string
  *            description: fileUploadType
  *            description: fileUploadType
- *          region:
+ *          s3Region:
  *            type: string
  *            type: string
  *            description: region of AWS S3
  *            description: region of AWS S3
- *          customEndpoint:
+ *          s3CustomEndpoint:
  *            type: string
  *            type: string
  *            description: custom endpoint of AWS S3
  *            description: custom endpoint of AWS S3
- *          bucket:
+ *          s3Bucket:
  *            type: string
  *            type: string
  *            description: AWS S3 bucket name
  *            description: AWS S3 bucket name
- *          accessKeyId:
+ *          s3AccessKeyId:
  *            type: string
  *            type: string
  *            description: accesskey id for authentification of AWS
  *            description: accesskey id for authentification of AWS
- *          secretAccessKey:
+ *          s3SecretAccessKey:
  *            type: string
  *            type: string
  *            description: secret key for authentification of AWS
  *            description: secret key for authentification of AWS
+ *          s3ReferenceFileWithRelayMode:
+ *            type: boolean
+ *            description: is enable internal stream system for s3 file request
  *          gcsApiKeyJsonPath:
  *          gcsApiKeyJsonPath:
  *            type: string
  *            type: string
  *            description: apiKeyJsonPath of gcp
  *            description: apiKeyJsonPath of gcp
@@ -117,6 +120,9 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *          gcsUploadNamespace:
  *          gcsUploadNamespace:
  *            type: string
  *            type: string
  *            description: name space of gcs
  *            description: name space of gcs
+ *          gcsReferenceFileWithRelayMode:
+ *            type: boolean
+ *            description: is enable internal stream system for gcs file request
  *          envGcsApiKeyJsonPath:
  *          envGcsApiKeyJsonPath:
  *            type: string
  *            type: string
  *            description: Path of the JSON file that contains service account key to authenticate to GCP API
  *            description: Path of the JSON file that contains service account key to authenticate to GCP API
@@ -171,6 +177,7 @@ module.exports = (crowi) => {
       body('gcsApiKeyJsonPath').trim(),
       body('gcsApiKeyJsonPath').trim(),
       body('gcsBucket').trim(),
       body('gcsBucket').trim(),
       body('gcsUploadNamespace').trim(),
       body('gcsUploadNamespace').trim(),
+      body('gcsReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
       body('s3Region').trim().if(value => value !== '').matches(/^[a-z]+-[a-z]+-\d+$/)
       body('s3Region').trim().if(value => value !== '').matches(/^[a-z]+-[a-z]+-\d+$/)
         .withMessage((value, { req }) => req.t('validation.aws_region')),
         .withMessage((value, { req }) => req.t('validation.aws_region')),
       body('s3CustomEndpoint').trim().if(value => value !== '').matches(/^(https?:\/\/[^/]+|)$/)
       body('s3CustomEndpoint').trim().if(value => value !== '').matches(/^(https?:\/\/[^/]+|)$/)
@@ -178,6 +185,7 @@ module.exports = (crowi) => {
       body('s3Bucket').trim(),
       body('s3Bucket').trim(),
       body('s3AccessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
       body('s3AccessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
       body('s3SecretAccessKey').trim(),
       body('s3SecretAccessKey').trim(),
+      body('s3ReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
     ],
     ],
     pluginSetting: [
     pluginSetting: [
       body('isEnabledPlugins').isBoolean(),
       body('isEnabledPlugins').isBoolean(),
@@ -232,10 +240,14 @@ module.exports = (crowi) => {
       s3Bucket: crowi.configManager.getConfig('crowi', 'aws:s3Bucket'),
       s3Bucket: crowi.configManager.getConfig('crowi', 'aws:s3Bucket'),
       s3AccessKeyId: crowi.configManager.getConfig('crowi', 'aws:s3AccessKeyId'),
       s3AccessKeyId: crowi.configManager.getConfig('crowi', 'aws:s3AccessKeyId'),
       s3SecretAccessKey: crowi.configManager.getConfig('crowi', 'aws:s3SecretAccessKey'),
       s3SecretAccessKey: crowi.configManager.getConfig('crowi', 'aws:s3SecretAccessKey'),
+      s3ReferenceFileWithRelayMode: crowi.configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode'),
+
       gcsUseOnlyEnvVars: crowi.configManager.getConfig('crowi', 'gcs:useOnlyEnvVarsForSomeOptions'),
       gcsUseOnlyEnvVars: crowi.configManager.getConfig('crowi', 'gcs:useOnlyEnvVarsForSomeOptions'),
       gcsApiKeyJsonPath: crowi.configManager.getConfig('crowi', 'gcs:apiKeyJsonPath'),
       gcsApiKeyJsonPath: crowi.configManager.getConfig('crowi', 'gcs:apiKeyJsonPath'),
       gcsBucket: crowi.configManager.getConfig('crowi', 'gcs:bucket'),
       gcsBucket: crowi.configManager.getConfig('crowi', 'gcs:bucket'),
       gcsUploadNamespace: crowi.configManager.getConfig('crowi', 'gcs:uploadNamespace'),
       gcsUploadNamespace: crowi.configManager.getConfig('crowi', 'gcs:uploadNamespace'),
+      gcsReferenceFileWithRelayMode: crowi.configManager.getConfig('crowi', 'gcs:referenceFileWithRelayMode'),
+
       envGcsApiKeyJsonPath: crowi.configManager.getConfigFromEnvVars('crowi', 'gcs:apiKeyJsonPath'),
       envGcsApiKeyJsonPath: crowi.configManager.getConfigFromEnvVars('crowi', 'gcs:apiKeyJsonPath'),
       envGcsBucket: crowi.configManager.getConfigFromEnvVars('crowi', 'gcs:bucket'),
       envGcsBucket: crowi.configManager.getConfigFromEnvVars('crowi', 'gcs:bucket'),
       envGcsUploadNamespace: crowi.configManager.getConfigFromEnvVars('crowi', 'gcs:uploadNamespace'),
       envGcsUploadNamespace: crowi.configManager.getConfigFromEnvVars('crowi', 'gcs:uploadNamespace'),
@@ -581,6 +593,7 @@ module.exports = (crowi) => {
       requestParams['gcs:apiKeyJsonPath'] = req.body.gcsApiKeyJsonPath;
       requestParams['gcs:apiKeyJsonPath'] = req.body.gcsApiKeyJsonPath;
       requestParams['gcs:bucket'] = req.body.gcsBucket;
       requestParams['gcs:bucket'] = req.body.gcsBucket;
       requestParams['gcs:uploadNamespace'] = req.body.gcsUploadNamespace;
       requestParams['gcs:uploadNamespace'] = req.body.gcsUploadNamespace;
+      requestParams['gcs:referenceFileWithRelayMode'] = req.body.gcsReferenceFileWithRelayMode;
     }
     }
 
 
     if (fileUploadType === 'aws') {
     if (fileUploadType === 'aws') {
@@ -589,6 +602,7 @@ module.exports = (crowi) => {
       requestParams['aws:s3Bucket'] = req.body.s3Bucket;
       requestParams['aws:s3Bucket'] = req.body.s3Bucket;
       requestParams['aws:s3AccessKeyId'] = req.body.s3AccessKeyId;
       requestParams['aws:s3AccessKeyId'] = req.body.s3AccessKeyId;
       requestParams['aws:s3SecretAccessKey'] = req.body.s3SecretAccessKey;
       requestParams['aws:s3SecretAccessKey'] = req.body.s3SecretAccessKey;
+      requestParams['aws:referenceFileWithRelayMode'] = req.body.s3ReferenceFileWithRelayMode;
     }
     }
 
 
     try {
     try {
@@ -604,6 +618,7 @@ module.exports = (crowi) => {
         responseParams.gcsApiKeyJsonPath = crowi.configManager.getConfig('crowi', 'gcs:apiKeyJsonPath');
         responseParams.gcsApiKeyJsonPath = crowi.configManager.getConfig('crowi', 'gcs:apiKeyJsonPath');
         responseParams.gcsBucket = crowi.configManager.getConfig('crowi', 'gcs:bucket');
         responseParams.gcsBucket = crowi.configManager.getConfig('crowi', 'gcs:bucket');
         responseParams.gcsUploadNamespace = crowi.configManager.getConfig('crowi', 'gcs:uploadNamespace');
         responseParams.gcsUploadNamespace = crowi.configManager.getConfig('crowi', 'gcs:uploadNamespace');
+        responseParams.gcsReferenceFileWithRelayMode = crowi.configManager.getConfig('crowi', 'gcs:referenceFileWithRelayMode ');
       }
       }
 
 
       if (fileUploadType === 'aws') {
       if (fileUploadType === 'aws') {
@@ -612,6 +627,7 @@ module.exports = (crowi) => {
         responseParams.s3Bucket = crowi.configManager.getConfig('crowi', 'aws:s3Bucket');
         responseParams.s3Bucket = crowi.configManager.getConfig('crowi', 'aws:s3Bucket');
         responseParams.s3AccessKeyId = crowi.configManager.getConfig('crowi', 'aws:s3AccessKeyId');
         responseParams.s3AccessKeyId = crowi.configManager.getConfig('crowi', 'aws:s3AccessKeyId');
         responseParams.s3SecretAccessKey = crowi.configManager.getConfig('crowi', 'aws:s3SecretAccessKey');
         responseParams.s3SecretAccessKey = crowi.configManager.getConfig('crowi', 'aws:s3SecretAccessKey');
+        responseParams.s3ReferenceFileWithRelayMode = crowi.configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
       }
       }
 
 
       return res.apiv3({ responseParams });
       return res.apiv3({ responseParams });

+ 1 - 1
src/server/routes/apiv3/export.js

@@ -62,7 +62,7 @@ module.exports = (crowi) => {
 
 
   const validator = {
   const validator = {
     deleteFile: [
     deleteFile: [
-      // https://regex101.com/r/mD4eZs/4
+      // https://regex101.com/r/mD4eZs/6
       // prevent from unexpecting attack doing delete file (path traversal attack)
       // prevent from unexpecting attack doing delete file (path traversal attack)
       param('fileName').not().matches(/(\.\.\/|\.\.\\)/),
       param('fileName').not().matches(/(\.\.\/|\.\.\\)/),
     ],
     ],

+ 56 - 26
src/server/routes/apiv3/healthcheck.js

@@ -48,6 +48,32 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  */
  */
 
 
 module.exports = (crowi) => {
 module.exports = (crowi) => {
+
+  async function checkMongo(errors, info) {
+    try {
+      const Config = crowi.models.Config;
+      await Config.findOne({});
+
+      info.mongo = 'OK';
+    }
+    catch (err) {
+      errors.push(new ErrorV3(`MongoDB is not connectable - ${err.message}`, 'healthcheck-mongodb-unhealthy', err.stack));
+    }
+  }
+
+  async function checkSearch(errors, info) {
+    const { searchService } = crowi;
+    if (searchService.isConfigured) {
+      try {
+        info.searchInfo = await searchService.getInfoForHealth();
+        searchService.resetErrorStatus();
+      }
+      catch (err) {
+        errors.push(new ErrorV3(`The Search Service is not connectable - ${err.message}`, 'healthcheck-search-unhealthy', err.stack));
+      }
+    }
+  }
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *
@@ -58,14 +84,19 @@ module.exports = (crowi) => {
    *      summary: /healthcheck
    *      summary: /healthcheck
    *      description: Check whether the server is healthy or not
    *      description: Check whether the server is healthy or not
    *      parameters:
    *      parameters:
-   *        - name: connectToMiddlewares
+   *        - name: checkServices
    *          in: query
    *          in: query
-   *          description: Check MongoDB and SearchService (consider as healthy even if any of middleware is available or not)
+   *          description: The list of services to check health
    *          schema:
    *          schema:
-   *            type: boolean
-   *        - name: checkMiddlewaresStrictly
+   *            type: array
+   *            items:
+   *              type: string
+   *              enum:
+   *                - mongo
+   *                - search
+   *        - name: strictly
    *          in: query
    *          in: query
-   *          description: Check MongoDB and SearchService and responds 503 if either of these is unhealthy
+   *          description: Check services and responds 503 if either of these is unhealthy
    *          schema:
    *          schema:
    *            type: boolean
    *            type: boolean
    *      responses:
    *      responses:
@@ -92,11 +123,22 @@ module.exports = (crowi) => {
    *                    $ref: '#/components/schemas/HealthcheckInfo'
    *                    $ref: '#/components/schemas/HealthcheckInfo'
    */
    */
   router.get('/', helmet.noCache(), async(req, res) => {
   router.get('/', helmet.noCache(), async(req, res) => {
-    const connectToMiddlewares = req.query.connectToMiddlewares != null;
-    const checkMiddlewaresStrictly = req.query.checkMiddlewaresStrictly != null;
+    let checkServices = req.query.checkServices || [];
+    let isStrictly = req.query.strictly != null;
 
 
-    // return 200 w/o connecting to MongoDB and SearchService
-    if (!connectToMiddlewares && !checkMiddlewaresStrictly) {
+    // for backward compatibility
+    if (req.query.connectToMiddlewares != null) {
+      logger.warn('The param \'connectToMiddlewares\' is deprecated. Use \'checkServices[]\' instead.');
+      checkServices = ['mongo', 'search'];
+    }
+    if (req.query.checkMiddlewaresStrictly != null) {
+      logger.warn('The param \'checkMiddlewaresStrictly\' is deprecated. Use \'checkServices[]\' and \'strictly\' instead.');
+      checkServices = ['mongo', 'search'];
+      isStrictly = true;
+    }
+
+    // return 200 w/o checking
+    if (checkServices.length === 0) {
       res.status(200).send({ status: 'OK' });
       res.status(200).send({ status: 'OK' });
       return;
       return;
     }
     }
@@ -105,30 +147,18 @@ module.exports = (crowi) => {
     const info = {};
     const info = {};
 
 
     // connect to MongoDB
     // connect to MongoDB
-    try {
-      const Config = crowi.models.Config;
-      await Config.findOne({});
-
-      info.mongo = 'OK';
-    }
-    catch (err) {
-      errors.push(new ErrorV3(`MongoDB is not connectable - ${err.message}`, 'healthcheck-mongodb-unhealthy', err.stack));
+    if (checkServices.includes('mongo')) {
+      await checkMongo(errors, info);
     }
     }
 
 
     // connect to search service
     // connect to search service
-    const { searchService } = crowi;
-    if (searchService.isConfigured) {
-      try {
-        info.searchInfo = await searchService.getInfoForHealth();
-      }
-      catch (err) {
-        errors.push(new ErrorV3(`The Search Service is not connectable - ${err.message}`, 'healthcheck-search-unhealthy', err.stack));
-      }
+    if (checkServices.includes('search')) {
+      await checkSearch(errors, info);
     }
     }
 
 
     if (errors.length > 0) {
     if (errors.length > 0) {
       let httpStatus = 200;
       let httpStatus = 200;
-      if (checkMiddlewaresStrictly) {
+      if (isStrictly) {
         httpStatus = 503;
         httpStatus = 503;
       }
       }
 
 

+ 2 - 2
src/server/routes/apiv3/notification-setting.js

@@ -229,7 +229,7 @@ module.exports = (crowi) => {
         createdUser: await UpdatePost.create(pathPattern, channel, req.user),
         createdUser: await UpdatePost.create(pathPattern, channel, req.user),
         userNotifications: await UpdatePost.findAll(),
         userNotifications: await UpdatePost.findAll(),
       };
       };
-      return res.apiv3({ responseParams });
+      return res.apiv3({ responseParams }, 201);
     }
     }
     catch (err) {
     catch (err) {
       const msg = 'Error occurred in updating user notification';
       const msg = 'Error occurred in updating user notification';
@@ -326,7 +326,7 @@ module.exports = (crowi) => {
 
 
     try {
     try {
       const createdNotification = await notification.save();
       const createdNotification = await notification.save();
-      return res.apiv3({ createdNotification });
+      return res.apiv3({ createdNotification }, 201);
     }
     }
     catch (err) {
     catch (err) {
       const msg = 'Error occurred in updating global notification';
       const msg = 'Error occurred in updating global notification';

+ 62 - 0
src/server/routes/apiv3/page.js

@@ -7,6 +7,7 @@ const { body, query } = require('express-validator');
 
 
 const router = express.Router();
 const router = express.Router();
 
 
+const { convertToNewAffiliationPath } = require('../../../lib/util/path-utils');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
 
 
@@ -154,6 +155,10 @@ module.exports = (crowi) => {
       body('hierarchyType').isString().isIn(['allSubordinatedPage', 'decideHierarchy']),
       body('hierarchyType').isString().isIn(['allSubordinatedPage', 'decideHierarchy']),
       body('hierarchyValue').isNumeric(),
       body('hierarchyValue').isNumeric(),
     ],
     ],
+    exist: [
+      query('fromPath').isString(),
+      query('toPath').isString(),
+    ],
   };
   };
 
 
   /**
   /**
@@ -315,6 +320,63 @@ module.exports = (crowi) => {
     return stream.pipe(res);
     return stream.pipe(res);
   });
   });
 
 
+  /**
+   * @swagger
+   *
+   *    /page/exist-paths:
+   *      get:
+   *        tags: [Page]
+   *        summary: /page/exist-paths
+   *        description: Get already exist paths
+   *        operationId: getAlreadyExistPaths
+   *        parameters:
+   *          - name: fromPath
+   *            in: query
+   *            description: old parent path
+   *            schema:
+   *              type: string
+   *          - name: toPath
+   *            in: query
+   *            description: new parent path
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Succeeded to retrieve pages.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    existPaths:
+   *                      type: object
+   *                      description: Paths are already exist in DB
+   *          500:
+   *            description: Internal server error.
+   */
+  router.get('/exist-paths', loginRequired, validator.exist, apiV3FormValidator, async(req, res) => {
+    const { fromPath, toPath } = req.query;
+
+    try {
+      const fromPage = await Page.findByPath(fromPath);
+      const fromPageDescendants = await Page.findManageableListWithDescendants(fromPage, req.user);
+
+      const toPathDescendantsArray = fromPageDescendants.map((subordinatedPage) => {
+        return convertToNewAffiliationPath(fromPath, toPath, subordinatedPage.path);
+      });
+
+      const existPages = await Page.findListByPathsArray(toPathDescendantsArray);
+      const existPaths = existPages.map(page => page.path);
+
+      return res.apiv3({ existPaths });
+
+    }
+    catch (err) {
+      logger.error('Failed to get exist path', err);
+      return res.apiv3Err(err, 500);
+    }
+
+  });
+
   // TODO GW-2746 bulk export pages
   // TODO GW-2746 bulk export pages
   // /**
   // /**
   //  * @swagger
   //  * @swagger

+ 500 - 18
src/server/routes/apiv3/pages.js

@@ -2,26 +2,266 @@ const loggerFactory = require('@alias/logger');
 
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const express = require('express');
 const express = require('express');
+const pathUtils = require('growi-commons').pathUtils;
 
 
-const router = express.Router();
+const { body } = require('express-validator/check');
 const { query } = require('express-validator');
 const { query } = require('express-validator');
+const ErrorV3 = require('../../models/vo/error-apiv3');
+
+const router = express.Router();
+
+const LIMIT_FOR_LIST = 10;
 
 
 /**
 /**
  * @swagger
  * @swagger
  *  tags:
  *  tags:
  *    name: Pages
  *    name: Pages
  */
  */
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      Page:
+ *        description: Page
+ *        type: object
+ *        properties:
+ *          _id:
+ *            type: string
+ *            description: page ID
+ *            example: 5e07345972560e001761fa63
+ *          __v:
+ *            type: number
+ *            description: DB record version
+ *            example: 0
+ *          commentCount:
+ *            type: number
+ *            description: count of comments
+ *            example: 3
+ *          createdAt:
+ *            type: string
+ *            description: date created at
+ *            example: 2010-01-01T00:00:00.000Z
+ *          creator:
+ *            $ref: '#/components/schemas/User'
+ *          extended:
+ *            type: object
+ *            description: extend data
+ *            example: {}
+ *          grant:
+ *            type: number
+ *            description: grant
+ *            example: 1
+ *          grantedUsers:
+ *            type: array
+ *            description: granted users
+ *            items:
+ *              type: string
+ *              description: user ID
+ *            example: ["5ae5fccfc5577b0004dbd8ab"]
+ *          lastUpdateUser:
+ *            $ref: '#/components/schemas/User'
+ *          liker:
+ *            type: array
+ *            description: granted users
+ *            items:
+ *              type: string
+ *              description: user ID
+ *            example: []
+ *          path:
+ *            type: string
+ *            description: page path
+ *            example: /
+ *          redirectTo:
+ *            type: string
+ *            description: redirect path
+ *            example: ""
+ *          revision:
+ *            type: string
+ *            description: revision ID
+ *            example: ["5ae5fccfc5577b0004dbd8ab"]
+ *          seenUsers:
+ *            type: array
+ *            description: granted users
+ *            items:
+ *              type: string
+ *              description: user ID
+ *            example: ["5ae5fccfc5577b0004dbd8ab"]
+ *          status:
+ *            type: string
+ *            description: status
+ *            enum:
+ *              - 'wip'
+ *              - 'published'
+ *              - 'deleted'
+ *              - 'deprecated'
+ *            example: published
+ *          updatedAt:
+ *            type: string
+ *            description: date updated at
+ *            example: 2010-01-01T00:00:00.000Z
+ */
+
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
 
-
   const Page = crowi.model('Page');
   const Page = crowi.model('Page');
+  const PageTagRelation = crowi.model('PageTagRelation');
+  const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
+
+  const globalNotificationService = crowi.getGlobalNotificationService();
+  const userNotificationService = crowi.getUserNotificationService();
+
+  const { serializePageSecurely } = require('../../models/serializers/page-serializer');
+
+  const validator = {
+    createPage: [
+      body('body').exists().not().isEmpty({ ignore_whitespace: true })
+        .withMessage('body is required'),
+      body('path').exists().not().isEmpty({ ignore_whitespace: true })
+        .withMessage('path is required'),
+      body('grant').if(value => value != null).isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
+      body('overwriteScopesOfDescendants').if(value => value != null).isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
+      body('isSlackEnabled').if(value => value != null).isBoolean().withMessage('isSlackEnabled must be boolean'),
+      body('slackChannels').if(value => value != null).isString().withMessage('slackChannels must be string'),
+      body('socketClientId').if(value => value != null).isInt().withMessage('socketClientId must be int'),
+      body('pageTags').if(value => value != null).isArray().withMessage('pageTags must be array'),
+    ],
+    renamePage: [
+      body('pageId').isMongoId().withMessage('pageId is required'),
+      body('revisionId').isMongoId().withMessage('revisionId is required'),
+      body('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
+      body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
+      body('isRemainMetadata').if(value => value != null).isBoolean().withMessage('isRemainMetadata must be boolean'),
+      body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
+      body('socketClientId').if(value => value != null).isInt().withMessage('socketClientId must be int'),
+    ],
+
+    duplicatePage: [
+      body('pageId').isMongoId().withMessage('pageId is required'),
+      body('pageNameInput').trim().isLength({ min: 1 }).withMessage('pageNameInput is required'),
+      body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
+    ],
+  };
+
+  async function createPageAction({
+    path, body, user, options,
+  }) {
+    const createdPage = Page.create(path, body, user, options);
+    return createdPage;
+  }
+
+  async function saveTagsAction({ createdPage, pageTags }) {
+    if (pageTags != null) {
+      await PageTagRelation.updatePageTags(createdPage.id, pageTags);
+      return PageTagRelation.listTagNamesByPage(createdPage.id);
+    }
+
+    return [];
+  }
+
+  /**
+   * @swagger
+   *
+   *    /pages/create:
+   *      post:
+   *        tags: [Pages]
+   *        operationId: createPage
+   *        description: Create page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  body:
+   *                    type: string
+   *                    description: Text of page
+   *                  path:
+   *                    $ref: '#/components/schemas/Page/properties/path'
+   *                  grant:
+   *                    $ref: '#/components/schemas/Page/properties/grant'
+   *                required:
+   *                  - body
+   *                  - path
+   *        responses:
+   *          201:
+   *            description: Succeeded to create page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    page:
+   *                      $ref: '#/components/schemas/Page'
+   *          409:
+   *            description: page path is already existed
+   */
+  router.post('/', accessTokenParser, loginRequiredStrictly, csrf, validator.createPage, apiV3FormValidator, async(req, res) => {
+    const {
+      body, grant, grantUserGroupId, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, socketClientId, pageTags,
+    } = req.body;
+
+    let { path } = req.body;
+
+    // check whether path starts slash
+    path = pathUtils.addHeadingSlash(path);
+
+    // check page existence
+    const isExist = await Page.count({ path }) > 0;
+    if (isExist) {
+      return res.apiv3Err(new ErrorV3('Failed to post page', 'page_exists'), 500);
+    }
+
+    const options = { socketClientId };
+    if (grant != null) {
+      options.grant = grant;
+      options.grantUserGroupId = grantUserGroupId;
+    }
+
+    const createdPage = await createPageAction({
+      path, body, user: req.user, options,
+    });
+
+    const savedTags = await saveTagsAction({ createdPage, pageTags });
+
+    const result = { page: serializePageSecurely(createdPage), tags: savedTags };
+
+    // update scopes for descendants
+    if (overwriteScopesOfDescendants) {
+      Page.applyScopesToDescendantsAsyncronously(createdPage, req.user);
+    }
+
+    try {
+      // global notification
+      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
+    }
+    catch (err) {
+      logger.error('Create grobal notification failed', err);
+    }
+
+    // user notification
+    if (isSlackEnabled) {
+      try {
+        const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create', false);
+        results.forEach((result) => {
+          if (result.status === 'rejected') {
+            logger.error('Create user notification failed', result.reason);
+          }
+        });
+      }
+      catch (err) {
+        logger.error('Create user notification failed', err);
+      }
+    }
+
+    return res.apiv3(result, 201);
+  });
 
 
-  const validator = {};
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -57,32 +297,146 @@ module.exports = (crowi) => {
       return res.apiv3(result);
       return res.apiv3(result);
     }
     }
     catch (err) {
     catch (err) {
-      res.code = 'unknown';
       logger.error('Failed to get recent pages', err);
       logger.error('Failed to get recent pages', err);
-      return res.apiv3Err(err, 500);
+      return res.apiv3Err(new ErrorV3('Failed to get recent pages', 'unknown'), 500);
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *
+   *    /pages/rename:
+   *      post:
+   *        tags: [Pages]
+   *        operationId: renamePage
+   *        description: Rename page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  pageId:
+   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                  path:
+   *                    $ref: '#/components/schemas/Page/properties/path'
+   *                  revisionId:
+   *                    type: string
+   *                    description: revision ID
+   *                    example: 5e07345972560e001761fa63
+   *                  newPagePath:
+   *                    type: string
+   *                    description: new path
+   *                    example: /user/alice/new_test
+   *                  isRenameRedirect:
+   *                    type: boolean
+   *                    description: whether redirect page
+   *                  isRemainMetadata:
+   *                    type: boolean
+   *                    description: whether remain meta data
+   *                  isRecursively:
+   *                    type: boolean
+   *                    description: whether rename page with descendants
+   *                required:
+   *                  - pageId
+   *                  - revisionId
+   *        responses:
+   *          200:
+   *            description: Succeeded to rename page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    page:
+   *                      $ref: '#/components/schemas/Page'
+   *          401:
+   *            description: page id is invalid
+   *          409:
+   *            description: page path is already existed
+   */
+  router.put('/rename', accessTokenParser, loginRequiredStrictly, csrf, validator.renamePage, apiV3FormValidator, async(req, res) => {
+    const { pageId, isRecursively, revisionId } = req.body;
+
+    let newPagePath = pathUtils.normalizePath(req.body.newPagePath);
+
+    const options = {
+      createRedirectPage: req.body.isRenameRedirect,
+      updateMetadata: !req.body.isRemainMetadata,
+      socketClientId: +req.body.socketClientId || undefined,
+    };
+
+    if (!Page.isCreatableName(newPagePath)) {
+      return res.apiv3Err(new ErrorV3(`Could not use the path '${newPagePath})'`, 'invalid_path'), 409);
+    }
+
+    // check whether path starts slash
+    newPagePath = pathUtils.addHeadingSlash(newPagePath);
+
+    const isExist = await Page.count({ path: newPagePath }) > 0;
+    if (isExist) {
+      // if page found, cannot cannot rename to that path
+      return res.apiv3Err(new ErrorV3(`${newPagePath} already exists`, 'already_exists'), 409);
+    }
+
+    let page;
+
+    try {
+      page = await Page.findByIdAndViewer(pageId, req.user);
+
+      if (page == null) {
+        return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
+      }
+
+      if (!page.isUpdatable(revisionId)) {
+        return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
+      }
+
+      if (isRecursively) {
+        page = await Page.renameRecursively(page, newPagePath, req.user, options);
+      }
+      else {
+        page = await Page.rename(page, newPagePath, req.user, options);
+      }
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
     }
     }
+
+    const result = { page: serializePageSecurely(page) };
+
+    try {
+      // global notification
+      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_MOVE, page, req.user, {
+        oldPath: req.body.path,
+      });
+    }
+    catch (err) {
+      logger.error('Move notification failed', err);
+    }
+
+    return res.apiv3(result);
   });
   });
 
 
+
   /**
   /**
-  * @swagger
-  *
-  *    /pages/empty-trash:
-  *      delete:
-  *        tags: [Pages]
-  *        description: empty trash
-  *        responses:
-  *          200:
-  *            description: Succeeded to remove all trash pages
-  */
+   * @swagger
+   *
+   *    /pages/empty-trash:
+   *      delete:
+   *        tags: [Pages]
+   *        description: empty trash
+   *        responses:
+   *          200:
+   *            description: Succeeded to remove all trash pages
+   */
   router.delete('/empty-trash', loginRequired, adminRequired, csrf, async(req, res) => {
   router.delete('/empty-trash', loginRequired, adminRequired, csrf, async(req, res) => {
     try {
     try {
       const pages = await Page.completelyDeletePageRecursively({ path: '/trash' }, req.user);
       const pages = await Page.completelyDeletePageRecursively({ path: '/trash' }, req.user);
       return res.apiv3({ pages });
       return res.apiv3({ pages });
     }
     }
     catch (err) {
     catch (err) {
-      res.code = 'unknown';
-      logger.error('Failed to delete trash pages', err);
-      return res.apiv3Err(err, 500);
+      return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
     }
     }
   });
   });
 
 
@@ -120,5 +474,133 @@ module.exports = (crowi) => {
     }
     }
   });
   });
 
 
+  /**
+   * @swagger
+   *
+   *
+   *    /pages/duplicate:
+   *      post:
+   *        tags: [Pages]
+   *        operationId: duplicatePage
+   *        description: Duplicate page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  pageId:
+   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                  pageNameInput:
+   *                    $ref: '#/components/schemas/Page/properties/path'
+   *                  isRecursively:
+   *                    type: boolean
+   *                    description: whether duplicate page with descendants
+   *                required:
+   *                  - pageId
+   *        responses:
+   *          200:
+   *            description: Succeeded to duplicate page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    page:
+   *                      $ref: '#/components/schemas/Page'
+   *
+   *          403:
+   *            description: Forbidden to duplicate page.
+   *          500:
+   *            description: Internal server error.
+   */
+  router.post('/duplicate', accessTokenParser, loginRequiredStrictly, csrf, validator.duplicatePage, apiV3FormValidator, async(req, res) => {
+    const { pageId, isRecursively } = req.body;
+
+    const newPagePath = pathUtils.normalizePath(req.body.pageNameInput);
+
+    // check page existence
+    const isExist = (await Page.count({ path: newPagePath })) > 0;
+    if (isExist) {
+      return res.apiv3Err(new ErrorV3(`Page exists '${newPagePath})'`, 'already_exists'), 409);
+    }
+
+    const page = await Page.findByIdAndViewer(pageId, req.user);
+
+    // null check
+    if (page == null) {
+      res.code = 'Page is not found';
+      logger.error('Failed to find the pages');
+      return res.apiv3Err(new ErrorV3('Not Founded the page', 'notfound_or_forbidden'), 404);
+    }
+
+    let newParentPage;
+
+    if (isRecursively) {
+      newParentPage = await crowi.pageService.duplicateRecursively(page, newPagePath, req.user);
+    }
+    else {
+      newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user);
+    }
+
+    const result = { page: serializePageSecurely(newParentPage) };
+
+    page.path = newPagePath;
+    try {
+      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, page, req.user);
+    }
+    catch (err) {
+      logger.error('Create grobal notification failed', err);
+    }
+
+    return res.apiv3(result);
+  });
+
+  /**
+   * @swagger
+   *
+   *
+   *    /pages/subordinated-list:
+   *      get:
+   *        tags: [Pages]
+   *        operationId: subordinatedList
+   *        description: Get subordinated pages
+   *        parameters:
+   *          - name: path
+   *            in: query
+   *            description: Parent path of search
+   *            schema:
+   *              type: string
+   *          - name: limit
+   *            in: query
+   *            description: Limit of acquisitions
+   *            schema:
+   *              type: number
+   *        responses:
+   *          200:
+   *            description: Succeeded to retrieve pages.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    subordinatedPaths:
+   *                      type: object
+   *                      description: descendants page
+   *          500:
+   *            description: Internal server error.
+   */
+  router.get('/subordinated-list', accessTokenParser, loginRequired, async(req, res) => {
+    const { path } = req.query;
+    const limit = parseInt(req.query.limit) || LIMIT_FOR_LIST;
+
+    try {
+      const pageData = await Page.findByPath(path);
+      const result = await Page.findManageableListWithDescendants(pageData, req.user, { limit });
+
+      return res.apiv3({ subordinatedPaths: result });
+    }
+    catch (err) {
+      return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
+    }
+
+  });
   return router;
   return router;
 };
 };

+ 2 - 2
src/server/routes/apiv3/response.js

@@ -4,13 +4,13 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
 const addCustomFunctionToResponse = (express, crowi) => {
 const addCustomFunctionToResponse = (express, crowi) => {
 
 
-  express.response.apiv3 = function(obj = {}) { // not arrow function
+  express.response.apiv3 = function(obj = {}, status = 200) { // not arrow function
     // obj must be object
     // obj must be object
     if (typeof obj !== 'object' || obj instanceof Array) {
     if (typeof obj !== 'object' || obj instanceof Array) {
       throw new Error('invalid value supplied to res.apiv3');
       throw new Error('invalid value supplied to res.apiv3');
     }
     }
 
 
-    this.json({ data: obj });
+    this.status(status).json({ data: obj });
   };
   };
 
 
   express.response.apiv3Err = function(_err, status = 400, info) { // not arrow function
   express.response.apiv3Err = function(_err, status = 400, info) { // not arrow function

+ 1 - 1
src/server/routes/apiv3/search.js

@@ -77,7 +77,7 @@ module.exports = (crowi) => {
     }
     }
 
 
     try {
     try {
-      await searchService.initClient();
+      await searchService.reconnectClient();
       return res.status(200).send();
       return res.status(200).send();
     }
     }
     catch (err) {
     catch (err) {

+ 1 - 1
src/server/routes/apiv3/share-links.js

@@ -107,7 +107,7 @@ module.exports = (crowi) => {
 
 
     try {
     try {
       const postedShareLink = await ShareLink.create({ relatedPage, expiredAt, description });
       const postedShareLink = await ShareLink.create({ relatedPage, expiredAt, description });
-      return res.apiv3(postedShareLink);
+      return res.apiv3(postedShareLink, 201);
     }
     }
     catch (err) {
     catch (err) {
       const msg = 'Error occured in post share link';
       const msg = 'Error occured in post share link';

+ 1 - 1
src/server/routes/apiv3/user-group.js

@@ -116,7 +116,7 @@ module.exports = (crowi) => {
       const userGroupName = crowi.xss.process(name);
       const userGroupName = crowi.xss.process(name);
       const userGroup = await UserGroup.createGroupByName(userGroupName);
       const userGroup = await UserGroup.createGroupByName(userGroupName);
 
 
-      return res.apiv3({ userGroup });
+      return res.apiv3({ userGroup }, 201);
     }
     }
     catch (err) {
     catch (err) {
       const msg = 'Error occurred in creating a user group';
       const msg = 'Error occurred in creating a user group';

+ 1 - 1
src/server/routes/apiv3/users.js

@@ -357,7 +357,7 @@ module.exports = (crowi) => {
   router.post('/invite', loginRequiredStrictly, adminRequired, csrf, validator.inviteEmail, apiV3FormValidator, async(req, res) => {
   router.post('/invite', loginRequiredStrictly, adminRequired, csrf, validator.inviteEmail, apiV3FormValidator, async(req, res) => {
     try {
     try {
       const invitedUserList = await User.createUsersByInvitation(req.body.shapedEmailList, req.body.sendEmail);
       const invitedUserList = await User.createUsersByInvitation(req.body.shapedEmailList, req.body.sendEmail);
-      return res.apiv3({ invitedUserList });
+      return res.apiv3({ invitedUserList }, 201);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Error', err);
       logger.error('Error', err);

+ 3 - 4
src/server/routes/index.js

@@ -4,6 +4,7 @@ const autoReap = require('multer-autoreap');
 autoReap.options.reapOnError = true; // continue reaping the file even if an error occurs
 autoReap.options.reapOnError = true; // continue reaping the file even if an error occurs
 
 
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
+  const autoReconnectToSearch = require('../middlewares/auto-reconnect-to-search')(crowi);
   const applicationNotInstalled = require('../middlewares/application-not-installed')(crowi);
   const applicationNotInstalled = require('../middlewares/application-not-installed')(crowi);
   const applicationInstalled = require('../middlewares/application-installed')(crowi);
   const applicationInstalled = require('../middlewares/application-installed')(crowi);
   const accessTokenParser = require('../middlewares/access-token-parser')(crowi);
   const accessTokenParser = require('../middlewares/access-token-parser')(crowi);
@@ -32,7 +33,7 @@ module.exports = function(crowi, app) {
 
 
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
 
 
-  app.get('/'                        , applicationInstalled, loginRequired , page.showTopPage);
+  app.get('/'                        , applicationInstalled, loginRequired , autoReconnectToSearch, page.showTopPage);
 
 
   // API v3
   // API v3
   app.use('/api-docs', require('./apiv3/docs')(crowi));
   app.use('/api-docs', require('./apiv3/docs')(crowi));
@@ -136,14 +137,12 @@ module.exports = function(crowi, app) {
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   app.get('/_api/users.list'          , accessTokenParser , loginRequired , user.api.list);
   app.get('/_api/users.list'          , accessTokenParser , loginRequired , user.api.list);
   app.get('/_api/pages.list'          , accessTokenParser , loginRequired , page.api.list);
   app.get('/_api/pages.list'          , accessTokenParser , loginRequired , page.api.list);
-  app.post('/_api/pages.create'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.create);
   app.post('/_api/pages.update'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.update);
   app.post('/_api/pages.update'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.update);
   app.get('/_api/pages.get'           , accessTokenParser , loginRequired , page.api.get);
   app.get('/_api/pages.get'           , accessTokenParser , loginRequired , page.api.get);
   app.get('/_api/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
   app.get('/_api/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
   app.get('/_api/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
   app.get('/_api/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
   app.get('/_api/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
   app.get('/_api/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
   // allow posting to guests because the client doesn't know whether the user logged in
   // allow posting to guests because the client doesn't know whether the user logged in
-  app.post('/_api/pages.rename'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.rename);
   app.post('/_api/pages.remove'       , loginRequiredStrictly , csrf, page.api.remove); // (Avoid from API Token)
   app.post('/_api/pages.remove'       , loginRequiredStrictly , csrf, page.api.remove); // (Avoid from API Token)
   app.post('/_api/pages.revertRemove' , loginRequiredStrictly , csrf, page.api.revertRemove); // (Avoid from API Token)
   app.post('/_api/pages.revertRemove' , loginRequiredStrictly , csrf, page.api.revertRemove); // (Avoid from API Token)
   app.post('/_api/pages.unlink'       , loginRequiredStrictly , csrf, page.api.unlink); // (Avoid from API Token)
   app.post('/_api/pages.unlink'       , loginRequiredStrictly , csrf, page.api.unlink); // (Avoid from API Token)
@@ -175,6 +174,6 @@ module.exports = function(crowi, app) {
   app.get('/share/:linkId', page.showSharedPage);
   app.get('/share/:linkId', page.showSharedPage);
 
 
   app.get('/*/$'                   , loginRequired , page.showPageWithEndOfSlash, page.notFound);
   app.get('/*/$'                   , loginRequired , page.showPageWithEndOfSlash, page.notFound);
-  app.get('/*'                     , loginRequired , page.showPage, page.notFound);
+  app.get('/*'                     , loginRequired , autoReconnectToSearch, page.showPage, page.notFound);
 
 
 };
 };

+ 17 - 51
src/server/routes/page.js

@@ -147,6 +147,17 @@ module.exports = function(crowi, app) {
   const interceptorManager = crowi.getInterceptorManager();
   const interceptorManager = crowi.getInterceptorManager();
   const globalNotificationService = crowi.getGlobalNotificationService();
   const globalNotificationService = crowi.getGlobalNotificationService();
 
 
+  const XssOption = require('../../lib/service/xss/xssOption');
+  const Xss = require('../../lib/service/xss/index');
+  const initializedConfig = {
+    isEnabledXssPrevention: crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    attrWhiteList: crowi.xssService.getAttrWhiteList(),
+  };
+  const xssOption = new XssOption(initializedConfig);
+  const xss = new Xss(xssOption);
+
+
   const actions = {};
   const actions = {};
 
 
   function getPathFromRequest(req) {
   function getPathFromRequest(req) {
@@ -230,6 +241,11 @@ module.exports = function(crowi, app) {
   }
   }
 
 
   function addRenderVarsForPresentation(renderVars, page) {
   function addRenderVarsForPresentation(renderVars, page) {
+    // sanitize page.revision.body
+    if (crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention')) {
+      const preventXssRevision = xss.process(page.revision.body);
+      page.revision.body = preventXssRevision;
+    }
     renderVars.page = page;
     renderVars.page = page;
     renderVars.revision = page.revision;
     renderVars.revision = page.revision;
   }
   }
@@ -695,57 +711,7 @@ module.exports = function(crowi, app) {
     }
     }
   };
   };
 
 
-  /**
-   * @swagger
-   *
-   *    /pages.create:
-   *      post:
-   *        tags: [Pages, CrowiCompatibles]
-   *        operationId: createPage
-   *        summary: /pages.create
-   *        description: Create page
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  body:
-   *                    $ref: '#/components/schemas/Revision/properties/body'
-   *                  path:
-   *                    $ref: '#/components/schemas/Page/properties/path'
-   *                  grant:
-   *                    $ref: '#/components/schemas/Page/properties/grant'
-   *                required:
-   *                  - body
-   *                  - path
-   *        responses:
-   *          200:
-   *            description: Succeeded to create page.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    page:
-   *                      $ref: '#/components/schemas/Page'
-   *                    revision:
-   *                      $ref: '#/components/schemas/Revision'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {post} /pages.create Create new page
-   * @apiName CreatePage
-   * @apiGroup Page
-   *
-   * @apiParam {String} body
-   * @apiParam {String} path
-   * @apiParam {String} grant
-   * @apiParam {Array} pageTags
-   */
+  // TODO If everything that depends on this route, delete it too
   api.create = async function(req, res) {
   api.create = async function(req, res) {
     const body = req.body.body || null;
     const body = req.body.body || null;
     let pagePath = req.body.path || null;
     let pagePath = req.body.path || null;

+ 24 - 0
src/server/service/config-loader.js

@@ -332,6 +332,18 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     type:    TYPES.STRING,
     default: null,
     default: null,
   },
   },
+  S3_REFERENCE_FILE_WITH_RELAY_MODE: {
+    ns:      'crowi',
+    key:     'aws:referenceFileWithRelayMode',
+    type:    TYPES.BOOLEAN,
+    default: false,
+  },
+  S3_LIFETIME_SEC_FOR_TEMPORARY_URL: {
+    ns:      'crowi',
+    key:     'aws:lifetimeSecForTemporaryUrl',
+    type:    TYPES.NUMBER,
+    default: 120,
+  },
   GCS_API_KEY_JSON_PATH: {
   GCS_API_KEY_JSON_PATH: {
     ns:      'crowi',
     ns:      'crowi',
     key:     'gcs:apiKeyJsonPath',
     key:     'gcs:apiKeyJsonPath',
@@ -350,12 +362,24 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     type:    TYPES.STRING,
     default: null,
     default: null,
   },
   },
+  GCS_REFERENCE_FILE_WITH_RELAY_MODE: {
+    ns:      'crowi',
+    key:     'gcs:referenceFileWithRelayMode',
+    type:    TYPES.BOOLEAN,
+    default: false,
+  },
   GCS_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: {
   GCS_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: {
     ns:      'crowi',
     ns:      'crowi',
     key:     'gcs:useOnlyEnvVarsForSomeOptions',
     key:     'gcs:useOnlyEnvVarsForSomeOptions',
     type:    TYPES.BOOLEAN,
     type:    TYPES.BOOLEAN,
     default: false,
     default: false,
   },
   },
+  GCS_LIFETIME_SEC_FOR_TEMPORARY_URL: {
+    ns:      'crowi',
+    key:     'gcs:lifetimeSecForTemporaryUrl',
+    type:    TYPES.NUMBER,
+    default: 120,
+  },
 };
 };
 
 
 class ConfigLoader {
 class ConfigLoader {

+ 37 - 0
src/server/service/file-uploader/aws.js

@@ -72,6 +72,43 @@ module.exports = function(crowi) {
       && this.configManager.getConfig('crowi', 'aws:s3Bucket') != null;
       && this.configManager.getConfig('crowi', 'aws:s3Bucket') != null;
   };
   };
 
 
+  lib.canRespond = function() {
+    return !this.configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
+  };
+
+  lib.respond = async function(res, attachment) {
+    if (!this.getIsUploadable()) {
+      throw new Error('AWS is not configured.');
+    }
+    const temporaryUrl = attachment.getValidTemporaryUrl();
+    if (temporaryUrl != null) {
+      return res.redirect(temporaryUrl);
+    }
+
+    const s3 = S3Factory();
+    const awsConfig = getAwsConfig();
+    const filePath = getFilePathOnStorage(attachment);
+    const lifetimeSecForTemporaryUrl = this.configManager.getConfig('crowi', 'aws:lifetimeSecForTemporaryUrl');
+
+    // issue signed url (default: expires 120 seconds)
+    // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrl-property
+    const params = {
+      Bucket: awsConfig.bucket,
+      Key: filePath,
+      Expires: lifetimeSecForTemporaryUrl,
+    };
+    const signedUrl = s3.getSignedUrl('getObject', params);
+
+    res.redirect(signedUrl);
+
+    try {
+      return attachment.cashTemporaryUrlByProvideSec(signedUrl, lifetimeSecForTemporaryUrl);
+    }
+    catch (err) {
+      logger.error(err);
+    }
+
+  };
 
 
   lib.deleteFile = async function(attachment) {
   lib.deleteFile = async function(attachment) {
     const filePath = getFilePathOnStorage(attachment);
     const filePath = getFilePathOnStorage(attachment);

+ 37 - 0
src/server/service/file-uploader/gcs.js

@@ -50,6 +50,43 @@ module.exports = function(crowi) {
       && this.configManager.getConfig('crowi', 'gcs:bucket') != null;
       && this.configManager.getConfig('crowi', 'gcs:bucket') != null;
   };
   };
 
 
+  lib.canRespond = function() {
+    return !this.configManager.getConfig('crowi', 'gcs:referenceFileWithRelayMode');
+  };
+
+  lib.respond = async function(res, attachment) {
+    if (!this.getIsUploadable()) {
+      throw new Error('GCS is not configured.');
+    }
+    const temporaryUrl = attachment.getValidTemporaryUrl();
+    if (temporaryUrl != null) {
+      return res.redirect(temporaryUrl);
+    }
+
+    const gcs = getGcsInstance();
+    const myBucket = gcs.bucket(getGcsBucket());
+    const filePath = getFilePathOnStorage(attachment);
+    const file = myBucket.file(filePath);
+    const lifetimeSecForTemporaryUrl = this.configManager.getConfig('crowi', 'gcs:lifetimeSecForTemporaryUrl');
+
+    // issue signed url (default: expires 120 seconds)
+    // https://cloud.google.com/storage/docs/access-control/signed-urls
+    const signedUrl = await file.getSignedUrl({
+      action: 'read',
+      expires: Date.now() + lifetimeSecForTemporaryUrl * 1000,
+    });
+
+    res.redirect(signedUrl);
+
+    try {
+      return attachment.cashTemporaryUrlByProvideSec(signedUrl, lifetimeSecForTemporaryUrl);
+    }
+    catch (err) {
+      logger.error(err);
+    }
+
+  };
+
   lib.deleteFile = async function(attachment) {
   lib.deleteFile = async function(attachment) {
     const filePath = getFilePathOnStorage(attachment);
     const filePath = getFilePathOnStorage(attachment);
     return lib.deleteFileByFilePath(filePath);
     return lib.deleteFileByFilePath(filePath);

+ 1 - 1
src/server/service/import.js

@@ -369,7 +369,7 @@ class ImportService {
 
 
     unzipStream.on('entry', (entry) => {
     unzipStream.on('entry', (entry) => {
       const fileName = entry.path;
       const fileName = entry.path;
-      // https://regex101.com/r/mD4eZs/4
+      // https://regex101.com/r/mD4eZs/6
       // prevent from unexpecting attack doing unzip file (path traversal attack)
       // prevent from unexpecting attack doing unzip file (path traversal attack)
       // FOR EXAMPLE
       // FOR EXAMPLE
       // ../../src/server/views/admin/markdown.html
       // ../../src/server/views/admin/markdown.html

+ 56 - 0
src/server/service/page.js

@@ -1,3 +1,7 @@
+const mongoose = require('mongoose');
+const escapeStringRegexp = require('escape-string-regexp');
+const { serializePageSecurely } = require('../models/serializers/page-serializer');
+
 class PageService {
 class PageService {
 
 
   constructor(crowi) {
   constructor(crowi) {
@@ -38,6 +42,58 @@ class PageService {
     return Promise.all(promises);
     return Promise.all(promises);
   }
   }
 
 
+  async duplicate(page, newPagePath, user) {
+    const Page = this.crowi.model('Page');
+    const PageTagRelation = mongoose.model('PageTagRelation');
+    // populate
+    await page.populate({ path: 'revision', model: 'Revision', select: 'body' }).execPopulate();
+
+    // create option
+    const options = { page };
+    options.grant = page.grant;
+    options.grantUserGroupId = page.grantedGroup;
+    options.grantedUsers = page.grantedUsers;
+
+    const createdPage = await Page.create(
+      newPagePath, page.revision.body, user, options,
+    );
+
+    // take over tags
+    const originTags = await page.findRelatedTagsById();
+    let savedTags = [];
+    if (originTags != null) {
+      await PageTagRelation.updatePageTags(createdPage.id, originTags);
+      savedTags = await PageTagRelation.listTagNamesByPage(createdPage.id);
+    }
+
+    const result = serializePageSecurely(createdPage);
+    result.tags = savedTags;
+
+    return result;
+  }
+
+  async duplicateRecursively(page, newPagePath, user) {
+    const Page = this.crowi.model('Page');
+    const newPagePathPrefix = newPagePath;
+    const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
+
+    const pages = await Page.findManageableListWithDescendants(page, user);
+
+    const promise = pages.map(async(page) => {
+      const newPagePath = page.path.replace(pathRegExp, newPagePathPrefix);
+      return this.duplicate(page, newPagePath, user);
+    });
+
+    const newPath = page.path.replace(pathRegExp, newPagePathPrefix);
+
+    await Promise.allSettled(promise);
+
+    const newParentpage = await Page.findByPath(newPath);
+
+    // TODO GW-4634 use stream
+    return newParentpage;
+  }
+
 
 
 }
 }
 
 

+ 80 - 0
src/server/service/search-reconnect-context/reconnect-context.js

@@ -0,0 +1,80 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:service:search-reconnect-context:reconnect-context');
+
+
+const RECONNECT_INTERVAL_SEC = 120;
+
+class ReconnectContext {
+
+  constructor() {
+    this.lastEvalDate = null;
+
+    this.reset(true);
+  }
+
+  reset() {
+    this.counter = 0;
+    this.stage = 0;
+  }
+
+  incrementCount() {
+    this.counter++;
+  }
+
+  incrementStage() {
+    this.counter = 0; // reset counter
+    this.stage++;
+  }
+
+  get shouldReconnectByCount() {
+    // https://www.google.com/search?q=10log10(x)-1+graph
+    const thresholdOfThisStage = 10 * Math.log10(this.stage) - 1;
+    return this.counter > thresholdOfThisStage;
+  }
+
+  get shouldReconnectByTime() {
+    if (this.lastEvalDate == null) {
+      this.lastEvalDate = new Date();
+      return true;
+    }
+
+    const thres = this.lastEvalDate.setSeconds(this.lastEvalDate.getSeconds() + RECONNECT_INTERVAL_SEC);
+    return thres < new Date();
+  }
+
+  get shouldReconnect() {
+    if (this.shouldReconnectByTime) {
+      logger.info('Server should reconnect by time');
+      return true;
+    }
+    if (this.shouldReconnectByCount) {
+      logger.info('Server should reconnect by count');
+      return true;
+    }
+    return false;
+  }
+
+}
+
+async function nextTick(context, reconnectHandler) {
+  context.incrementCount();
+
+  if (context.shouldReconnect) {
+    const isSuccessToReconnect = await reconnectHandler();
+
+    // success to reconnect
+    if (isSuccessToReconnect) {
+      context.reset();
+    }
+    // fail to reconnect
+    else {
+      context.incrementStage();
+    }
+  }
+}
+
+module.exports = {
+  ReconnectContext,
+  nextTick,
+};

+ 17 - 6
src/server/service/search.js

@@ -11,7 +11,7 @@ class SearchService {
     this.isErrorOccuredOnSearching = null;
     this.isErrorOccuredOnSearching = null;
 
 
     try {
     try {
-      this.delegator = this.initDelegator();
+      this.delegator = this.generateDelegator();
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
@@ -39,7 +39,7 @@ class SearchService {
     return this.configManager.getConfig('crowi', 'app:elasticsearchUri') != null;
     return this.configManager.getConfig('crowi', 'app:elasticsearchUri') != null;
   }
   }
 
 
-  initDelegator() {
+  generateDelegator() {
     logger.info('Initializing search delegator');
     logger.info('Initializing search delegator');
 
 
     if (this.isSearchboxEnabled) {
     if (this.isSearchboxEnabled) {
@@ -52,7 +52,6 @@ class SearchService {
       const ElasticsearchDelegator = require('./search-delegator/elasticsearch.js');
       const ElasticsearchDelegator = require('./search-delegator/elasticsearch.js');
       return new ElasticsearchDelegator(this.configManager, this.crowi.socketIoService);
       return new ElasticsearchDelegator(this.configManager, this.crowi.socketIoService);
     }
     }
-
   }
   }
 
 
   registerUpdateEvent() {
   registerUpdateEvent() {
@@ -69,12 +68,24 @@ class SearchService {
     tagEvent.on('update', this.delegator.syncTagChanged.bind(this.delegator));
     tagEvent.on('update', this.delegator.syncTagChanged.bind(this.delegator));
   }
   }
 
 
-  async initClient() {
-    // reset error flag
+  resetErrorStatus() {
     this.isErrorOccuredOnHealthcheck = false;
     this.isErrorOccuredOnHealthcheck = false;
     this.isErrorOccuredOnSearching = false;
     this.isErrorOccuredOnSearching = false;
+  }
+
+  async reconnectClient() {
+    logger.info('Try to reconnect...');
+    this.delegator.initClient();
+
+    try {
+      await this.getInfoForHealth();
 
 
-    return this.delegator.initClient();
+      logger.info('Reconnecting succeeded.');
+      this.resetErrorStatus();
+    }
+    catch (err) {
+      throw err;
+    }
   }
   }
 
 
   async getInfo() {
   async getInfo() {

+ 50 - 0
src/server/service/user-notification/index.js

@@ -0,0 +1,50 @@
+const toArrayFromCsv = require('@commons/util/to-array-from-csv');
+
+/**
+ * service class of UserNotification
+ */
+class UserNotificationService {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+
+    this.Page = this.crowi.model('Page');
+  }
+
+  /**
+   * fire user notification
+   *
+   * @memberof UserNotificationService
+   *
+   * @param {Page} page
+   * @param {User} user
+   * @param {string} slackChannelsStr comma separated string. e.g. 'general,channel1,channel2'
+   * @param {boolean} updateOrCreate
+   * @param {string} previousRevision
+   */
+  async fire(page, user, slackChannelsStr, updateOrCreate, previousRevision) {
+    const { slackNotificationService, slack } = this.crowi;
+
+    await page.updateSlackChannel(slackChannelsStr);
+
+    if (!slackNotificationService.hasSlackConfig()) {
+      throw new Error('slackNotificationService has not been set up');
+    }
+
+    // "dev,slacktest" => [dev,slacktest]
+    const slackChannels = toArrayFromCsv(slackChannelsStr);
+
+    const promises = slackChannels.map(async(chan) => {
+      const res = await slack.postPage(page, user, chan, updateOrCreate, previousRevision);
+      if (res.status !== 'ok') {
+        throw new Error(`fail to send slack notification to #${chan} channel`);
+      }
+      return res;
+    });
+
+    return Promise.allSettled(promises);
+  }
+
+}
+
+module.exports = UserNotificationService;

+ 2 - 0
src/server/views/login/error.html

@@ -17,6 +17,8 @@
 {% endblock %}
 {% endblock %}
 {% block sidebar %}
 {% block sidebar %}
 {% endblock %}
 {% endblock %}
+{% block head_warn_alert_siteurl_undefined %}
+{% endblock %}
 
 
 
 
 
 

+ 30 - 0
src/test/libs/to-array-from-csv.test.js

@@ -0,0 +1,30 @@
+const toArrayFromCsv = require('@commons/util/to-array-from-csv');
+
+describe('To array from csv', () => {
+
+  test('case 1', () => {
+    const result = toArrayFromCsv('dev,general');
+    expect(result).toStrictEqual(['dev', 'general']);
+  });
+
+  test('case 2', () => {
+    const result = toArrayFromCsv('dev');
+    expect(result).toStrictEqual(['dev']);
+  });
+
+  test('case 3', () => {
+    const result = toArrayFromCsv('');
+    expect(result).toStrictEqual([]);
+  });
+
+  test('case 4', () => {
+    const result = toArrayFromCsv('dev, general');
+    expect(result).toStrictEqual(['dev', 'general']);
+  });
+
+  test('case 5', () => {
+    const result = toArrayFromCsv(',dev,general');
+    expect(result).toStrictEqual(['dev', 'general']);
+  });
+
+});

+ 37 - 1
src/test/util/path-utils.test.js

@@ -1,4 +1,4 @@
-const { isTopPage } = require('../../lib/util/path-utils');
+const { isTopPage, convertToNewAffiliationPath } = require('../../lib/util/path-utils');
 
 
 
 
 describe('TopPage Path test', () => {
 describe('TopPage Path test', () => {
@@ -19,3 +19,39 @@ describe('TopPage Path test', () => {
     expect(result).toBe(false);
     expect(result).toBe(false);
   });
   });
 });
 });
+
+
+describe('convertToNewAffiliationPath test', () => {
+  test('Child path is not converted normally', () => {
+    const result = convertToNewAffiliationPath('parent/', 'parent2/', 'parent/child');
+    expect(result).toBe('parent2/child');
+  });
+
+  test('Parent path is not converted normally', () => {
+    const result = convertToNewAffiliationPath('parent/', 'parent3/', 'parent/child');
+    expect(result === 'parent/child').toBe(false);
+  });
+
+  test('Parent and Child path names are switched unexpectedly', () => {
+    const result = convertToNewAffiliationPath('parent/', 'parent4/', 'parent/child');
+    expect(result === 'child/parent4').toBe(false);
+  });
+
+  test('Child path is null', () => {
+    expect(() => {
+      convertToNewAffiliationPath('parent/', 'parent5/', null);
+    }).toThrow();
+  });
+
+  test('Old parent path is null', () => {
+    expect(() => {
+      convertToNewAffiliationPath(null, 'parent5/', 'child');
+    }).toThrow();
+  });
+
+  test('New parent path is null', () => {
+    expect(() => {
+      convertToNewAffiliationPath('parent/', null, 'child');
+    }).toThrow();
+  });
+});

+ 28 - 22
yarn.lock

@@ -2689,11 +2689,10 @@ atob@^2.1.1:
   version "2.1.1"
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.1.tgz#ae2d5a729477f289d60dd7f96a6314a22dd6c22a"
   resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.1.tgz#ae2d5a729477f289d60dd7f96a6314a22dd6c22a"
 
 
-attr-accept@^1.1.3:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-1.1.3.tgz#48230c79f93790ef2775fcec4f0db0f5db41ca52"
-  dependencies:
-    core-js "^2.5.0"
+attr-accept@^2.2.1:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.2.tgz#646613809660110749e92f2c10833b70968d929b"
+  integrity sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==
 
 
 auto-parse@>=1.8.0:
 auto-parse@>=1.8.0:
   version "1.8.0"
   version "1.8.0"
@@ -4315,10 +4314,6 @@ core-js@^1.0.0:
   version "1.2.7"
   version "1.2.7"
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636"
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636"
 
 
-core-js@^2.5.0:
-  version "2.5.3"
-  resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.3.tgz#8acc38345824f16d8365b7c9b4259168e8ed603e"
-
 core-js@^3.2.1:
 core-js@^3.2.1:
   version "3.2.1"
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.2.1.tgz#cd41f38534da6cc59f7db050fe67307de9868b09"
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.2.1.tgz#cd41f38534da6cc59f7db050fe67307de9868b09"
@@ -6166,11 +6161,12 @@ file-loader@^5.0.2:
     loader-utils "^1.2.3"
     loader-utils "^1.2.3"
     schema-utils "^2.5.0"
     schema-utils "^2.5.0"
 
 
-file-selector@^0.1.11:
-  version "0.1.11"
-  resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.1.11.tgz#4648d1303fc594afe8638d0f35caed38697d32cf"
+file-selector@^0.2.2:
+  version "0.2.4"
+  resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.2.4.tgz#7b98286f9dbb9925f420130ea5ed0a69238d4d80"
+  integrity sha512-ZDsQNbrv6qRi1YTDOEWzf5J2KjZ9KMI1Q2SGeTkCJmNNW25Jg4TW4UMcmoqcg4WrAyKRcpBXdbWRxkfrOzVRbA==
   dependencies:
   dependencies:
-    tslib "^1.9.0"
+    tslib "^2.0.3"
 
 
 file-uri-to-path@1.0.0:
 file-uri-to-path@1.0.0:
   version "1.0.0"
   version "1.0.0"
@@ -9056,6 +9052,7 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1:
 loose-envify@^1.4.0:
 loose-envify@^1.4.0:
   version "1.4.0"
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
   resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
+  integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
   dependencies:
   dependencies:
     js-tokens "^3.0.0 || ^4.0.0"
     js-tokens "^3.0.0 || ^4.0.0"
 
 
@@ -11712,6 +11709,7 @@ prop-types@^15.0.0, prop-types@^15.6.2:
 prop-types@^15.5.0, prop-types@^15.6.0, prop-types@^15.7.2:
 prop-types@^15.5.0, prop-types@^15.6.0, prop-types@^15.7.2:
   version "15.7.2"
   version "15.7.2"
   resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
   resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
+  integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
   dependencies:
   dependencies:
     loose-envify "^1.4.0"
     loose-envify "^1.4.0"
     object-assign "^4.1.1"
     object-assign "^4.1.1"
@@ -12019,12 +12017,13 @@ react-dom@^16.8.3:
     prop-types "^15.6.2"
     prop-types "^15.6.2"
     scheduler "^0.13.3"
     scheduler "^0.13.3"
 
 
-react-dropzone@^10.1.3:
-  version "10.1.3"
-  resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-10.1.3.tgz#e45a395b19f440b934484b9b4c416318433e2c90"
+react-dropzone@^11.2.4:
+  version "11.2.4"
+  resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-11.2.4.tgz#391a8d2e41a8a974340f83524d306540192e3313"
+  integrity sha512-EGSvK2CxFTuc28WxwuJCICyuYFX8b+sRumwU6Bs6sTbElV2HtQkT0d6C+HEee6XfbjiLIZ+Th9uji27rvo2wGw==
   dependencies:
   dependencies:
-    attr-accept "^1.1.3"
-    file-selector "^0.1.11"
+    attr-accept "^2.2.1"
+    file-selector "^0.2.2"
     prop-types "^15.7.2"
     prop-types "^15.7.2"
 
 
 react-fast-compare@^2.0.1:
 react-fast-compare@^2.0.1:
@@ -12092,8 +12091,9 @@ react-is@^16.7.0, react-is@^16.9.0:
   integrity sha512-GFMtL0vHkiBv9HluwNZTggSn/sCyEt9n02aM0dSAjGGyqyNlAyftYm4phPxdvCigG15JreC5biwxCgTAJZ7yAA==
   integrity sha512-GFMtL0vHkiBv9HluwNZTggSn/sCyEt9n02aM0dSAjGGyqyNlAyftYm4phPxdvCigG15JreC5biwxCgTAJZ7yAA==
 
 
 react-is@^16.8.1:
 react-is@^16.8.1:
-  version "16.8.6"
-  resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16"
+  version "16.13.1"
+  resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
+  integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
 
 
 react-lifecycles-compat@^3.0.4:
 react-lifecycles-compat@^3.0.4:
   version "3.0.4"
   version "3.0.4"
@@ -14731,14 +14731,20 @@ tslib@^1.8.1:
   integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
   integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
 
 
 tslib@^1.9.0:
 tslib@^1.9.0:
-  version "1.9.2"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.2.tgz#8be0cc9a1f6dc7727c38deb16c2ebd1a2892988e"
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
+  integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
 
 tslib@^1.9.3:
 tslib@^1.9.3:
   version "1.11.1"
   version "1.11.1"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35"
   integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA==
   integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA==
 
 
+tslib@^2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c"
+  integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ==
+
 tsscmp@1.0.6:
 tsscmp@1.0.6:
   version "1.0.6"
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
   resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"