Răsfoiți Sursa

Merge remote-tracking branch 'origin/master' into feat/gw3604-userhome-renovation

Yuki Takei 5 ani în urmă
părinte
comite
67b468b18d
53 a modificat fișierele cu 1149 adăugiri și 467 ștergeri
  1. 18 11
      .github/workflows/ci.yml
  2. 11 3
      resource/locales/en_US/admin/admin.json
  3. 4 1
      resource/locales/en_US/translation.json
  4. 11 3
      resource/locales/ja_JP/admin/admin.json
  5. 3 0
      resource/locales/ja_JP/translation.json
  6. 12 4
      resource/locales/zh_CN/admin/admin.json
  7. 4 1
      resource/locales/zh_CN/translation.json
  8. 6 7
      src/client/js/app.jsx
  9. 1 1
      src/client/js/components/Admin/App/AppSetting.jsx
  10. 3 3
      src/client/js/components/Admin/App/AppSettingsPageContents.jsx
  11. 76 114
      src/client/js/components/Admin/App/AwsSetting.jsx
  12. 101 0
      src/client/js/components/Admin/App/FileUploadSetting.jsx
  13. 117 0
      src/client/js/components/Admin/App/GcsSettings.jsx
  14. 3 3
      src/client/js/components/Admin/App/MailSetting.jsx
  15. 2 0
      src/client/js/components/BookmarkButton.jsx
  16. 113 39
      src/client/js/components/CustomNavigation.jsx
  17. 0 0
      src/client/js/components/Icons/HistoryIcon.jsx
  18. 6 4
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  19. 4 4
      src/client/js/components/NotFoundPage.jsx
  20. 7 1
      src/client/js/components/Page/NotFoundAlert.jsx
  21. 20 2
      src/client/js/components/Page/PageManagement.jsx
  22. 5 5
      src/client/js/components/Page/TagEditModal.jsx
  23. 19 11
      src/client/js/components/Page/TagLabels.jsx
  24. 57 99
      src/client/js/components/PageAccessoriesModal.jsx
  25. 17 16
      src/client/js/components/PageComment/Comment.jsx
  26. 4 2
      src/client/js/components/TableOfContents.jsx
  27. 27 17
      src/client/js/components/TopOfTableContents.jsx
  28. 2 2
      src/client/js/components/TrashPageList.jsx
  29. 0 5
      src/client/js/legacy/crowi.js
  30. 119 35
      src/client/js/services/AdminAppContainer.js
  31. 9 0
      src/client/js/services/NavigationContainer.js
  32. 2 2
      src/client/js/services/PageContainer.js
  33. 5 0
      src/client/styles/scss/_comment.scss
  34. 6 0
      src/client/styles/scss/theme/_apply-colors-dark.scss
  35. 6 0
      src/client/styles/scss/theme/_apply-colors-light.scss
  36. 11 0
      src/client/styles/scss/theme/_apply-colors.scss
  37. 72 0
      src/migrations/20200915035234-rename-s3-config.js
  38. 15 2
      src/server/crowi/index.js
  39. 5 5
      src/server/models/config.js
  40. 116 26
      src/server/routes/apiv3/app-settings.js
  41. 3 4
      src/server/routes/apiv3/bookmarks.js
  42. 4 3
      src/server/routes/apiv3/page.js
  43. 1 1
      src/server/routes/apiv3/users.js
  44. 12 6
      src/server/service/config-loader.js
  45. 18 0
      src/server/service/config-manager.js
  46. 60 0
      src/server/service/file-uploader-switch.js
  47. 10 10
      src/server/service/file-uploader/aws.js
  48. 13 7
      src/server/service/file-uploader/index.js
  49. 4 4
      src/server/util/middlewares.js
  50. 0 1
      src/server/views/layout-growi/forbidden.html
  51. 2 0
      src/server/views/widget/forbidden_content.html
  52. 1 1
      src/server/views/widget/not_creatable_content.html
  53. 2 2
      src/server/views/widget/page_content.html

+ 18 - 11
.github/workflows/ci.yml

@@ -70,6 +70,12 @@ jobs:
       matrix:
       matrix:
         node-version: [14.x]
         node-version: [14.x]
 
 
+    services:
+      mongodb:
+        image: mongo:4.4
+        ports:
+        - 27017/tcp
+
     steps:
     steps:
     - uses: actions/checkout@v2
     - uses: actions/checkout@v2
     - name: Use Node.js ${{ matrix.node-version }}
     - name: Use Node.js ${{ matrix.node-version }}
@@ -103,15 +109,11 @@ jobs:
         echo -n "node " && node -v
         echo -n "node " && node -v
         echo -n "npm " && npm -v
         echo -n "npm " && npm -v
         yarn list --depth=0
         yarn list --depth=0
-    - name: Launch MongoDB
-      uses: wbari/start-mongoDB@v0.2
-      with:
-        mongoDBVersion: 3.6
     - name: yarn test
     - name: yarn test
       run: |
       run: |
         yarn test
         yarn test
       env:
       env:
-        MONGO_URI: mongodb://localhost:27017/growi_test
+        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
 
 
     - name: Slack Notification
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
       uses: weseek/ghaction-slack-notification@master
@@ -202,6 +204,12 @@ jobs:
       matrix:
       matrix:
         node-version: [12.x, 14.x]
         node-version: [12.x, 14.x]
 
 
+    services:
+      mongodb:
+        image: mongo:4.4
+        ports:
+        - 27017/tcp
+
     steps:
     steps:
     - uses: actions/checkout@v2
     - uses: actions/checkout@v2
     - name: Use Node.js ${{ matrix.node-version }}
     - name: Use Node.js ${{ matrix.node-version }}
@@ -254,16 +262,15 @@ jobs:
         echo -n "node " && node -v
         echo -n "node " && node -v
         echo -n "npm " && npm -v
         echo -n "npm " && npm -v
         yarn list --production --depth=0
         yarn list --production --depth=0
-    - name: Launch MongoDB
-      uses: wbari/start-mongoDB@v0.2
-      with:
-        mongoDBVersion: 3.6
+    - name: Get DB name
+      id: getdbname
+      run: |
+        echo ::set-output name=suffix::$(echo '${{ matrix.node-version }}' | sed s/\\.//)
     - name: yarn server:prod:ci
     - name: yarn server:prod:ci
       run: |
       run: |
         yarn server:prod:ci
         yarn server:prod:ci
       env:
       env:
-        MONGO_URI: mongodb://localhost:27017/growi
-
+        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi-${{ steps.getdbname.outputs.suffix }}
     - name: Upload report as artifact
     - name: Upload report as artifact
       uses: actions/upload-artifact@v2
       uses: actions/upload-artifact@v2
       with:
       with:

+ 11 - 3
resource/locales/en_US/admin/admin.json

@@ -38,10 +38,17 @@
     "host": "Host",
     "host": "Host",
     "port": "Port",
     "port": "Port",
     "user": "User",
     "user": "User",
+    "initialize_mail_settings": "initialize e-mail settings",
+    "initialize_mail_modal_header": "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_method":"File Upload Method",
+    "fixed_by_env_var": "This is fixed by the env var <code>FILE_UPLOAD={{fileUploadType}}</code>.",
+    "gcs_label": "GCP(GCS)",
+    "aws_label": "AWS(S3)",
+    "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "ses_settings":"SES settings",
     "ses_settings":"SES settings",
     "test_connection": "Test connection to mail",
     "test_connection": "Test connection to mail",
-    "aws_settings": "AWS settings",
-    "aws_access": "This is for AWS settings. If you complete AWS settings, file upload function, profile picture function etc will be enabled.",
     "change_setting": "Caution:if you change this setting not completed, you will not be able to access files you have uploaded so far.",
     "change_setting": "Caution:if you change this setting not completed, you will not be able to access files you have uploaded so far.",
     "region": "Region",
     "region": "Region",
     "bucket_name": "Bucket name",
     "bucket_name": "Bucket name",
@@ -52,7 +59,8 @@
     "load_plugins": "Load_plugins",
     "load_plugins": "Load_plugins",
     "enable": "Enable",
     "enable": "Enable",
     "disable": "Disable",
     "disable": "Disable",
-    "use_env_var_if_empty": "If the value in the database is empty, the value of the environment variable <cod>{{variable}}</code> is used."
+    "use_env_var_if_empty": "If the value in the database is empty, the value of the environment variable <cod>{{variable}}</code> is used.",
+    "note_for_the_only_env_option": "The GCS Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> ."
   },
   },
   "markdown_setting": {
   "markdown_setting": {
     "lineBreak_header": "Line break setting",
     "lineBreak_header": "Line break setting",

+ 4 - 1
resource/locales/en_US/translation.json

@@ -301,6 +301,9 @@
       "conflict": "Couldn't save the changes you made because someone else was editing this page. Please re-edit the affected section after reloading the page."
       "conflict": "Couldn't save the changes you made because someone else was editing this page. Please re-edit the affected section after reloading the page."
     }
     }
   },
   },
+  "page_comment": {
+    "display_the_page_when_posting_this_comment": "Display the page when posting this comment"
+  },
   "page_api_error": {
   "page_api_error": {
     "notfound_or_forbidden": "Original page is not found or forbidden.",
     "notfound_or_forbidden": "Original page is not found or forbidden.",
     "already_exists": "New page is already exists.",
     "already_exists": "New page is already exists.",
@@ -537,7 +540,7 @@
     "missing mandatory configs": "The following mandatory items are not set in either database nor environment variables.",
     "missing mandatory configs": "The following mandatory items are not set in either database nor environment variables.",
     "Local": {
     "Local": {
       "name": "ID/Password",
       "name": "ID/Password",
-      "note for the only env option": "The LOCAL authentication is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}/code> .",
+      "note for the only env option": "The LOCAL authentication is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
       "enable_local": "Enable ID/Password"
       "enable_local": "Enable ID/Password"
     },
     },
     "ldap": {
     "ldap": {

+ 11 - 3
resource/locales/ja_JP/admin/admin.json

@@ -38,10 +38,17 @@
     "host": "ホスト",
     "host": "ホスト",
     "port": "ポート",
     "port": "ポート",
     "user": "ユーザー",
     "user": "ユーザー",
+    "initialize_mail_settings": "設定を初期化",
+    "initialize_mail_modal_header": "メール設定の初期化",
+    "confirm_to_initialize_mail_settings": "一度初期化した設定は戻せません。本当に初期化しますか?",
+    "file_upload_settings":"ファイルアップロード設定",
+    "file_upload_method":"ファイルアップロード方法",
+    "gcs_label": "GCP(GCS)",
+    "aws_label": "AWS(S3)",
+    "fixed_by_env_var": "環境変数 <code>FILE_UPLOAD={{fileUploadType}}</code> により固定されています。",
+    "file_upload": "ファイルをアップロードするための設定を行います。ファイルアップロードの設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
     "ses_settings":"SES設定",
     "ses_settings":"SES設定",
     "test_connection": "接続テスト",
     "test_connection": "接続テスト",
-    "aws_settings": "AWS設定",
-    "aws_access": "AWS にアクセスするための設定を行います。AWS の設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
     "change_setting": "この設定を途中で変更すると、これまでにアップロードしたファイル等へのアクセスができなくなりますのでご注意下さい。",
     "change_setting": "この設定を途中で変更すると、これまでにアップロードしたファイル等へのアクセスができなくなりますのでご注意下さい。",
     "region": "リージョン",
     "region": "リージョン",
     "bucket_name": "バケット名",
     "bucket_name": "バケット名",
@@ -52,7 +59,8 @@
     "load_plugins": "プラグインを読み込む",
     "load_plugins": "プラグインを読み込む",
     "enable": "有効",
     "enable": "有効",
     "disable": "無効",
     "disable": "無効",
-    "use_env_var_if_empty": "データベース側の値が空の場合、環境変数 <code>{{variable}}</code> の値を利用します"
+    "use_env_var_if_empty": "データベース側の値が空の場合、環境変数 <code>{{variable}}</code> の値を利用します",
+    "note_for_the_only_env_option": "現在GCS設定は環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください"
   },
   },
   "markdown_setting": {
   "markdown_setting": {
     "lineBreak_header": "Line Break設定",
     "lineBreak_header": "Line Break設定",

+ 3 - 0
resource/locales/ja_JP/translation.json

@@ -303,6 +303,9 @@
       "conflict": "すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。"
       "conflict": "すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。"
     }
     }
   },
   },
+  "page_comment": {
+    "display_the_page_when_posting_this_comment": "投稿時のページを表示する"
+  },
   "page_api_error": {
   "page_api_error": {
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
     "already_exists": "新しいページが既に存在しています。",
     "already_exists": "新しいページが既に存在しています。",

+ 12 - 4
resource/locales/zh_CN/admin/admin.json

@@ -38,10 +38,17 @@
 		"host": "服务器",
 		"host": "服务器",
 		"port": "端口号",
 		"port": "端口号",
 		"user": "用户名",
 		"user": "用户名",
+    "initialize_mail_settings": "重置邮件设置",
+    "initialize_mail_modal_header": "重置邮件设置",
+    "confirm_to_initialize_mail_settings": "当前设置将被清空且不可恢复。确认重置?",
+    "file_upload_settings":"文件上传设置",
+    "file_upload_method":"文件上传方法",
+    "gcs_label": "GCP(GCS)",
+    "aws_label": "AWS(S3)",
+		"fixed_by_env_var": "这是由env var<code>FILE_UPLOAD={{fileUploadType}}</code>修复的。",
+    "file_upload": "这是文件上传设定。完成了文件上传设定以后,文件上传功能、档案头像功能将会被开启。",
     "ses_settings":"SES设置",
     "ses_settings":"SES设置",
     "test_connection": "测试邮件服务器连接",
     "test_connection": "测试邮件服务器连接",
-		"aws_settings": "AWS设置",
-		"aws_access": "这是用于AWS设置的。如果您完成了AWS设置,文件上传功能,个人资料图片功能等将被启用。",
 		"": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
 		"": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
 		"change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
 		"change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
 		"region": "Region",
 		"region": "Region",
@@ -53,8 +60,9 @@
 		"load_plugins": "加载插件",
 		"load_plugins": "加载插件",
 		"enable": "启用",
 		"enable": "启用",
 		"disable": "停用",
 		"disable": "停用",
-		"use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <cod>{{variable}}</code> 启用。"
-	},
+		"use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <cod>{{variable}}</code> 启用。",
+    "note_for_the_only_env_option": "The GCS settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> ."
+  },
 	"markdown_setting": {
 	"markdown_setting": {
 		"lineBreak_header": "换行设置",
 		"lineBreak_header": "换行设置",
 		"lineBreak_desc": "您可以更改换行设置。",
 		"lineBreak_desc": "您可以更改换行设置。",

+ 4 - 1
resource/locales/zh_CN/translation.json

@@ -282,6 +282,9 @@
 			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
 			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
 		}
 		}
 	},
 	},
+  "page_comment": {
+    "display_the_page_when_posting_this_comment": "Display the page when posting this comment"
+  },
 	"page_api_error": {
 	"page_api_error": {
 		"notfound_or_forbidden": "未找到或禁止原始页。",
 		"notfound_or_forbidden": "未找到或禁止原始页。",
 		"already_exists": "新建页面已存在",
 		"already_exists": "新建页面已存在",
@@ -525,7 +528,7 @@
 		"missing mandatory configs": "The following mandatory items are not set in either database nor environment variables.",
 		"missing mandatory configs": "The following mandatory items are not set in either database nor environment variables.",
 		"Local": {
 		"Local": {
 			"name": "ID/Password",
 			"name": "ID/Password",
-			"note for the only env option": "The LOCAL authentication is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}/code> .",
+			"note for the only env option": "The LOCAL authentication is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
 			"enable_local": "Enable ID/Password"
 			"enable_local": "Enable ID/Password"
 		},
 		},
 		"ldap": {
 		"ldap": {

+ 6 - 7
src/client/js/app.jsx

@@ -84,7 +84,11 @@ Object.assign(componentMappings, {
 
 
   'not-found-page': <NotFoundPage />,
   'not-found-page': <NotFoundPage />,
 
 
-  'not-found-alert': <NotFoundAlert onPageCreateClicked={navigationContainer.setEditorMode} />,
+  'not-found-alert': <NotFoundAlert
+    onPageCreateClicked={navigationContainer.setEditorMode}
+    isForbidden={pageContainer.state.isForbidden}
+    isNotCreatable={pageContainer.state.isNotCreatable}
+  />,
 
 
   'page-timeline': <PageTimeline />,
   'page-timeline': <PageTimeline />,
 
 
@@ -101,7 +105,7 @@ if (pageContainer.state.pageId != null) {
     'page-comments-list': <PageComments />,
     'page-comments-list': <PageComments />,
     'page-comment-write': <CommentEditorLazyRenderer />,
     'page-comment-write': <CommentEditorLazyRenderer />,
     'page-management': <PageManagement />,
     'page-management': <PageManagement />,
-    'revision-toc': <TableOfContents />,
+    'revision-toc': <TableOfContents isGuestUserMode={appContainer.currentUser == null} />,
     'seen-user-list': <SeenUserList />,
     'seen-user-list': <SeenUserList />,
     'liker-list': <LikerList />,
     'liker-list': <LikerList />,
 
 
@@ -123,11 +127,6 @@ if (pageContainer.state.path != null) {
     'grw-subnav-container': <GrowiSubNavigation />,
     'grw-subnav-container': <GrowiSubNavigation />,
     'grw-subnav-switcher-container': <GrowiSubNavigationSwitcher />,
     'grw-subnav-switcher-container': <GrowiSubNavigationSwitcher />,
     'user-info': <UserInfo />,
     'user-info': <UserInfo />,
-  });
-}
-// additional definitions if user is logged in
-if (appContainer.currentUser != null) {
-  Object.assign(componentMappings, {
     'display-switcher': <DisplaySwitcher />,
     'display-switcher': <DisplaySwitcher />,
   });
   });
 }
 }

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

@@ -81,7 +81,7 @@ class AppSetting extends React.Component {
           >
           >
             {t('admin:app_setting.default_language')}
             {t('admin:app_setting.default_language')}
           </label>
           </label>
-          <div className="col-md-6">
+          <div className="col-md-6 py-2">
             {
             {
               localeMetadatas.map(meta => (
               localeMetadatas.map(meta => (
                 <div key={meta.id} className="custom-control custom-radio custom-control-inline">
                 <div key={meta.id} className="custom-control custom-radio custom-control-inline">

+ 3 - 3
src/client/js/components/Admin/App/AppSettingsPageContents.jsx

@@ -5,8 +5,8 @@ import PropTypes from 'prop-types';
 import AppSetting from './AppSetting';
 import AppSetting from './AppSetting';
 import SiteUrlSetting from './SiteUrlSetting';
 import SiteUrlSetting from './SiteUrlSetting';
 import MailSetting from './MailSetting';
 import MailSetting from './MailSetting';
-import AwsSetting from './AwsSetting';
 import PluginSetting from './PluginSetting';
 import PluginSetting from './PluginSetting';
+import FileUploadSetting from './FileUploadSetting';
 
 
 class AppSettingsPageContents extends React.Component {
 class AppSettingsPageContents extends React.Component {
 
 
@@ -38,8 +38,8 @@ class AppSettingsPageContents extends React.Component {
 
 
         <div className="row mt-5">
         <div className="row mt-5">
           <div className="col-lg-12">
           <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:app_setting.aws_settings')}</h2>
-            <AwsSetting />
+            <h2 className="admin-setting-header">{t('admin:app_setting.file_upload_settings')}</h2>
+            <FileUploadSetting />
           </div>
           </div>
         </div>
         </div>
 
 

+ 76 - 114
src/client/js/components/Admin/App/AwsSetting.jsx

@@ -1,142 +1,104 @@
 import React from 'react';
 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 loggerFactory from '@alias/logger';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '../../../util/apiNotification';
 
 
 import AppContainer from '../../../services/AppContainer';
 import AppContainer from '../../../services/AppContainer';
 import AdminAppContainer from '../../../services/AdminAppContainer';
 import AdminAppContainer from '../../../services/AdminAppContainer';
-import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 
-const logger = loggerFactory('growi:appSettings');
-
-class AwsSetting extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.submitHandler = this.submitHandler.bind(this);
-  }
-
-  async submitHandler() {
-    const { t, adminAppContainer } = this.props;
-
-    try {
-      await adminAppContainer.updateAwsSettingHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.aws_settings') }));
-    }
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  }
-
-  render() {
-    const { t, adminAppContainer } = this.props;
-
-    return (
-      <React.Fragment>
-        <p className="card well">
-          {t('admin:app_setting.aws_access')}
-          <br />
-          <span className="text-danger">
-            <i className="ti-unlink"></i>
-            {t('admin:app_setting.change_setting')}
-          </span>
-        </p>
-
-        <div className="row form-group">
-          <label className="text-left text-md-right col-md-3 col-form-label">
-            {t('admin:app_setting.region')}
-          </label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              placeholder={`${t('eg')} ap-northeast-1`}
-              defaultValue={adminAppContainer.state.region || ''}
-              onChange={(e) => {
-                adminAppContainer.changeRegion(e.target.value);
+function AwsSetting(props) {
+  const { t, adminAppContainer } = props;
+
+  return (
+    <React.Fragment>
+      <div className="row form-group">
+        <label className="text-left text-md-right col-md-3 col-form-label">
+          {t('admin:app_setting.region')}
+        </label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            placeholder={`${t('eg')} ap-northeast-1`}
+            defaultValue={adminAppContainer.state.s3Region || ''}
+            onChange={(e) => {
+                adminAppContainer.changeS3Region(e.target.value);
               }}
               }}
-            />
-          </div>
+          />
         </div>
         </div>
-
-        <div className="row form-group">
-          <label className="text-left text-md-right col-md-3 col-form-label">
-            {t('admin:app_setting.custom_endpoint')}
-          </label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              placeholder={`${t('eg')} http://localhost:9000`}
-              defaultValue={adminAppContainer.state.customEndpoint || ''}
-              onChange={(e) => {
-                adminAppContainer.changeCustomEndpoint(e.target.value);
+      </div>
+
+      <div className="row form-group">
+        <label className="text-left text-md-right col-md-3 col-form-label">
+          {t('admin:app_setting.custom_endpoint')}
+        </label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            placeholder={`${t('eg')} http://localhost:9000`}
+            defaultValue={adminAppContainer.state.s3CustomEndpoint || ''}
+            onChange={(e) => {
+                adminAppContainer.changeS3CustomEndpoint(e.target.value);
               }}
               }}
-            />
-            <p className="form-text text-muted">{t('admin:app_setting.custom_endpoint_change')}</p>
-          </div>
+          />
+          <p className="form-text text-muted">{t('admin:app_setting.custom_endpoint_change')}</p>
         </div>
         </div>
-
-        <div className="row form-group">
-          <label className="text-left text-md-right col-md-3 col-form-label">
-            {t('admin:app_setting.bucket_name')}
-          </label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              placeholder={`${t('eg')} crowi`}
-              defaultValue={adminAppContainer.state.bucket || ''}
-              onChange={(e) => {
-                adminAppContainer.changeBucket(e.target.value);
+      </div>
+
+      <div className="row form-group">
+        <label className="text-left text-md-right col-md-3 col-form-label">
+          {t('admin:app_setting.bucket_name')}
+        </label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            placeholder={`${t('eg')} crowi`}
+            defaultValue={adminAppContainer.state.s3Bucket || ''}
+            onChange={(e) => {
+                adminAppContainer.changeS3Bucket(e.target.value);
               }}
               }}
-            />
-          </div>
+          />
         </div>
         </div>
+      </div>
 
 
-        <div className="row form-group">
-          <label className="text-left text-md-right col-md-3 col-form-label">
+      <div className="row form-group">
+        <label className="text-left text-md-right col-md-3 col-form-label">
             Access key ID
             Access key ID
-          </label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              defaultValue={adminAppContainer.state.accessKeyId || ''}
-              onChange={(e) => {
-                adminAppContainer.changeAccessKeyId(e.target.value);
+        </label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            defaultValue={adminAppContainer.state.s3AccessKeyId || ''}
+            onChange={(e) => {
+                adminAppContainer.changeS3AccessKeyId(e.target.value);
               }}
               }}
-            />
-          </div>
+          />
         </div>
         </div>
+      </div>
 
 
-        <div className="row form-group">
-          <label className="text-left text-md-right col-md-3 col-form-label">
+      <div className="row form-group">
+        <label className="text-left text-md-right col-md-3 col-form-label">
             Secret access key
             Secret access key
-          </label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              defaultValue={adminAppContainer.state.secretAccessKey || ''}
-              onChange={(e) => {
-                adminAppContainer.changeSecretAccessKey(e.target.value);
+        </label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            defaultValue={adminAppContainer.state.s3SecretAccessKey || ''}
+            onChange={(e) => {
+                adminAppContainer.changeS3SecretAccessKey(e.target.value);
               }}
               }}
-            />
-          </div>
+          />
         </div>
         </div>
-
-        <AdminUpdateButtonRow onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
-      </React.Fragment>
-    );
-  }
-
+      </div>
+    </React.Fragment>
+  );
 }
 }
 
 
+
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */

+ 101 - 0
src/client/js/components/Admin/App/FileUploadSetting.jsx

@@ -0,0 +1,101 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+import AwsSetting from './AwsSetting';
+import GcsSettings from './GcsSettings';
+
+function FileUploadSetting(props) {
+
+  const { t, adminAppContainer } = props;
+  const { fileUploadType } = adminAppContainer.state;
+  const fileUploadTypes = ['aws', 'gcs'];
+
+  async function submitHandler() {
+    const { t } = props;
+
+    try {
+      await adminAppContainer.updateFileUploadSettingHandler();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.file_upload_settings') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  return (
+    <React.Fragment>
+      <p className="card well my-3">
+        {t('admin:app_setting.file_upload')}
+        <br />
+        <br />
+        <span className="text-danger">
+          <i className="ti-unlink"></i>
+          {t('admin:app_setting.change_setting')}
+        </span>
+      </p>
+
+      <div className="row form-group mb-5">
+        <label className="text-left text-md-right col-md-3 col-form-label">
+          {t('admin:app_setting.file_upload_method')}
+        </label>
+
+        <div className="col-md-6 py-2">
+          {fileUploadTypes.map((type) => {
+              return (
+                <div key={type} className="custom-control custom-radio custom-control-inline">
+                  <input
+                    type="radio"
+                    className="custom-control-input"
+                    name="file-upload-type"
+                    id={`file-upload-type-radio-${type}`}
+                    checked={adminAppContainer.state.fileUploadType === type}
+                    disabled={adminAppContainer.state.isFixedFileUploadByEnvVar}
+                    onChange={(e) => {
+                    adminAppContainer.changeFileUploadType(type);
+                  }}
+                  />
+                  <label className="custom-control-label" htmlFor={`file-upload-type-radio-${type}`}>{t(`admin:app_setting.${type}_label`)}</label>
+                </div>
+              );
+            })}
+        </div>
+        {adminAppContainer.state.isFixedFileUploadByEnvVar && (
+          <p className="alert alert-warning mt-2 text-left offset-3 col-6">
+            <i className="icon-exclamation icon-fw">
+            </i><b>FIXED</b><br />
+            {/* eslint-disable-next-line react/no-danger */}
+            <b dangerouslySetInnerHTML={{ __html: t('admin:app_setting.fixed_by_env_var', { fileUploadType: adminAppContainer.state.envFileUploadType }) }} />
+          </p>
+        )}
+      </div>
+
+      {fileUploadType === 'aws' && <AwsSetting />}
+      {fileUploadType === 'gcs' && <GcsSettings />}
+
+      <AdminUpdateButtonRow onClick={submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+
+    </React.Fragment>
+  );
+}
+
+
+/**
+ * Wrapper component for using unstated
+ */
+const FileUploadSettingWrapper = withUnstatedContainers(FileUploadSetting, [AppContainer, AdminAppContainer]);
+
+FileUploadSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(FileUploadSettingWrapper);

+ 117 - 0
src/client/js/components/Admin/App/GcsSettings.jsx

@@ -0,0 +1,117 @@
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+
+
+function GcsSetting(props) {
+  const { t, adminAppContainer } = props;
+  const { gcsUseOnlyEnvVars } = adminAppContainer.state;
+
+  return (
+    <>
+      {gcsUseOnlyEnvVars && (
+        <p
+          className="alert alert-info"
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: t('admin:app_setting.note_for_the_only_env_option', { env: 'IS_GCS_ENV_PRIORITIZED' }) }}
+        />
+      )}
+      <table className={`table settings-table ${gcsUseOnlyEnvVars && 'use-only-env-vars'}`}>
+        <colgroup>
+          <col className="item-name" />
+          <col className="from-db" />
+          <col className="from-env-vars" />
+        </colgroup>
+        <thead>
+          <tr>
+            <th></th>
+            <th>Database</th>
+            <th>Environment variables</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr>
+            <th>Api Key Json Path</th>
+            <td>
+              <input
+                className="form-control"
+                type="text"
+                name="gcsApiKeyJsonPath"
+                readOnly={gcsUseOnlyEnvVars}
+                defaultValue={adminAppContainer.state.gcsApiKeyJsonPath}
+                onChange={e => adminAppContainer.changeGcsApiKeyJsonPath(e.target.value)}
+              />
+            </td>
+            <td>
+              <input className="form-control" type="text" value={adminAppContainer.state.envGcsApiKeyJsonPath || ''} readOnly tabIndex="-1" />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'GCS_API_KEY_JSON_PATH' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>{t('admin:app_setting.bucket_name')}</th>
+            <td>
+              <input
+                className="form-control"
+                type="text"
+                name="gcsBucket"
+                readOnly={gcsUseOnlyEnvVars}
+                defaultValue={adminAppContainer.state.gcsBucket}
+                onChange={e => adminAppContainer.changeGcsBucket(e.target.value)}
+              />
+            </td>
+            <td>
+              <input className="form-control" type="text" value={adminAppContainer.state.envGcsBucket || ''} readOnly tabIndex="-1" />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'GCS_BUCKET' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>Name Space</th>
+            <td>
+              <input
+                className="form-control"
+                type="text"
+                name="gcsUploadNamespace"
+                readOnly={gcsUseOnlyEnvVars}
+                defaultValue={adminAppContainer.state.gcsUploadNamespace}
+                onChange={e => adminAppContainer.changeGcsUploadNamespace(e.target.value)}
+              />
+            </td>
+            <td>
+              <input className="form-control" type="text" value={adminAppContainer.state.envGcsUploadNamespace || ''} readOnly tabIndex="-1" />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'GCS_UPLOAD_NAMESPACE' }) }} />
+              </p>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </>
+  );
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const GcsSettingWrapper = withUnstatedContainers(GcsSetting, [AppContainer, AdminAppContainer]);
+
+GcsSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(GcsSettingWrapper);

+ 3 - 3
src/client/js/components/Admin/App/MailSetting.jsx

@@ -62,7 +62,7 @@ function MailSetting(props) {
         <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.transmission_method')}
           {t('admin:app_setting.transmission_method')}
         </label>
         </label>
-        <div className="col-md-6">
+        <div className="col-md-6 py-2">
           {transmissionMethods.map((method) => {
           {transmissionMethods.map((method) => {
               return (
               return (
                 <div key={method} className="custom-control custom-radio custom-control-inline">
                 <div key={method} className="custom-control custom-radio custom-control-inline">
@@ -70,13 +70,13 @@ function MailSetting(props) {
                     type="radio"
                     type="radio"
                     className="custom-control-input"
                     className="custom-control-input"
                     name="transmission-method"
                     name="transmission-method"
-                    id={`transmission-nethod-radio-${method}`}
+                    id={`transmission-method-radio-${method}`}
                     checked={adminAppContainer.state.transmissionMethod === method}
                     checked={adminAppContainer.state.transmissionMethod === method}
                     onChange={(e) => {
                     onChange={(e) => {
                     adminAppContainer.changeTransmissionMethod(method);
                     adminAppContainer.changeTransmissionMethod(method);
                   }}
                   }}
                   />
                   />
-                  <label className="custom-control-label" htmlFor={`transmission-nethod-radio-${method}`}>{t(`admin:app_setting.${method}_label`)}</label>
+                  <label className="custom-control-label" htmlFor={`transmission-method-radio-${method}`}>{t(`admin:app_setting.${method}_label`)}</label>
                 </div>
                 </div>
               );
               );
             })}
             })}

+ 2 - 0
src/client/js/components/BookmarkButton.jsx

@@ -38,6 +38,8 @@ class BookmarkButton extends React.Component {
     return (
     return (
       <button
       <button
         type="button"
         type="button"
+        href="#"
+        title="Bookmark"
         onClick={this.handleClick}
         onClick={this.handleClick}
         className={`btn btn-bookmark border-0
         className={`btn btn-bookmark border-0
           ${`btn-${this.props.size}`}
           ${`btn-${this.props.size}`}

+ 113 - 39
src/client/js/components/CustomNavigation.jsx

@@ -1,18 +1,37 @@
-import React, { useEffect, useState } from 'react';
+import React, {
+  useEffect, useState, useRef, useMemo, useCallback,
+} from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import {
 import {
   Nav, NavItem, NavLink, TabContent, TabPane,
   Nav, NavItem, NavLink, TabContent, TabPane,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 
 
-const CustomNavigation = (props) => {
-  const [activeTab, setActiveTab] = useState('');
-  // [TODO: set default active tab by gw4079]
-  const [sliderWidth, setSliderWidth] = useState(null);
-  const [sliderMarginLeft, setSliderMarginLeft] = useState(null);
+export const CustomNav = (props) => {
+  const navContainer = useRef();
+  const [sliderWidth, setSliderWidth] = useState(0);
+  const [sliderMarginLeft, setSliderMarginLeft] = useState(0);
+
+  const { activeTab, navTabMapping, onNavSelected } = props;
+
+  const navTabRefs = useMemo(() => {
+    const obj = {};
+    Object.keys(navTabMapping).forEach((key) => {
+      obj[key] = React.createRef();
+    });
+    return obj;
+  }, [navTabMapping]);
+
+  const navLinkClickHandler = useCallback((key) => {
+    if (onNavSelected != null) {
+      onNavSelected(key);
+    }
+  }, [onNavSelected]);
 
 
-  function switchActiveTab(activeTab) {
-    setActiveTab(activeTab);
+  function registerNavLink(key, elm) {
+    if (elm != null) {
+      navTabRefs[key] = elm;
+    }
   }
   }
 
 
   // Might make this dynamic for px, %, pt, em
   // Might make this dynamic for px, %, pt, em
@@ -25,59 +44,114 @@ const CustomNavigation = (props) => {
       return;
       return;
     }
     }
 
 
-    const navBar = document.getElementById('grw-custom-navbar');
-    const navTabs = document.querySelectorAll('ul.grw-custom-navbar > li.grw-custom-navtab');
-
-    if (navBar == null || navTabs == null) {
+    if (navContainer == null) {
       return;
       return;
     }
     }
 
 
     let tempML = 0;
     let tempML = 0;
 
 
-    const styles = [].map.call(navTabs, (el) => {
-      const width = getPercentage(el.offsetWidth, navBar.offsetWidth);
+    const styles = Object.entries(navTabRefs).map((el) => {
+      const width = getPercentage(el[1].offsetWidth, navContainer.current.offsetWidth);
       const marginLeft = tempML;
       const marginLeft = tempML;
       tempML += width;
       tempML += width;
       return { width, marginLeft };
       return { width, marginLeft };
     });
     });
-    const { width, marginLeft } = styles[props.navTabMapping[activeTab].index];
+    const { width, marginLeft } = styles[navTabMapping[activeTab].index];
 
 
     setSliderWidth(width);
     setSliderWidth(width);
     setSliderMarginLeft(marginLeft);
     setSliderMarginLeft(marginLeft);
 
 
-  }, [activeTab]);
+  }, [activeTab, navTabRefs, navTabMapping]);
+
+  return (
+    <>
+      <div ref={navContainer}>
+        <Nav className="nav-title grw-custom-navbar" id="grw-custom-navbar">
+          {Object.entries(navTabMapping).map(([key, value]) => {
+
+            const isActive = activeTab === key;
+            const isLinkEnabled = value.isLinkEnabled != null ? value.isLinkEnabled(value) : true;
+            const { Icon, i18n } = value;
+
+            return (
+              <NavItem
+                key={key}
+                type="button"
+                className={`p-0 grw-custom-navtab ${isActive && 'active'}}`}
+              >
+                <NavLink key={key} innerRef={elm => registerNavLink(key, elm)} disabled={!isLinkEnabled} onClick={() => navLinkClickHandler(key)}>
+                  <Icon /> {i18n}
+                </NavLink>
+              </NavItem>
+            );
+          })}
+        </Nav>
+      </div>
+      <hr className="my-0 grw-nav-slide-hr border-none" style={{ width: `${sliderWidth}%`, marginLeft: `${sliderMarginLeft}%` }} />
+    </>
+  );
+
+};
+
+CustomNav.propTypes = {
+  activeTab: PropTypes.string.isRequired,
+  navTabMapping: PropTypes.object.isRequired,
+  onNavSelected: PropTypes.func,
+};
+
 
 
+export const CustomTabContent = (props) => {
+
+  const { activeTab, navTabMapping, additionalClassNames } = props;
+
+  return (
+    <TabContent activeTab={activeTab} className={additionalClassNames.join(' ')}>
+      {Object.entries(navTabMapping).map(([key, value]) => {
+
+        const { Content } = value;
+
+        return (
+          <TabPane key={key} tabId={key}>
+            <Content />
+          </TabPane>
+        );
+      })}
+    </TabContent>
+  );
+
+};
+
+CustomTabContent.propTypes = {
+  activeTab: PropTypes.string.isRequired,
+  navTabMapping: PropTypes.object.isRequired,
+  additionalClassNames: PropTypes.arrayOf(PropTypes.string),
+};
+CustomTabContent.defaultProps = {
+  additionalClassNames: [],
+};
+
+
+const CustomNavigation = (props) => {
+  const { navTabMapping, defaultTabIndex, tabContentClasses } = props;
+  const [activeTab, setActiveTab] = useState(Object.keys(props.navTabMapping)[defaultTabIndex || 0]);
 
 
   return (
   return (
     <React.Fragment>
     <React.Fragment>
-      <Nav className="nav-title grw-custom-navbar" id="grw-custom-navbar">
-        {Object.entries(props.navTabMapping).map(([key, value]) => {
-          return (
-            <NavItem key={key} type="button" className={`p-0 grw-custom-navtab ${activeTab === key && 'active'}`}>
-              <NavLink onClick={() => { switchActiveTab(key) }}>
-                {value.icon}
-                {value.i18n}
-              </NavLink>
-            </NavItem>
-          );
-        })}
-      </Nav>
-      <hr className="my-0 grw-nav-slide-hr border-none" style={{ width: `${sliderWidth}%`, marginLeft: `${sliderMarginLeft}%` }} />
-      <TabContent activeTab={activeTab} className="p-4">
-        {Object.entries(props.navTabMapping).map(([key, value]) => {
-          return (
-            <TabPane key={key} tabId={key}>
-              {value.tabContent}
-            </TabPane>
-          );
-        })}
-      </TabContent>
+
+      <CustomNav activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={setActiveTab} />
+      <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} additionalClassNames={tabContentClasses} />
+
     </React.Fragment>
     </React.Fragment>
   );
   );
 };
 };
 
 
 CustomNavigation.propTypes = {
 CustomNavigation.propTypes = {
-  navTabMapping: PropTypes.object,
+  navTabMapping: PropTypes.object.isRequired,
+  defaultTabIndex: PropTypes.number,
+  tabContentClasses: PropTypes.arrayOf(PropTypes.string),
+};
+CustomNavigation.defaultProps = {
+  tabContentClasses: ['p-4'],
 };
 };
 
 
 export default CustomNavigation;
 export default CustomNavigation;

+ 0 - 0
src/client/js/components/Icons/RecentChangesIcon.jsx → src/client/js/components/Icons/HistoryIcon.jsx


+ 6 - 4
src/client/js/components/Navbar/GrowiSubNavigation.jsx

@@ -121,7 +121,7 @@ const PageReactionButtons = ({ appContainer, pageContainer }) => {
   return (
   return (
     <>
     <>
       {pageUser == null && (
       {pageUser == null && (
-      <span>
+      <span className="mr-2">
         <LikeButton pageId={pageId} isLiked={isLiked} />
         <LikeButton pageId={pageId} isLiked={isLiked} />
       </span>
       </span>
       )}
       )}
@@ -145,6 +145,8 @@ const GrowiSubNavigation = (props) => {
 
 
   const { currentUser } = appContainer;
   const { currentUser } = appContainer;
   const isPageNotFound = pageId == null;
   const isPageNotFound = pageId == null;
+  // Tags cannot be edited while the new page and editorMode is view
+  const isTagLabelHidden = (editorMode !== 'edit' && isPageNotFound);
   const isUserPage = pageUser != null;
   const isUserPage = pageUser != null;
   const isPageInTrash = isTrashPage(path);
   const isPageInTrash = isTrashPage(path);
 
 
@@ -164,9 +166,9 @@ const GrowiSubNavigation = (props) => {
         ) }
         ) }
 
 
         <div className="grw-path-nav-container">
         <div className="grw-path-nav-container">
-          { !isCompactMode && !isPageNotFound && !isPageForbidden && !isUserPage && (
+          { !isCompactMode && !isTagLabelHidden && !isPageForbidden && !isUserPage && (
             <div className="mb-2">
             <div className="mb-2">
-              <TagLabels />
+              <TagLabels editorMode={editorMode} />
             </div>
             </div>
           ) }
           ) }
           <PagePathNav pageId={pageId} pagePath={path} isPageForbidden={isPageForbidden} />
           <PagePathNav pageId={pageId} pagePath={path} isPageForbidden={isPageForbidden} />
@@ -183,7 +185,7 @@ const GrowiSubNavigation = (props) => {
             { !isPageNotFound && !isPageForbidden && <PageManagement /> }
             { !isPageNotFound && !isPageForbidden && <PageManagement /> }
           </div>
           </div>
           <div className="mt-2">
           <div className="mt-2">
-            { !isCreatable && !isPageInTrash
+            { !isCreatable && !isPageInTrash && !isPageForbidden
             && (
             && (
             <ThreeStrandedButton
             <ThreeStrandedButton
               onThreeStrandedButtonClicked={onThreeStrandedButtonClicked}
               onThreeStrandedButtonClicked={onThreeStrandedButtonClicked}

+ 4 - 4
src/client/js/components/NotFoundPage.jsx

@@ -12,15 +12,15 @@ const NotFoundPage = (props) => {
 
 
   const navTabMapping = {
   const navTabMapping = {
     pagelist: {
     pagelist: {
-      icon: <PageListIcon />,
+      Icon: PageListIcon,
+      Content: PageList,
       i18n: t('page_list'),
       i18n: t('page_list'),
-      tabContent: <PageList />,
       index: 0,
       index: 0,
     },
     },
     timeLine: {
     timeLine: {
-      icon: <TimeLineIcon />,
+      Icon: TimeLineIcon,
+      Content: PageTimeline,
       i18n: t('Timeline View'),
       i18n: t('Timeline View'),
-      tabContent: <PageTimeline />,
       index: 1,
       index: 1,
     },
     },
   };
   };

+ 7 - 1
src/client/js/components/Page/NotFoundAlert.jsx

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
 const NotFoundAlert = (props) => {
 const NotFoundAlert = (props) => {
-  const { t } = props;
+  const { t, isForbidden, isNotCreatable } = props;
   function clickHandler(viewType) {
   function clickHandler(viewType) {
     if (props.onPageCreateClicked === null) {
     if (props.onPageCreateClicked === null) {
       return;
       return;
@@ -11,6 +11,10 @@ const NotFoundAlert = (props) => {
     props.onPageCreateClicked(viewType);
     props.onPageCreateClicked(viewType);
   }
   }
 
 
+  if (isForbidden || isNotCreatable) {
+    return null;
+  }
+
   return (
   return (
     <div className="border border-info m-4 p-3">
     <div className="border border-info m-4 p-3">
       <div className="col-md-12 p-0">
       <div className="col-md-12 p-0">
@@ -35,6 +39,8 @@ const NotFoundAlert = (props) => {
 NotFoundAlert.propTypes = {
 NotFoundAlert.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   onPageCreateClicked: PropTypes.func,
   onPageCreateClicked: PropTypes.func,
+  isForbidden: PropTypes.bool.isRequired,
+  isNotCreatable: PropTypes.bool.isRequired,
 };
 };
 
 
 export default withTranslation()(NotFoundAlert);
 export default withTranslation()(NotFoundAlert);

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

@@ -100,6 +100,24 @@ const PageManagement = (props) => {
   //   setIsArchiveCreateModalShown(false);
   //   setIsArchiveCreateModalShown(false);
   // }
   // }
 
 
+  function renderDropdownItemForTopPage() {
+    return (
+      <>
+        <button className="dropdown-item" type="button" onClick={openPageDuplicateModalHandler}>
+          <i className="icon-fw icon-docs"></i> { t('Duplicate') }
+        </button>
+        {/* TODO Presentation Mode is not function. So if it is really necessary, survey this cause and implement Presentation Mode in top page */}
+        {/* <button className="dropdown-item" type="button" onClick={openPagePresentationModalHandler}>
+          <i className="icon-fw"><PresentationIcon /></i><span className="d-none d-sm-inline"> { t('Presentation Mode') }</span>
+        </button> */}
+        <button type="button" className="dropdown-item" onClick={() => { exportPageHandler('md') }}>
+          <i className="icon-fw icon-cloud-download"></i>{t('export_bulk.export_page_markdown')}
+        </button>
+        <div className="dropdown-divider"></div>
+      </>
+    );
+  }
+
   function renderDropdownItemForNotTopPage() {
   function renderDropdownItemForNotTopPage() {
     return (
     return (
       <>
       <>
@@ -110,7 +128,7 @@ const PageManagement = (props) => {
           <i className="icon-fw icon-docs"></i> { t('Duplicate') }
           <i className="icon-fw icon-docs"></i> { t('Duplicate') }
         </button>
         </button>
         <button className="dropdown-item" type="button" onClick={openPagePresentationModalHandler}>
         <button className="dropdown-item" type="button" onClick={openPagePresentationModalHandler}>
-          <i className="icon-fw"><PresentationIcon /></i><span className="d-none d-sm-inline"> { t('Presentation Mode') }</span>
+          <i className="icon-fw"><PresentationIcon /></i> { t('Presentation Mode') }
         </button>
         </button>
         <button type="button" className="dropdown-item" onClick={() => { exportPageHandler('md') }}>
         <button type="button" className="dropdown-item" onClick={() => { exportPageHandler('md') }}>
           <i className="icon-fw icon-cloud-download"></i>{t('export_bulk.export_page_markdown')}
           <i className="icon-fw icon-cloud-download"></i>{t('export_bulk.export_page_markdown')}
@@ -206,7 +224,7 @@ const PageManagement = (props) => {
     <>
     <>
       {currentUser == null ? renderDotsIconForGuestUser() : renderDotsIconForCurrentUser()}
       {currentUser == null ? renderDotsIconForGuestUser() : renderDotsIconForCurrentUser()}
       <div className="dropdown-menu dropdown-menu-right">
       <div className="dropdown-menu dropdown-menu-right">
-        {!isTopPagePath && renderDropdownItemForNotTopPage()}
+        {isTopPagePath ? renderDropdownItemForTopPage() : renderDropdownItemForNotTopPage()}
         <button className="dropdown-item" type="button" onClick={openPageTemplateModalHandler}>
         <button className="dropdown-item" type="button" onClick={openPageTemplateModalHandler}>
           <i className="icon-fw icon-magic-wand"></i> { t('template.option_label.create/edit') }
           <i className="icon-fw icon-magic-wand"></i> { t('template.option_label.create/edit') }
         </button>
         </button>

+ 5 - 5
src/client/js/components/Page/TagEditModal.jsx

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import {
 import {
-  Button, Modal, ModalHeader, ModalBody, ModalFooter,
+  Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import TagsInput from './TagsInput';
 import TagsInput from './TagsInput';
@@ -37,15 +37,15 @@ function TagEditModal(props) {
   return (
   return (
     <Modal isOpen={props.isOpen} toggle={closeModalHandler} id="edit-tag-modal">
     <Modal isOpen={props.isOpen} toggle={closeModalHandler} id="edit-tag-modal">
       <ModalHeader tag="h4" toggle={closeModalHandler} className="bg-primary text-light">
       <ModalHeader tag="h4" toggle={closeModalHandler} className="bg-primary text-light">
-          Edit Tags
+        Edit Tags
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
         <TagsInput tags={tags} onTagsUpdated={onTagsUpdatedByTagsInput} />
         <TagsInput tags={tags} onTagsUpdated={onTagsUpdatedByTagsInput} />
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
-        <Button color="primary" onClick={handleSubmit}>
-            Done
-        </Button>
+        <button type="button" className="btn btn-primary" onClick={handleSubmit}>
+          Done
+        </button>
       </ModalFooter>
       </ModalFooter>
     </Modal>
     </Modal>
   );
   );

+ 19 - 11
src/client/js/components/Page/TagLabels.jsx

@@ -28,11 +28,12 @@ class TagLabels extends React.Component {
 
 
   /**
   /**
    * @return tags data
    * @return tags data
-   *   1. pageContainer.state.tags if pageId is not null
-   *   2. editorContainer.state.tags if pageId is null
+   *   1. pageContainer.state.tags if editorMode is view
+   *   2. editorContainer.state.tags if editorMode is edit
    */
    */
-  getEditTargetData() {
-    return (this.props.editorContainer.state.pageId != null) ? this.props.editorContainer.state.tags : this.props.pageContainer.state.tags;
+  getTagData() {
+    const { editorContainer, pageContainer, editorMode } = this.props;
+    return (editorMode === 'edit') ? editorContainer.state.tags : pageContainer.state.tags;
   }
   }
 
 
   openEditorModal() {
   openEditorModal() {
@@ -43,20 +44,26 @@ class TagLabels extends React.Component {
     this.setState({ isTagEditModalShown: false });
     this.setState({ isTagEditModalShown: false });
   }
   }
 
 
-  async tagsUpdatedHandler(tags) {
-    const { appContainer, editorContainer, pageContainer } = this.props;
+  async tagsUpdatedHandler(newTags) {
+    const {
+      appContainer, editorContainer, pageContainer, editorMode,
+    } = this.props;
+
     const { pageId } = pageContainer.state;
     const { pageId } = pageContainer.state;
 
 
-    // only update tags in editorContainer when new page
-    if (pageId != null) {
-      return editorContainer.setState({ tags });
+    // It will not be reflected in the DB until the page is refreshed
+    if (editorMode === 'edit') {
+      return editorContainer.setState({ tags: newTags });
     }
     }
 
 
     try {
     try {
-      await appContainer.apiPost('/tags.update', { pageId, tags });
+      const { tags } = await appContainer.apiPost('/tags.update', { pageId, tags: newTags });
 
 
       // update pageContainer.state
       // update pageContainer.state
       pageContainer.setState({ tags });
       pageContainer.setState({ tags });
+      // update editorContainer.state
+      editorContainer.setState({ tags });
+
       toastSuccess('updated tags successfully');
       toastSuccess('updated tags successfully');
     }
     }
     catch (err) {
     catch (err) {
@@ -66,7 +73,7 @@ class TagLabels extends React.Component {
 
 
 
 
   render() {
   render() {
-    const tags = this.getEditTargetData();
+    const tags = this.getTagData();
 
 
     return (
     return (
       <>
       <>
@@ -107,6 +114,7 @@ TagLabels.propTypes = {
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
 
+  editorMode: PropTypes.string.isRequired,
 };
 };
 
 
 export default withTranslation()(TagLabelsWrapper);
 export default withTranslation()(TagLabelsWrapper);

+ 57 - 99
src/client/js/components/PageAccessoriesModal.jsx

@@ -1,15 +1,14 @@
-import React, { useEffect, useState } from 'react';
+import React, { useCallback, useMemo } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import {
 import {
-  Modal, ModalBody, ModalHeader, Nav, NavItem, NavLink, TabContent, TabPane,
+  Modal, ModalBody, ModalHeader, TabContent, TabPane,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
-
 import PageListIcon from './Icons/PageListIcon';
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
-import RecentChangesIcon from './Icons/RecentChangesIcon';
+import HistoryIcon from './Icons/HistoryIcon';
 import AttachmentIcon from './Icons/AttachmentIcon';
 import AttachmentIcon from './Icons/AttachmentIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
 
 
@@ -20,123 +19,82 @@ import PageTimeline from './PageTimeline';
 import PageList from './PageList';
 import PageList from './PageList';
 import PageHistory from './PageHistory';
 import PageHistory from './PageHistory';
 import ShareLink from './ShareLink/ShareLink';
 import ShareLink from './ShareLink/ShareLink';
-
-
-const navTabMapping = {
-  pagelist: {
-    icon: <PageListIcon />,
-    i18n: 'page_list',
-    index: 0,
-  },
-  timeline:  {
-    icon: <TimeLineIcon />,
-    i18n: 'Timeline View',
-    index: 1,
-  },
-  pageHistory: {
-    icon: <RecentChangesIcon />,
-    i18n: 'History',
-    index: 2,
-  },
-  attachment: {
-    icon: <AttachmentIcon />,
-    i18n: 'attachment_data',
-    index: 3,
-  },
-  shareLink: {
-    icon: <ShareLinkIcon />,
-    i18n: 'share_links.share_link_management',
-    index: 4,
-  },
-};
+import { CustomNav } from './CustomNavigation';
 
 
 const PageAccessoriesModal = (props) => {
 const PageAccessoriesModal = (props) => {
-  const { t, pageAccessoriesContainer } = props;
+  const {
+    t, pageAccessoriesContainer, onClose, isGuestUserMode,
+  } = props;
   const { switchActiveTab } = pageAccessoriesContainer;
   const { switchActiveTab } = pageAccessoriesContainer;
-  const { activeTab } = pageAccessoriesContainer.state;
-
-  const [sliderWidth, setSliderWidth] = useState(null);
-  const [sliderMarginLeft, setSliderMarginLeft] = useState(null);
-
-  function closeModalHandler() {
-    if (props.onClose == null) {
+  const { activeTab, activeComponents } = pageAccessoriesContainer.state;
+
+  const navTabMapping = useMemo(() => {
+    return {
+      pagelist: {
+        Icon: PageListIcon,
+        i18n: t('page_list'),
+        index: 0,
+      },
+      timeline:  {
+        Icon: TimeLineIcon,
+        i18n: t('Timeline View'),
+        index: 1,
+      },
+      pageHistory: {
+        Icon: HistoryIcon,
+        i18n: t('History'),
+        index: 2,
+      },
+      attachment: {
+        Icon: AttachmentIcon,
+        i18n: t('attachment_data'),
+        index: 3,
+      },
+      shareLink: {
+        Icon: ShareLinkIcon,
+        i18n: t('share_links.share_link_management'),
+        index: 4,
+        isLinkEnabled: v => !isGuestUserMode,
+      },
+    };
+  }, [t, isGuestUserMode]);
+
+  const closeModalHandler = useCallback(() => {
+    if (onClose == null) {
       return;
       return;
     }
     }
-    props.onClose();
-  }
-
-  // Might make this dynamic for px, %, pt, em
-  function getPercentage(min, max) {
-    return min / max * 100;
-  }
-
-  useEffect(() => {
-    if (activeTab === '') {
-      return;
-    }
-
-    const navTitle = document.getElementById('nav-title');
-    const navTabs = document.querySelectorAll('li.nav-link');
-
-    if (navTitle == null || navTabs == null) {
-      return;
-    }
-
-    let tempML = 0;
-
-    const styles = [].map.call(navTabs, (el) => {
-      const width = getPercentage(el.offsetWidth, navTitle.offsetWidth);
-      const marginLeft = tempML;
-      tempML += width;
-      return { width, marginLeft };
-    });
-
-    const { width, marginLeft } = styles[navTabMapping[activeTab].index];
-
-    setSliderWidth(width);
-    setSliderMarginLeft(marginLeft);
-
-  }, [activeTab]);
-
+    onClose();
+  }, [onClose]);
 
 
   return (
   return (
     <React.Fragment>
     <React.Fragment>
       <Modal size="xl" isOpen={props.isOpen} toggle={closeModalHandler} className="grw-page-accessories-modal">
       <Modal size="xl" isOpen={props.isOpen} toggle={closeModalHandler} className="grw-page-accessories-modal">
-        {/* [TODO: insert a modal header and move nav tabs there  by gw-3890] */}
         <ModalHeader className="p-0" toggle={closeModalHandler}>
         <ModalHeader className="p-0" toggle={closeModalHandler}>
-          <Nav className="nav-title" id="nav-title">
-            {Object.entries(navTabMapping).map(([key, value]) => {
-              return (
-                <NavItem key={key} type="button" className={`p-0 nav-link ${activeTab === key && 'active'}`}>
-                  <NavLink onClick={() => { switchActiveTab(key) }}>
-                    {value.icon}
-                    {t(value.i18n)}
-                  </NavLink>
-                </NavItem>
-              );
-            })}
-          </Nav>
-          <hr className="my-0 grw-nav-slide-hr border-none" style={{ width: `${sliderWidth}%`, marginLeft: `${sliderMarginLeft}%` }} />
+          <CustomNav activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={switchActiveTab} />
         </ModalHeader>
         </ModalHeader>
         <ModalBody className="overflow-auto grw-modal-body-style p-0">
         <ModalBody className="overflow-auto grw-modal-body-style p-0">
+          {/* Do not use CustomTabContent because of performance problem:
+              the 'navTabMapping[tabId].Content' for PageAccessoriesModal depends on activeComponents */}
           <TabContent activeTab={activeTab} className="p-5">
           <TabContent activeTab={activeTab} className="p-5">
             <TabPane tabId="pagelist">
             <TabPane tabId="pagelist">
-              {pageAccessoriesContainer.state.activeComponents.has('pagelist') && <PageList />}
+              {activeComponents.has('pagelist') && <PageList />}
             </TabPane>
             </TabPane>
             <TabPane tabId="timeline">
             <TabPane tabId="timeline">
-              {pageAccessoriesContainer.state.activeComponents.has('timeline') && <PageTimeline /> }
+              {activeComponents.has('timeline') && <PageTimeline /> }
             </TabPane>
             </TabPane>
             <TabPane tabId="pageHistory">
             <TabPane tabId="pageHistory">
               <div className="overflow-auto">
               <div className="overflow-auto">
-                {pageAccessoriesContainer.state.activeComponents.has('pageHistory') && <PageHistory /> }
+                {activeComponents.has('pageHistory') && <PageHistory /> }
               </div>
               </div>
             </TabPane>
             </TabPane>
             <TabPane tabId="attachment">
             <TabPane tabId="attachment">
-              {pageAccessoriesContainer.state.activeComponents.has('attachment') && <PageAttachment />}
-            </TabPane>
-            <TabPane tabId="shareLink">
-              {pageAccessoriesContainer.state.activeComponents.has('shareLink') && <ShareLink />}
+              {activeComponents.has('attachment') && <PageAttachment />}
             </TabPane>
             </TabPane>
+            {!isGuestUserMode && (
+              <TabPane tabId="shareLink">
+                {activeComponents.has('shareLink') && <ShareLink />}
+              </TabPane>
+            )}
           </TabContent>
           </TabContent>
         </ModalBody>
         </ModalBody>
       </Modal>
       </Modal>
@@ -151,8 +109,8 @@ const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal,
 
 
 PageAccessoriesModal.propTypes = {
 PageAccessoriesModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
-  // pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
   pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+  isGuestUserMode: PropTypes.bool.isRequired,
   isOpen: PropTypes.bool.isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func,
   onClose: PropTypes.func,
 };
 };

+ 17 - 16
src/client/js/components/PageComment/Comment.jsx

@@ -1,6 +1,7 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
+import { withTranslation } from 'react-i18next';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
 
 
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
@@ -16,6 +17,7 @@ import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
 import Username from '../User/Username';
 import CommentEditor from './CommentEditor';
 import CommentEditor from './CommentEditor';
 import CommentControl from './CommentControl';
 import CommentControl from './CommentControl';
+import HistoryIcon from '../Icons/HistoryIcon';
 
 
 /**
 /**
  *
  *
@@ -38,7 +40,6 @@ class Comment extends React.PureComponent {
     this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
     this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
     this.isCurrentRevision = this.isCurrentRevision.bind(this);
     this.isCurrentRevision = this.isCurrentRevision.bind(this);
     this.getRootClassName = this.getRootClassName.bind(this);
     this.getRootClassName = this.getRootClassName.bind(this);
-    this.getRevisionLabelClassName = this.getRevisionLabelClassName.bind(this);
     this.deleteBtnClickedHandler = this.deleteBtnClickedHandler.bind(this);
     this.deleteBtnClickedHandler = this.deleteBtnClickedHandler.bind(this);
     this.renderText = this.renderText.bind(this);
     this.renderText = this.renderText.bind(this);
     this.renderHtml = this.renderHtml.bind(this);
     this.renderHtml = this.renderHtml.bind(this);
@@ -109,11 +110,6 @@ class Comment extends React.PureComponent {
     return className;
     return className;
   }
   }
 
 
-  getRevisionLabelClassName() {
-    return `page-comment-revision badge ${
-      this.isCurrentRevision() ? 'badge-primary' : 'badge-secondary'}`;
-  }
-
   deleteBtnClickedHandler() {
   deleteBtnClickedHandler() {
     this.props.deleteBtnClicked(this.props.comment);
     this.props.deleteBtnClicked(this.props.comment);
   }
   }
@@ -155,6 +151,7 @@ class Comment extends React.PureComponent {
   }
   }
 
 
   render() {
   render() {
+    const { t } = this.props;
     const comment = this.props.comment;
     const comment = this.props.comment;
     const commentId = comment._id;
     const commentId = comment._id;
     const creator = comment.creator;
     const creator = comment.creator;
@@ -166,8 +163,6 @@ class Comment extends React.PureComponent {
     const rootClassName = this.getRootClassName(comment);
     const rootClassName = this.getRootClassName(comment);
     const commentBody = isMarkdown ? this.renderRevisionBody() : this.renderText(comment.comment);
     const commentBody = isMarkdown ? this.renderRevisionBody() : this.renderText(comment.comment);
     const revHref = `?revision=${comment.revision}`;
     const revHref = `?revision=${comment.revision}`;
-    const revFirst8Letters = comment.revision.substr(-8);
-    const revisionLavelClassName = this.getRevisionLabelClassName();
 
 
     const editedDateId = `editedDate-${comment._id}`;
     const editedDateId = `editedDate-${comment._id}`;
     const editedDateFormatted = isEdited
     const editedDateFormatted = isEdited
@@ -176,7 +171,6 @@ class Comment extends React.PureComponent {
 
 
     return (
     return (
       <React.Fragment>
       <React.Fragment>
-
         {this.state.isReEdit ? (
         {this.state.isReEdit ? (
           <CommentEditor
           <CommentEditor
             growiRenderer={this.props.growiRenderer}
             growiRenderer={this.props.growiRenderer}
@@ -206,10 +200,17 @@ class Comment extends React.PureComponent {
                     <span id={editedDateId}>&nbsp;(edited)</span>
                     <span id={editedDateId}>&nbsp;(edited)</span>
                     <UncontrolledTooltip placement="bottom" fade={false} target={editedDateId}>{editedDateFormatted}</UncontrolledTooltip>
                     <UncontrolledTooltip placement="bottom" fade={false} target={editedDateId}>{editedDateFormatted}</UncontrolledTooltip>
                   </>
                   </>
-                ) }
-                <span className="ml-2"><a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a></span>
+                )}
+                <span className="ml-2">
+                  <a id={`page-comment-revision-${commentId}`} className="page-comment-revision" href={revHref}>
+                    <HistoryIcon />
+                  </a>
+                  <UncontrolledTooltip placement="bottom" fade={false} target={`page-comment-revision-${commentId}`}>
+                    {t('page_comment.display_the_page_when_posting_this_comment')}
+                  </UncontrolledTooltip>
+                </span>
               </div>
               </div>
-              { this.checkPermissionToControlComment() && (
+              {this.checkPermissionToControlComment() && (
                 <CommentControl
                 <CommentControl
                   onClickDeleteBtn={this.deleteBtnClickedHandler}
                   onClickDeleteBtn={this.deleteBtnClickedHandler}
                   onClickEditBtn={() => this.setState({ isReEdit: true })}
                   onClickEditBtn={() => this.setState({ isReEdit: true })}
@@ -217,9 +218,8 @@ class Comment extends React.PureComponent {
               ) }
               ) }
             </div>
             </div>
           </div>
           </div>
-          )
-        }
-
+        )
+      }
       </React.Fragment>
       </React.Fragment>
     );
     );
   }
   }
@@ -232,6 +232,7 @@ class Comment extends React.PureComponent {
 const CommentWrapper = withUnstatedContainers(Comment, [AppContainer, PageContainer]);
 const CommentWrapper = withUnstatedContainers(Comment, [AppContainer, PageContainer]);
 
 
 Comment.propTypes = {
 Comment.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
 
@@ -240,4 +241,4 @@ Comment.propTypes = {
   deleteBtnClicked: PropTypes.func.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
 };
 };
 
 
-export default CommentWrapper;
+export default withTranslation()(CommentWrapper);

+ 4 - 2
src/client/js/components/TableOfContents.jsx

@@ -22,7 +22,7 @@ const logger = loggerFactory('growi:TableOfContents');
  */
  */
 const TableOfContents = (props) => {
 const TableOfContents = (props) => {
 
 
-  const { pageContainer, navigationContainer } = props;
+  const { pageContainer, navigationContainer, isGuestUserMode } = props;
   const { pageUser } = pageContainer.state;
   const { pageUser } = pageContainer.state;
   const isUserPage = pageUser != null;
   const isUserPage = pageUser != null;
 
 
@@ -49,7 +49,7 @@ const TableOfContents = (props) => {
 
 
   return (
   return (
     <>
     <>
-      <TopOfTableContents />
+      <TopOfTableContents isGuestUserMode={isGuestUserMode} />
       <StickyStretchableScroller
       <StickyStretchableScroller
         contentsElemSelector=".revision-toc .markdownIt-TOC"
         contentsElemSelector=".revision-toc .markdownIt-TOC"
         stickyElemSelector="#revision-toc"
         stickyElemSelector="#revision-toc"
@@ -90,6 +90,8 @@ const TableOfContentsWrapper = withUnstatedContainers(TableOfContents, [PageCont
 TableOfContents.propTypes = {
 TableOfContents.propTypes = {
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+
+  isGuestUserMode: PropTypes.bool.isRequired,
 };
 };
 
 
 export default withTranslation()(TableOfContentsWrapper);
 export default withTranslation()(TableOfContentsWrapper);

+ 27 - 17
src/client/js/components/TopOfTableContents.jsx

@@ -3,11 +3,12 @@ import PropTypes from 'prop-types';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
+import { UncontrolledTooltip } from 'reactstrap';
 import PageAccessoriesContainer from '../services/PageAccessoriesContainer';
 import PageAccessoriesContainer from '../services/PageAccessoriesContainer';
 
 
 import PageListIcon from './Icons/PageListIcon';
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
-import RecentChangesIcon from './Icons/RecentChangesIcon';
+import HistoryIcon from './Icons/HistoryIcon';
 import AttachmentIcon from './Icons/AttachmentIcon';
 import AttachmentIcon from './Icons/AttachmentIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
 
 
@@ -16,16 +17,15 @@ import PageAccessoriesModal from './PageAccessoriesModal';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 
 const TopOfTableContents = (props) => {
 const TopOfTableContents = (props) => {
-  const { pageAccessoriesContainer } = props;
+  const { t, pageAccessoriesContainer, isGuestUserMode } = props;
 
 
   function renderModal() {
   function renderModal() {
     return (
     return (
-      <>
-        <PageAccessoriesModal
-          isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
-          onClose={pageAccessoriesContainer.closePageAccessoriesModal}
-        />
-      </>
+      <PageAccessoriesModal
+        isGuestUserMode={isGuestUserMode}
+        isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
+        onClose={pageAccessoriesContainer.closePageAccessoriesModal}
+      />
     );
     );
   }
   }
 
 
@@ -53,7 +53,7 @@ const TopOfTableContents = (props) => {
           className="btn btn-link grw-btn-top-of-table"
           className="btn btn-link grw-btn-top-of-table"
           onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('pageHistory')}
           onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('pageHistory')}
         >
         >
-          <RecentChangesIcon />
+          <HistoryIcon />
         </button>
         </button>
 
 
         <button
         <button
@@ -64,14 +64,20 @@ const TopOfTableContents = (props) => {
           <AttachmentIcon />
           <AttachmentIcon />
         </button>
         </button>
 
 
-        <button
-          type="button"
-          className="btn btn-link grw-btn-top-of-table"
-          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('shareLink')}
-        >
-          <ShareLinkIcon />
-        </button>
-
+        <div id="shareLink-btn-wrapper-for-tooltip">
+          <button
+            type="button"
+            className={`btn btn-link grw-btn-top-of-table ${isGuestUserMode && 'disabled'}`}
+            onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('shareLink')}
+          >
+            <ShareLinkIcon />
+          </button>
+        </div>
+        {isGuestUserMode && (
+          <UncontrolledTooltip placement="top" target="shareLink-btn-wrapper-for-tooltip" fade={false}>
+            {t('Not available for guest')}
+          </UncontrolledTooltip>
+        )}
         <div
         <div
           id="seen-user-list"
           id="seen-user-list"
           data-user-ids-str="{{ page.seenUsers|slice(-15)|default([])|reverse|join(',') }}"
           data-user-ids-str="{{ page.seenUsers|slice(-15)|default([])|reverse|join(',') }}"
@@ -90,7 +96,11 @@ const TopOfTableContents = (props) => {
 const TopOfTableContentsWrapper = withUnstatedContainers(TopOfTableContents, [PageAccessoriesContainer]);
 const TopOfTableContentsWrapper = withUnstatedContainers(TopOfTableContents, [PageAccessoriesContainer]);
 
 
 TopOfTableContents.propTypes = {
 TopOfTableContents.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+
   pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
   pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+
+  isGuestUserMode: PropTypes.bool.isRequired,
 };
 };
 
 
 export default withTranslation()(TopOfTableContentsWrapper);
 export default withTranslation()(TopOfTableContentsWrapper);

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

@@ -11,9 +11,9 @@ const TrashPageList = (props) => {
 
 
   const navTabMapping = {
   const navTabMapping = {
     pagelist: {
     pagelist: {
-      icon: <PageListIcon />,
+      Icon: PageListIcon,
+      Content: PageList,
       i18n: t('page_list'),
       i18n: t('page_list'),
-      tabContent: <PageList />,
       index: 0,
       index: 0,
     },
     },
   };
   };

+ 0 - 5
src/client/js/legacy/crowi.js

@@ -202,11 +202,6 @@ $(() => {
 window.addEventListener('load', (e) => {
 window.addEventListener('load', (e) => {
   const { appContainer } = window;
   const { appContainer } = window;
 
 
-  // do nothing if user is guest
-  if (appContainer.currentUser == null) {
-    return;
-  }
-
   // hash on page
   // hash on page
   if (window.location.hash) {
   if (window.location.hash) {
     const navigationContainer = appContainer.getContainer('NavigationContainer');
     const navigationContainer = appContainer.getContainer('NavigationContainer');

+ 119 - 35
src/client/js/services/AdminAppContainer.js

@@ -20,23 +20,39 @@ export default class AdminAppContainer extends Container {
       confidential: '',
       confidential: '',
       globalLang: '',
       globalLang: '',
       fileUpload: '',
       fileUpload: '',
+
       siteUrl: '',
       siteUrl: '',
       envSiteUrl: '',
       envSiteUrl: '',
       isSetSiteUrl: true,
       isSetSiteUrl: true,
       isMailerSetup: false,
       isMailerSetup: false,
       fromAddress: '',
       fromAddress: '',
       transmissionMethod: '',
       transmissionMethod: '',
+
       smtpHost: '',
       smtpHost: '',
       smtpPort: '',
       smtpPort: '',
       smtpUser: '',
       smtpUser: '',
       smtpPassword: '',
       smtpPassword: '',
       sesAccessKeyId: '',
       sesAccessKeyId: '',
       sesSecretAccessKey: '',
       sesSecretAccessKey: '',
-      region: '',
-      customEndpoint: '',
-      bucket: '',
-      accessKeyId: '',
-      secretAccessKey: '',
+
+      fileUploadType: '',
+      envFileUploadType: '',
+      isFixedFileUploadByEnvVar: false,
+
+      gcsUseOnlyEnvVars: false,
+      gcsApiKeyJsonPath: '',
+      envGcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      envGcsBucket: '',
+      gcsUploadNamespace: '',
+      envGcsUploadNamespace: '',
+
+      s3Region: '',
+      s3CustomEndpoint: '',
+      s3Bucket: '',
+      s3AccessKeyId: '',
+      s3SecretAccessKey: '',
+
       isEnabledPlugins: true,
       isEnabledPlugins: true,
     };
     };
 
 
@@ -73,13 +89,39 @@ export default class AdminAppContainer extends Container {
       smtpPassword: appSettingsParams.smtpPassword,
       smtpPassword: appSettingsParams.smtpPassword,
       sesAccessKeyId: appSettingsParams.sesAccessKeyId,
       sesAccessKeyId: appSettingsParams.sesAccessKeyId,
       sesSecretAccessKey: appSettingsParams.sesSecretAccessKey,
       sesSecretAccessKey: appSettingsParams.sesSecretAccessKey,
-      region: appSettingsParams.region,
-      customEndpoint: appSettingsParams.customEndpoint,
-      bucket: appSettingsParams.bucket,
-      accessKeyId: appSettingsParams.accessKeyId,
-      secretAccessKey: appSettingsParams.secretAccessKey,
+
+      fileUploadType: appSettingsParams.fileUploadType,
+      envFileUploadType: appSettingsParams.envFileUploadType,
+
+      s3Region: appSettingsParams.s3Region,
+      s3CustomEndpoint: appSettingsParams.s3CustomEndpoint,
+      s3Bucket: appSettingsParams.s3Bucket,
+      s3AccessKeyId: appSettingsParams.s3AccessKeyId,
+      s3SecretAccessKey: appSettingsParams.s3SecretAccessKey,
+      gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars,
+      gcsApiKeyJsonPath: appSettingsParams.gcsApiKeyJsonPath,
+      gcsBucket: appSettingsParams.gcsBucket,
+      gcsUploadNamespace: appSettingsParams.gcsUploadNamespace,
+      envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
+      envGcsBucket: appSettingsParams.envGcsBucket,
+      envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
       isEnabledPlugins: appSettingsParams.isEnabledPlugins,
       isEnabledPlugins: appSettingsParams.isEnabledPlugins,
     });
     });
+
+    // check is file upload type forced
+    if (this.isFixedFileUploadByEnvVar(appSettingsParams.envFileUploadType)) {
+      this.setState({ fileUploadType: appSettingsParams.envFileUploadType });
+      this.setState({ isFixedFileUploadByEnvVar: true });
+    }
+
+  }
+
+  /**
+   * get isFixedFileUploadByEnvVar
+   * @return {bool} isFixedFileUploadByEnvVar
+   */
+  isFixedFileUploadByEnvVar(envFileUploadType) {
+    return envFileUploadType != null;
   }
   }
 
 
   /**
   /**
@@ -161,52 +203,66 @@ export default class AdminAppContainer extends Container {
   }
   }
 
 
   /**
   /**
-   * Change sesAccessKeyId
+   * Change s3Region
+   */
+  changeS3Region(s3Region) {
+    this.setState({ s3Region });
+  }
+
+  /**
+   * Change s3CustomEndpoint
    */
    */
-  changeSesAccessKeyId(sesAccessKeyId) {
-    this.setState({ sesAccessKeyId });
+  changeS3CustomEndpoint(s3CustomEndpoint) {
+    this.setState({ s3CustomEndpoint });
   }
   }
 
 
   /**
   /**
-   * Change sesSecretAccessKey
+   * Change fileUploadType
    */
    */
-  changeSesSecretAccessKey(sesSecretAccessKey) {
-    this.setState({ sesSecretAccessKey });
+  changeFileUploadType(fileUploadType) {
+    this.setState({ fileUploadType });
   }
   }
 
 
   /**
   /**
    * Change region
    * Change region
    */
    */
-  changeRegion(region) {
-    this.setState({ region });
+  changeS3Bucket(s3Bucket) {
+    this.setState({ s3Bucket });
   }
   }
 
 
   /**
   /**
-   * Change custom endpoint
+   * Change access key id
    */
    */
-  changeCustomEndpoint(customEndpoint) {
-    this.setState({ customEndpoint });
+  changeS3AccessKeyId(s3AccessKeyId) {
+    this.setState({ s3AccessKeyId });
   }
   }
 
 
   /**
   /**
-   * Change bucket name
+   * Change secret access key
    */
    */
-  changeBucket(bucket) {
-    this.setState({ bucket });
+  changeS3SecretAccessKey(s3SecretAccessKey) {
+    this.setState({ s3SecretAccessKey });
   }
   }
 
 
   /**
   /**
-   * Change access key id
+   * Change gcsApiKeyJsonPath
    */
    */
-  changeAccessKeyId(accessKeyId) {
-    this.setState({ accessKeyId });
+  changeGcsApiKeyJsonPath(gcsApiKeyJsonPath) {
+    this.setState({ gcsApiKeyJsonPath });
   }
   }
 
 
   /**
   /**
-   * Change secret access key
+   * Change gcsBucket
+   */
+  changeGcsBucket(gcsBucket) {
+    this.setState({ gcsBucket });
+  }
+
+  /**
+   * Change gcsUploadNamespace
    */
    */
-  changeSecretAccessKey(secretAccessKey) {
-    this.setState({ secretAccessKey });
+  changeGcsUploadNamespace(gcsUploadNamespace) {
+    this.setState({ gcsUploadNamespace });
   }
   }
 
 
   /**
   /**
@@ -302,6 +358,17 @@ export default class AdminAppContainer extends Container {
     return this.appContainer.apiv3.post('/app-settings/smtp-test');
     return this.appContainer.apiv3.post('/app-settings/smtp-test');
   }
   }
 
 
+  /**
+   * Update file upload setting
+   * @memberOf AdminAppContainer
+   */
+  updateFileUploadSettingHandler() {
+    if (this.state.fileUploadType === 'aws') {
+      return this.updateAwsSettingHandler();
+    }
+    return this.updateGcsSettingHandler();
+  }
+
   /**
   /**
    * Update AWS setting
    * Update AWS setting
    * @memberOf AdminAppContainer
    * @memberOf AdminAppContainer
@@ -309,11 +376,28 @@ export default class AdminAppContainer extends Container {
    */
    */
   async updateAwsSettingHandler() {
   async updateAwsSettingHandler() {
     const response = await this.appContainer.apiv3.put('/app-settings/aws-setting', {
     const response = await this.appContainer.apiv3.put('/app-settings/aws-setting', {
-      region: this.state.region,
-      customEndpoint: this.state.customEndpoint,
-      bucket: this.state.bucket,
-      accessKeyId: this.state.accessKeyId,
-      secretAccessKey: this.state.secretAccessKey,
+      fileUploadType: this.state.fileUploadType,
+      s3Region: this.state.s3Region,
+      s3CustomEndpoint: this.state.s3CustomEndpoint,
+      s3Bucket: this.state.s3Bucket,
+      s3AccessKeyId: this.state.s3AccessKeyId,
+      s3SecretAccessKey: this.state.s3SecretAccessKey,
+    });
+    const { awsSettingParams } = response.data;
+    return awsSettingParams;
+  }
+
+  /**
+   * Update GCS setting
+   * @memberOf AdminAppContainer
+   * @return {Array} Appearance
+   */
+  async updateGcsSettingHandler() {
+    const response = await this.appContainer.apiv3.put('/app-settings/gcs-setting', {
+      fileUploadType: this.state.fileUploadType,
+      gcsApiKeyJsonPath: this.state.gcsApiKeyJsonPath,
+      gcsBucket: this.state.gcsBucket,
+      gcsUploadNamespace: this.state.gcsUploadNamespace,
     });
     });
     const { awsSettingParams } = response.data;
     const { awsSettingParams } = response.data;
     return awsSettingParams;
     return awsSettingParams;

+ 9 - 0
src/client/js/services/NavigationContainer.js

@@ -1,4 +1,7 @@
 import { Container } from 'unstated';
 import { Container } from 'unstated';
+import loggerFactory from '@alias/logger';
+
+const logger = loggerFactory('growi:services:NavigationContainer');
 
 
 /**
 /**
  * Service container related to options for Application
  * Service container related to options for Application
@@ -86,6 +89,12 @@ export default class NavigationContainer extends Container {
   }
   }
 
 
   setEditorMode(editorMode) {
   setEditorMode(editorMode) {
+
+    if (this.appContainer.currentUser == null) {
+      logger.warn('Please login or signup to edit the page or use hackmd.');
+      return;
+    }
+
     this.setState({ editorMode });
     this.setState({ editorMode });
     if (editorMode === 'view') {
     if (editorMode === 'view') {
       $('body').removeClass('on-edit');
       $('body').removeClass('on-edit');

+ 2 - 2
src/client/js/services/PageContainer.js

@@ -58,10 +58,10 @@ export default class PageContainer extends Container {
       sumOfBookmarks: 0,
       sumOfBookmarks: 0,
       createdAt: mainContent.getAttribute('data-page-created-at'),
       createdAt: mainContent.getAttribute('data-page-created-at'),
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
-      isForbidden:  JSON.parse(mainContent.getAttribute('data-page-is-forbidden')),
+      isForbidden: JSON.parse(mainContent.getAttribute('data-page-is-forbidden')),
       isDeleted:  JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
       isDeleted:  JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
       isDeletable:  JSON.parse(mainContent.getAttribute('data-page-is-deletable')),
       isDeletable:  JSON.parse(mainContent.getAttribute('data-page-is-deletable')),
-      isCreatable: JSON.parse(mainContent.getAttribute('data-page-is-creatable')),
+      isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
       isAbleToDeleteCompletely:  JSON.parse(mainContent.getAttribute('data-page-is-able-to-delete-completely')),
       isAbleToDeleteCompletely:  JSON.parse(mainContent.getAttribute('data-page-is-able-to-delete-completely')),
       pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),
       pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),
       tags: null,
       tags: null,

+ 5 - 0
src/client/styles/scss/_comment.scss

@@ -40,6 +40,11 @@
       font-size: 0.9em;
       font-size: 0.9em;
       color: $gray-400;
       color: $gray-400;
     }
     }
+
+    .page-comment-revision svg {
+      width: 16px;
+      height: 16px;
+    }
   }
   }
 
 
   .page-comment-main {
   .page-comment-main {

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

@@ -44,6 +44,12 @@ 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%);

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

@@ -36,6 +36,12 @@ $table-hover-bg: $bgcolor-table-hover;
   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%);
 }
 }

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

@@ -448,6 +448,17 @@ body.on-edit {
   }
   }
 }
 }
 
 
+/*
+ * GROWI comment
+ */
+.page-comment-meta .page-comment-revision svg {
+  fill: $color-link;
+
+  &:hover() {
+    fill: $color-link-hover;
+  }
+}
+
 /*
 /*
  * GROWI comment form
  * GROWI comment form
  */
  */

+ 72 - 0
src/migrations/20200915035234-rename-s3-config.js

@@ -0,0 +1,72 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:rename-s3-config');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+const { getModelSafely } = require('@commons/util/mongoose-utils');
+
+const awsConfigs = [
+  {
+    oldValue: 'aws:bucket',
+    newValue: 'aws:s3Bucket',
+  },
+  {
+    oldValue: 'aws:region',
+    newValue: 'aws:s3Region',
+  },
+  {
+    oldValue: 'aws:accessKeyId',
+    newValue: 'aws:s3AccessKeyId',
+  },
+  {
+    oldValue: 'aws:secretAccessKey',
+    newValue: 'aws:s3SecretAccessKey',
+  },
+  {
+    oldValue: 'aws:customEndpoint',
+    newValue: 'aws:s3CustomEndpoint',
+  },
+];
+
+module.exports = {
+  async up(db, client) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = getModelSafely('Config') || require('@server/models/config')();
+
+    const request = awsConfigs.map((awsConfig) => {
+      return {
+        updateOne: {
+          filter: { key: awsConfig.oldValue },
+          update:  { key: awsConfig.newValue },
+        },
+      };
+    });
+
+    await Config.bulkWrite(request);
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, client) {
+    logger.info('Rollback migration');
+
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = getModelSafely('Config') || require('@server/models/config')();
+
+    const request = awsConfigs.map((awsConfig) => {
+      return {
+        updateOne: {
+          filter: { key: awsConfig.newValue },
+          update:  { key: awsConfig.oldValue },
+        },
+      };
+    });
+
+    await Config.bulkWrite(request);
+    logger.info('Migration has been successfully rollbacked');
+  },
+};

+ 15 - 2
src/server/crowi/index.js

@@ -105,6 +105,7 @@ Crowi.prototype.init = async function() {
     this.setupSlack(),
     this.setupSlack(),
     this.setupCsrf(),
     this.setupCsrf(),
     this.setUpFileUpload(),
     this.setUpFileUpload(),
+    this.setUpFileUploaderSwitchService(),
     this.setupAttachmentService(),
     this.setupAttachmentService(),
     this.setUpAcl(),
     this.setUpAcl(),
     this.setUpCustomize(),
     this.setUpCustomize(),
@@ -546,12 +547,24 @@ Crowi.prototype.setUpApp = async function() {
 /**
 /**
  * setup FileUploadService
  * setup FileUploadService
  */
  */
-Crowi.prototype.setUpFileUpload = async function() {
-  if (this.fileUploadService == null) {
+Crowi.prototype.setUpFileUpload = async function(isForceUpdate = false) {
+  if (this.fileUploadService == null || isForceUpdate) {
     this.fileUploadService = require('../service/file-uploader')(this);
     this.fileUploadService = require('../service/file-uploader')(this);
   }
   }
 };
 };
 
 
+/**
+ * setup FileUploaderSwitchService
+ */
+Crowi.prototype.setUpFileUploaderSwitchService = async function() {
+  const FileUploaderSwitchService = require('../service/file-uploader-switch');
+  this.fileUploaderSwitchService = new FileUploaderSwitchService(this);
+  // add as a message handler
+  if (this.s2sMessagingService != null) {
+    this.s2sMessagingService.addMessageHandler(this.fileUploaderSwitchService);
+  }
+};
+
 /**
 /**
  * setup AttachmentService
  * setup AttachmentService
  */
  */

+ 5 - 5
src/server/models/config.js

@@ -86,11 +86,11 @@ module.exports = function(crowi) {
       'security:passport-basic:isEnabled' : false,
       'security:passport-basic:isEnabled' : false,
       'security:passport-basic:isSameUsernameTreatedAsIdenticalUser': false,
       'security:passport-basic:isSameUsernameTreatedAsIdenticalUser': false,
 
 
-      'aws:bucket'          : 'growi',
-      'aws:region'          : 'ap-northeast-1',
-      'aws:accessKeyId'     : undefined,
-      'aws:secretAccessKey' : undefined,
-      'aws:customEndpoint'  : undefined,
+      'aws:s3Bucket'          : 'growi',
+      'aws:s3Region'          : 'ap-northeast-1',
+      'aws:s3AccessKeyId'     : undefined,
+      'aws:s3SecretAccessKey' : undefined,
+      'aws:s3CustomEndpoint'  : undefined,
 
 
       'mail:from'         : undefined,
       'mail:from'         : undefined,
       'mail:smtpHost'     : undefined,
       'mail:smtpHost'     : undefined,

+ 116 - 26
src/server/routes/apiv3/app-settings.js

@@ -105,6 +105,28 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *          secretAccessKey:
  *          secretAccessKey:
  *            type: string
  *            type: string
  *            description: secret key for authentification of AWS
  *            description: secret key for authentification of AWS
+ *      GcsSettingParams:
+ *        description: GcsSettingParams
+ *        type: object
+ *        properties:
+ *          gcsApiKeyJsonPath:
+ *            type: string
+ *            description: apiKeyJsonPath of gcp
+ *          gcsBucket:
+ *            type: string
+ *            description: bucket name of gcs
+ *          gcsUploadNamespace:
+ *            type: string
+ *            description: name space of gcs
+ *          envGcsApiKeyJsonPath:
+ *            type: string
+ *            description: Path of the JSON file that contains service account key to authenticate to GCP API
+ *          envGcsBucket:
+ *            type: string
+ *            description: Name of the GCS bucket
+ *          envGcsUploadNamespace:
+ *            type: string
+ *            description: Directory name to create in the bucket
  *      PluginSettingParams:
  *      PluginSettingParams:
  *        description: PluginSettingParams
  *        description: PluginSettingParams
  *        type: object
  *        type: object
@@ -116,7 +138,6 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
 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);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   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);
@@ -147,11 +168,16 @@ module.exports = (crowi) => {
       body('sesSecretAccessKey').trim(),
       body('sesSecretAccessKey').trim(),
     ],
     ],
     awsSetting: [
     awsSetting: [
-      body('region').trim().matches(/^[a-z]+-[a-z]+-\d+$/).withMessage((value, { req }) => req.t('validation.aws_region')),
-      body('customEndpoint').trim().matches(/^(https?:\/\/[^/]+|)$/).withMessage((value, { req }) => req.t('validation.aws_custom_endpoint')),
-      body('bucket').trim(),
-      body('accessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
-      body('secretAccessKey').trim(),
+      body('s3Region').trim().matches(/^[a-z]+-[a-z]+-\d+$/).withMessage((value, { req }) => req.t('validation.aws_region')),
+      body('s3CustomEndpoint').trim().matches(/^(https?:\/\/[^/]+|)$/).withMessage((value, { req }) => req.t('validation.aws_custom_endpoint')),
+      body('s3Bucket').trim(),
+      body('s3AccessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
+      body('s3SecretAccessKey').trim(),
+    ],
+    gcsSetting: [
+      body('gcsApiKeyJsonPath').trim(),
+      body('gcsBucket').trim(),
+      body('gcsUploadNamespace').trim(),
     ],
     ],
     pluginSetting: [
     pluginSetting: [
       body('isEnabledPlugins').isBoolean(),
       body('isEnabledPlugins').isBoolean(),
@@ -178,7 +204,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: app settings params
    *                      description: app settings params
    */
    */
-  router.get('/', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.get('/', accessTokenParser, loginRequiredStrictly, adminRequired, async(req, res) => {
     const appSettingsParams = {
     const appSettingsParams = {
       title: crowi.configManager.getConfig('crowi', 'app:title'),
       title: crowi.configManager.getConfig('crowi', 'app:title'),
       confidential: crowi.configManager.getConfig('crowi', 'app:confidential'),
       confidential: crowi.configManager.getConfig('crowi', 'app:confidential'),
@@ -188,6 +214,7 @@ module.exports = (crowi) => {
       envSiteUrl: crowi.configManager.getConfigFromEnvVars('crowi', 'app:siteUrl'),
       envSiteUrl: crowi.configManager.getConfigFromEnvVars('crowi', 'app:siteUrl'),
       isMailerSetup: crowi.mailService.isMailerSetup,
       isMailerSetup: crowi.mailService.isMailerSetup,
       fromAddress: crowi.configManager.getConfig('crowi', 'mail:from'),
       fromAddress: crowi.configManager.getConfig('crowi', 'mail:from'),
+
       transmissionMethod: crowi.configManager.getConfig('crowi', 'mail:transmissionMethod'),
       transmissionMethod: crowi.configManager.getConfig('crowi', 'mail:transmissionMethod'),
       smtpHost: crowi.configManager.getConfig('crowi', 'mail:smtpHost'),
       smtpHost: crowi.configManager.getConfig('crowi', 'mail:smtpHost'),
       smtpPort: crowi.configManager.getConfig('crowi', 'mail:smtpPort'),
       smtpPort: crowi.configManager.getConfig('crowi', 'mail:smtpPort'),
@@ -195,11 +222,23 @@ module.exports = (crowi) => {
       smtpPassword: crowi.configManager.getConfig('crowi', 'mail:smtpPassword'),
       smtpPassword: crowi.configManager.getConfig('crowi', 'mail:smtpPassword'),
       sesAccessKeyId: crowi.configManager.getConfig('crowi', 'mail:sesAccessKeyId'),
       sesAccessKeyId: crowi.configManager.getConfig('crowi', 'mail:sesAccessKeyId'),
       sesSecretAccessKey: crowi.configManager.getConfig('crowi', 'mail:sesSecretAccessKey'),
       sesSecretAccessKey: crowi.configManager.getConfig('crowi', 'mail:sesSecretAccessKey'),
-      region: crowi.configManager.getConfig('crowi', 'aws:region'),
-      customEndpoint: crowi.configManager.getConfig('crowi', 'aws:customEndpoint'),
-      bucket: crowi.configManager.getConfig('crowi', 'aws:bucket'),
-      accessKeyId: crowi.configManager.getConfig('crowi', 'aws:accessKeyId'),
-      secretAccessKey: crowi.configManager.getConfig('crowi', 'aws:secretAccessKey'),
+
+      fileUploadType: crowi.configManager.getConfig('crowi', 'app:fileUploadType'),
+      envFileUploadType: crowi.configManager.getConfigFromEnvVars('crowi', 'app:fileUploadType'),
+
+      s3Region: crowi.configManager.getConfig('crowi', 'aws:s3Region'),
+      s3CustomEndpoint: crowi.configManager.getConfig('crowi', 'aws:s3CustomEndpoint'),
+      s3Bucket: crowi.configManager.getConfig('crowi', 'aws:s3Bucket'),
+      s3AccessKeyId: crowi.configManager.getConfig('crowi', 'aws:s3AccessKeyId'),
+      s3SecretAccessKey: crowi.configManager.getConfig('crowi', 'aws:s3SecretAccessKey'),
+      gcsUseOnlyEnvVars: crowi.configManager.getConfig('crowi', 'gcs:isGcsEnvPrioritizes'),
+      gcsApiKeyJsonPath: crowi.configManager.getConfig('crowi', 'gcs:apiKeyJsonPath'),
+      gcsBucket: crowi.configManager.getConfig('crowi', 'gcs:bucket'),
+      gcsUploadNamespace: crowi.configManager.getConfig('crowi', 'gcs:uploadNamespace'),
+      envGcsApiKeyJsonPath: crowi.configManager.getConfigFromEnvVars('crowi', 'gcs:apiKeyJsonPath'),
+      envGcsBucket: crowi.configManager.getConfigFromEnvVars('crowi', 'gcs:bucket'),
+      envGcsUploadNamespace: crowi.configManager.getConfigFromEnvVars('crowi', 'gcs:uploadNamespace'),
+
       isEnabledPlugins: crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins'),
       isEnabledPlugins: crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins'),
     };
     };
     return res.apiv3({ appSettingsParams });
     return res.apiv3({ appSettingsParams });
@@ -532,25 +571,25 @@ module.exports = (crowi) => {
    */
    */
   router.put('/aws-setting', loginRequiredStrictly, adminRequired, csrf, validator.awsSetting, apiV3FormValidator, async(req, res) => {
   router.put('/aws-setting', loginRequiredStrictly, adminRequired, csrf, validator.awsSetting, apiV3FormValidator, async(req, res) => {
     const requestAwsSettingParams = {
     const requestAwsSettingParams = {
-      'aws:region': req.body.region,
-      'aws:customEndpoint': req.body.customEndpoint,
-      'aws:bucket': req.body.bucket,
-      'aws:accessKeyId': req.body.accessKeyId,
-      'aws:secretAccessKey': req.body.secretAccessKey,
+      'app:fileUploadType': req.body.fileUploadType,
+      'aws:s3Region': req.body.s3Region,
+      'aws:s3CustomEndpoint': req.body.s3CustomEndpoint,
+      'aws:s3Bucket': req.body.s3Bucket,
+      'aws:s3AccessKeyId': req.body.s3AccessKeyId,
+      'aws:s3SecretAccessKey': req.body.s3SecretAccessKey,
     };
     };
 
 
     try {
     try {
-      const { configManager } = crowi;
-
-      // update config without publishing S2sMessage
-      await configManager.updateConfigsInTheSameNamespace('crowi', requestAwsSettingParams, true);
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestAwsSettingParams, true);
+      await crowi.setUpFileUpload(true);
+      crowi.fileUploaderSwitchService.publishUpdatedMessage();
 
 
       const awsSettingParams = {
       const awsSettingParams = {
-        region: crowi.configManager.getConfig('crowi', 'aws:region'),
-        customEndpoint: crowi.configManager.getConfig('crowi', 'aws:customEndpoint'),
-        bucket: crowi.configManager.getConfig('crowi', 'aws:bucket'),
-        accessKeyId: crowi.configManager.getConfig('crowi', 'aws:accessKeyId'),
-        secretAccessKey: crowi.configManager.getConfig('crowi', 'aws:secretAccessKey'),
+        s3Region: crowi.configManager.getConfig('crowi', 'aws:s3Region'),
+        s3CustomEndpoint: crowi.configManager.getConfig('crowi', 'aws:s3CustomEndpoint'),
+        s3Bucket: crowi.configManager.getConfig('crowi', 'aws:s3Bucket'),
+        s3AccessKeyId: crowi.configManager.getConfig('crowi', 'aws:s3AccessKeyId'),
+        s3SecretAccessKey: crowi.configManager.getConfig('crowi', 'aws:s3SecretAccessKey'),
       };
       };
       return res.apiv3({ awsSettingParams });
       return res.apiv3({ awsSettingParams });
     }
     }
@@ -562,6 +601,57 @@ module.exports = (crowi) => {
 
 
   });
   });
 
 
+  /**
+   * @swagger
+   *
+   *    /app-settings/gcs-setting:
+   *      put:
+   *        tags: [AppSettings]
+   *        operationId: updateAppSettingGcsSetting
+   *        summary: /app-settings/gcs-setting
+   *        description: Update gcs setting
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/GcsSettingParams'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update gcs setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/GcsSettingParams'
+   */
+  router.put('/gcs-setting', loginRequiredStrictly, adminRequired, csrf, validator.gcsSetting, apiV3FormValidator, async(req, res) => {
+    const requestGcsSettingParams = {
+      'app:fileUploadType': req.body.fileUploadType,
+      'gcs:apiKeyJsonPath': req.body.gcsApiKeyJsonPath,
+      'gcs:bucket': req.body.gcsBucket,
+      'gcs:uploadNamespace': req.body.gcsUploadNamespace,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestGcsSettingParams, true);
+      await crowi.setUpFileUpload(true);
+      crowi.fileUploaderSwitchService.publishUpdatedMessage();
+
+      const gcsSettingParams = {
+        gcsApiKeyJsonPath: crowi.configManager.getConfig('crowi', 'gcs:apiKeyJsonPath'),
+        gcsBucket: crowi.configManager.getConfig('crowi', 'gcs:bucket'),
+        gcsUploadNamespace: crowi.configManager.getConfig('crowi', 'gcs:uploadNamespace'),
+      };
+      return res.apiv3({ gcsSettingParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating aws setting';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-awsSetting-failed'));
+    }
+
+  });
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *

+ 3 - 4
src/server/routes/apiv3/bookmarks.js

@@ -54,8 +54,7 @@ const router = express.Router();
 
 
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
+  const loginRequired = require('../../middlewares/login-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);
 
 
@@ -63,7 +62,7 @@ module.exports = (crowi) => {
 
 
   const validator = {
   const validator = {
     bookmarks: [
     bookmarks: [
-      body('pageId').isMongoId(),
+      body('pageId').isString(),
       body('bool').isBoolean(),
       body('bool').isBoolean(),
     ],
     ],
     bookmarkInfo: [
     bookmarkInfo: [
@@ -214,7 +213,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/Bookmark'
    *                  $ref: '#/components/schemas/Bookmark'
    */
    */
-  router.put('/', accessTokenParser, loginRequiredStrictly, csrf, validator.bookmarks, apiV3FormValidator, async(req, res) => {
+  router.put('/', accessTokenParser, loginRequired, csrf, validator.bookmarks, apiV3FormValidator, async(req, res) => {
     const { pageId, bool } = req.body;
     const { pageId, bool } = req.body;
 
 
     let bookmark;
     let bookmark;

+ 4 - 3
src/server/routes/apiv3/page.js

@@ -112,7 +112,8 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  */
  */
 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);
+  const loginRequired = require('../../middlewares/login-required')(crowi, true);
+  const loginRequiredStrictly = require('../../middlewares/login-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);
 
 
@@ -165,7 +166,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/Page'
    *                  $ref: '#/components/schemas/Page'
    */
    */
-  router.put('/likes', accessTokenParser, loginRequired, csrf, validator.likes, apiV3FormValidator, async(req, res) => {
+  router.put('/likes', accessTokenParser, loginRequiredStrictly, csrf, validator.likes, apiV3FormValidator, async(req, res) => {
     const { pageId, bool } = req.body;
     const { pageId, bool } = req.body;
 
 
     let page;
     let page;
@@ -227,7 +228,7 @@ module.exports = (crowi) => {
   *          200:
   *          200:
   *            description: Return page's markdown
   *            description: Return page's markdown
   */
   */
-  router.get('/export/:pageId', loginRequired, validator.export, async(req, res) => {
+  router.get('/export/:pageId', loginRequiredStrictly, validator.export, async(req, res) => {
     const { pageId } = req.params;
     const { pageId } = req.params;
     const { format, revisionId = null } = req.query;
     const { format, revisionId = null } = req.query;
     let revision;
     let revision;

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

@@ -156,7 +156,7 @@ module.exports = (crowi) => {
    *                      $ref: '#/components/schemas/PaginateResult'
    *                      $ref: '#/components/schemas/PaginateResult'
    */
    */
 
 
-  router.get('/', validator.statusList, apiV3FormValidator, async(req, res) => {
+  router.get('/', loginRequiredStrictly, validator.statusList, apiV3FormValidator, async(req, res) => {
 
 
     const page = parseInt(req.query.page) || 1;
     const page = parseInt(req.query.page) || 1;
     // status
     // status

+ 12 - 6
src/server/service/config-loader.js

@@ -23,12 +23,12 @@ const TYPES = {
  *  So, parameters of these are under consideration.
  *  So, parameters of these are under consideration.
  */
  */
 const ENV_VAR_NAME_TO_CONFIG_INFO = {
 const ENV_VAR_NAME_TO_CONFIG_INFO = {
-  // FILE_UPLOAD: {
-  //   ns:      ,
-  //   key:     ,
-  //   type:    ,
-  //   default:
-  // },
+  FILE_UPLOAD: {
+    ns:      'crowi',
+    key:     'app:fileUploadType',
+    type:    TYPES.STRING,
+    default: 'aws',
+  },
   // HACKMD_URI: {
   // HACKMD_URI: {
   //   ns:      ,
   //   ns:      ,
   //   key:     ,
   //   key:     ,
@@ -344,6 +344,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     type:    TYPES.STRING,
     default: null,
     default: null,
   },
   },
+  IS_GCS_ENV_PRIORITIZED: {
+    ns:      'crowi',
+    key:     'gcs:isGcsEnvPrioritizes',
+    type:    TYPES.BOOLEAN,
+    default: false,
+  },
 };
 };
 
 
 class ConfigLoader {
 class ConfigLoader {

+ 18 - 0
src/server/service/config-manager.js

@@ -24,6 +24,16 @@ const KEYS_FOR_SAML_USE_ONLY_ENV_OPTION = [
   'security:passport-saml:ABLCRule',
   'security:passport-saml:ABLCRule',
 ];
 ];
 
 
+const KEYS_FOR_FIEL_UPLOAD_USE_ONLY_ENV_OPTION = [
+  'app:fileUploadType',
+];
+
+const KEYS_FOR_GCS_USE_ONLY_ENV_OPTION = [
+  'gcs:apiKeyJsonPath',
+  'gcs:bucket',
+  'gcs:uploadNamespace',
+];
+
 class ConfigManager extends S2sMessageHandlable {
 class ConfigManager extends S2sMessageHandlable {
 
 
   constructor(configModel) {
   constructor(configModel) {
@@ -218,6 +228,14 @@ class ConfigManager extends S2sMessageHandlable {
         KEYS_FOR_SAML_USE_ONLY_ENV_OPTION.includes(key)
         KEYS_FOR_SAML_USE_ONLY_ENV_OPTION.includes(key)
         && this.defaultSearch('crowi', 'security:passport-saml:useOnlyEnvVarsForSomeOptions')
         && this.defaultSearch('crowi', 'security:passport-saml:useOnlyEnvVarsForSomeOptions')
       )
       )
+      // file upload option
+      // [TODO GW-4173] control with the env var gcs:isFileUploadEnvPrioritizes
+      || KEYS_FOR_FIEL_UPLOAD_USE_ONLY_ENV_OPTION.includes(key)
+      // gcs option
+      || (
+        KEYS_FOR_GCS_USE_ONLY_ENV_OPTION.includes(key)
+        && this.searchOnlyFromEnvVarConfigs('crowi', 'gcs:isGcsEnvPrioritizes')
+      )
     ));
     ));
   }
   }
 
 

+ 60 - 0
src/server/service/file-uploader-switch.js

@@ -0,0 +1,60 @@
+const logger = require('@alias/logger')('growi:service:FileUploader');
+
+const S2sMessage = require('../models/vo/s2s-message');
+const S2sMessageHandlable = require('./s2s-messaging/handlable');
+
+class fileUploaderSwitch extends S2sMessageHandlable {
+
+  constructor(crowi) {
+    super();
+
+    this.crowi = crowi;
+    this.configManager = crowi.configManager;
+    this.s2sMessagingService = crowi.s2sMessagingService;
+    this.appService = crowi.appService;
+    this.xssService = crowi.xssService;
+
+    this.lastLoadedAt = null;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleS2sMessage(s2sMessage) {
+    const { eventName, updatedAt } = s2sMessage;
+    if (eventName !== 'fileUploadServiceUpdated' || updatedAt == null) {
+      return false;
+    }
+
+    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleS2sMessage(s2sMessage) {
+    const { configManager } = this;
+
+    logger.info('Reset fileupload service by pubsub notification');
+    await configManager.loadConfigs();
+    await this.crowi.setUpFileUpload(true);
+  }
+
+  async publishUpdatedMessage() {
+    const { s2sMessagingService } = this;
+
+    if (s2sMessagingService != null) {
+      const s2sMessage = new S2sMessage('fileUploadServiceUpdated', { updatedAt: new Date() });
+
+      try {
+        await s2sMessagingService.publish(s2sMessage);
+      }
+      catch (e) {
+        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      }
+    }
+  }
+
+}
+
+module.exports = fileUploaderSwitch;

+ 10 - 10
src/server/service/file-uploader/aws.js

@@ -10,11 +10,11 @@ module.exports = function(crowi) {
 
 
   function getAwsConfig() {
   function getAwsConfig() {
     return {
     return {
-      accessKeyId: configManager.getConfig('crowi', 'aws:accessKeyId'),
-      secretAccessKey: configManager.getConfig('crowi', 'aws:secretAccessKey'),
-      region: configManager.getConfig('crowi', 'aws:region'),
-      bucket: configManager.getConfig('crowi', 'aws:bucket'),
-      customEndpoint: configManager.getConfig('crowi', 'aws:customEndpoint'),
+      accessKeyId: configManager.getConfig('crowi', 'aws:s3AccessKeyId'),
+      secretAccessKey: configManager.getConfig('crowi', 'aws:s3SecretAccessKey'),
+      region: configManager.getConfig('crowi', 'aws:s3Region'),
+      bucket: configManager.getConfig('crowi', 'aws:s3Bucket'),
+      customEndpoint: configManager.getConfig('crowi', 'aws:s3CustomEndpoint'),
     };
     };
   }
   }
 
 
@@ -67,13 +67,13 @@ module.exports = function(crowi) {
   }
   }
 
 
   lib.isValidUploadSettings = function() {
   lib.isValidUploadSettings = function() {
-    return this.configManager.getConfig('crowi', 'aws:accessKeyId') != null
-      && this.configManager.getConfig('crowi', 'aws:secretAccessKey') != null
+    return this.configManager.getConfig('crowi', 'aws:s3AccessKeyId') != null
+      && this.configManager.getConfig('crowi', 'aws:s3SecretAccessKey') != null
       && (
       && (
-        this.configManager.getConfig('crowi', 'aws:region') != null
-          || this.configManager.getConfig('crowi', 'aws:customEndpoint') != null
+        this.configManager.getConfig('crowi', 'aws:s3Region') != null
+          || this.configManager.getConfig('crowi', 'aws:s3CustomEndpoint') != null
       )
       )
-      && this.configManager.getConfig('crowi', 'aws:bucket') != null;
+      && this.configManager.getConfig('crowi', 'aws:s3Bucket') != null;
   };
   };
 
 
 
 

+ 13 - 7
src/server/service/file-uploader/index.js

@@ -1,3 +1,5 @@
+const logger = require('@alias/logger')('growi:service:FileUploaderServise');
+
 const envToModuleMappings = {
 const envToModuleMappings = {
   aws:     'aws',
   aws:     'aws',
   local:   'local',
   local:   'local',
@@ -9,22 +11,26 @@ const envToModuleMappings = {
   gcs:     'gcs',
   gcs:     'gcs',
 };
 };
 
 
-class FileUploaderFactory {
+class FileUploadServiceFactory {
+
+  initializeUploader(crowi) {
+    const method = envToModuleMappings[crowi.configManager.getConfig('crowi', 'app:fileUploadType')];
+    const modulePath = `./${method}`;
+    this.uploader = require(modulePath)(crowi);
 
 
-  getUploader(crowi) {
     if (this.uploader == null) {
     if (this.uploader == null) {
-      const method = envToModuleMappings[process.env.FILE_UPLOAD] || 'aws';
-      const modulePath = `./${method}`;
-      this.uploader = require(modulePath)(crowi);
+      logger.warn('Failed to initialize uploader.');
     }
     }
+  }
 
 
+  getUploader(crowi) {
+    this.initializeUploader(crowi);
     return this.uploader;
     return this.uploader;
   }
   }
 
 
 }
 }
 
 
-const factory = new FileUploaderFactory();
-
 module.exports = (crowi) => {
 module.exports = (crowi) => {
+  const factory = new FileUploadServiceFactory(crowi);
   return factory.getUploader(crowi);
   return factory.getUploader(crowi);
 };
 };

+ 4 - 4
src/server/util/middlewares.js

@@ -155,10 +155,10 @@ module.exports = (crowi) => {
 
 
   middlewares.awsEnabled = function() {
   middlewares.awsEnabled = function() {
     return function(req, res, next) {
     return function(req, res, next) {
-      if ((configManager.getConfig('crowi', 'aws:region') !== '' || this.configManager.getConfig('crowi', 'aws:customEndpoint') !== '')
-          && configManager.getConfig('crowi', 'aws:bucket') !== ''
-          && configManager.getConfig('crowi', 'aws:accessKeyId') !== ''
-          && configManager.getConfig('crowi', 'aws:secretAccessKey') !== '') {
+      if ((configManager.getConfig('crowi', 'aws:s3Region') !== '' || this.configManager.getConfig('crowi', 'aws:s3CustomEndpoint') !== '')
+          && configManager.getConfig('crowi', 'aws:s3Bucket') !== ''
+          && configManager.getConfig('crowi', 'aws:s3AccessKeyId') !== ''
+          && configManager.getConfig('crowi', 'aws:s3SecretAccessKey') !== '') {
         req.flash('globalError', req.t('message.aws_sttings_required'));
         req.flash('globalError', req.t('message.aws_sttings_required'));
         return res.redirect('/');
         return res.redirect('/');
       }
       }

+ 0 - 1
src/server/views/layout-growi/forbidden.html

@@ -5,7 +5,6 @@
   {% include '../widget/page_alerts.html' %}
   {% include '../widget/page_alerts.html' %}
 {% endblock %}
 {% endblock %}
 
 
-
 {% block content_main %}
 {% block content_main %}
   <div class="row">
   <div class="row">
     <div class="col grw-page-content-container">
     <div class="col grw-page-content-container">

+ 2 - 0
src/server/views/widget/forbidden_content.html

@@ -10,6 +10,8 @@
 <div id="content-main" class="content-main page-list"
 <div id="content-main" class="content-main page-list"
   data-path="{{ encodeURI(path) }}"
   data-path="{{ encodeURI(path) }}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
+  data-page-is-forbidden="true"
+  data-page-is-not-creatable="true"
   >
   >
 
 
   <div class="row row-alerts d-edit-none">
   <div class="row row-alerts d-edit-none">

+ 1 - 1
src/server/views/widget/not_creatable_content.html

@@ -10,7 +10,7 @@
 <div id="content-main" class="content-main page-list"
 <div id="content-main" class="content-main page-list"
   data-path="{{ encodeURI(path) }}"
   data-path="{{ encodeURI(path) }}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
-  data-page-is-creatable="true"
+  data-page-is-not-creatable="true"
   ></div>
   ></div>
 
 
 </div>
 </div>

+ 2 - 2
src/server/views/widget/page_content.html

@@ -14,10 +14,10 @@
   data-page-grant-group-name="{{ grantedGroupName }}"
   data-page-grant-group-name="{{ grantedGroupName }}"
   data-page-is-liked="{% if user %}{{ page.isLiked(user) }}{% else %}false{% endif %}"
   data-page-is-liked="{% if user %}{{ page.isLiked(user) }}{% else %}false{% endif %}"
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
-  data-page-is-forbidden="{% if forbidden %}true{% else %}false{% endif %}"
+  data-page-is-forbidden="false"
   data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
   data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
   data-page-is-deletable="{% if isDeletablePage() %}true{% else %}false{% endif %}"
   data-page-is-deletable="{% if isDeletablePage() %}true{% else %}false{% endif %}"
-  data-page-is-creatable="false"
+  data-page-is-not-creatable="false"
   data-page-is-able-to-delete-completely="{% if user.canDeleteCompletely(page.creator._id) %}true{% else %}false{% endif %}"
   data-page-is-able-to-delete-completely="{% if user.canDeleteCompletely(page.creator._id) %}true{% else %}false{% endif %}"
   data-slack-channels="{{ slack|default('') }}"
   data-slack-channels="{{ slack|default('') }}"
   data-page-created-at="{% if page %}{{ page.createdAt|datetz('Y/m/d H:i:s') }}{% endif %}"
   data-page-created-at="{% if page %}{{ page.createdAt|datetz('Y/m/d H:i:s') }}{% endif %}"