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

Merge pull request #2583 from weseek/imprv/bookmark-list-appearance

Imprv/bookmark list appearance
Ryuichi Paul E. Egoshi 5 лет назад
Родитель
Сommit
fd8dabc041
99 измененных файлов с 2415 добавлено и 843 удалено
  1. 22 2
      CHANGES.md
  2. 4 4
      README.md
  3. 2 2
      bin/github-actions/update-readme.sh
  4. 2 0
      config/env.dev.js
  5. 2 3
      config/logger/config.dev.js
  6. 4 2
      docker/README.md
  7. 6 4
      package.json
  8. 1 1
      resource/cdn-manifests.js
  9. 1 0
      resource/locales/en_US/translation.json
  10. 1 0
      resource/locales/ja_JP/translation.json
  11. 3 2
      src/client/js/admin.jsx
  12. 2 2
      src/client/js/app.jsx
  13. 5 5
      src/client/js/base.jsx
  14. 1 1
      src/client/js/components/Admin/App/MailSetting.jsx
  15. 7 7
      src/client/js/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx
  16. 12 8
      src/client/js/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  17. 4 4
      src/client/js/components/Admin/ExportArchiveDataPage.jsx
  18. 5 5
      src/client/js/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  19. 1 1
      src/client/js/components/Admin/Users/UserInviteModal.jsx
  20. 80 0
      src/client/js/components/Hotkeys/HotkeysDetector.jsx
  21. 79 0
      src/client/js/components/Hotkeys/HotkeysManager.jsx
  22. 31 0
      src/client/js/components/Hotkeys/Subscribers/CreatePage.jsx
  23. 30 0
      src/client/js/components/Hotkeys/Subscribers/EditPage.jsx
  24. 26 0
      src/client/js/components/Hotkeys/Subscribers/ShowShortcutsModal.jsx
  25. 20 0
      src/client/js/components/Hotkeys/Subscribers/ShowStaffCredit.jsx
  26. 25 0
      src/client/js/components/Hotkeys/Subscribers/SwitchToMirrorMode.jsx
  27. 13 13
      src/client/js/components/InstallerForm.jsx
  28. 25 32
      src/client/js/components/MyBookmarkList/MyBookmarkList.jsx
  29. 2 2
      src/client/js/components/Navbar/PersonalDropdown.jsx
  30. 1 1
      src/client/js/components/Page.jsx
  31. 8 3
      src/client/js/components/Page/RevisionRenderer.jsx
  32. 1 1
      src/client/js/components/PageAttachment.jsx
  33. 3 3
      src/client/js/components/PageEditor/MarkdownDrawioUtil.js
  34. 1 1
      src/client/js/components/PageStatusAlert.jsx
  35. 1 1
      src/client/js/components/Sidebar/RecentChanges.jsx
  36. 42 57
      src/client/js/components/StaffCredit/StaffCredit.jsx
  37. 12 3
      src/client/js/components/TableOfContents.jsx
  38. 0 1
      src/client/js/hackmd-agent.js
  39. 0 40
      src/client/js/legacy/crowi.js
  40. 57 0
      src/client/js/models/HotkeyStroke.js
  41. 19 0
      src/client/js/services/AdminSocketIoContainer.js
  42. 16 20
      src/client/js/services/NavigationContainer.js
  43. 16 16
      src/client/js/services/PageContainer.js
  44. 9 5
      src/client/js/services/SocketIoContainer.js
  45. 0 16
      src/client/js/util/interceptor/drawio-interceptor.js
  46. 1 6
      src/client/styles/scss/_layout_kibela.scss
  47. 8 0
      src/client/styles/scss/_me.scss
  48. 3 0
      src/client/styles/scss/_mirror_mode.scss
  49. 4 4
      src/client/styles/scss/_search.scss
  50. 9 11
      src/client/styles/scss/_shortcuts.scss
  51. 15 8
      src/client/styles/scss/_staff_credit.scss
  52. 1 0
      src/client/styles/scss/style-app.scss
  53. 2 0
      src/server/crowi/express-init.js
  54. 102 41
      src/server/crowi/index.js
  55. 0 11
      src/server/events/search.js
  56. 11 0
      src/server/middlewares/auto-reconnect-to-s2s-msg-server.js
  57. 2 0
      src/server/models/bookmark.js
  58. 75 75
      src/server/models/page.js
  59. 3 3
      src/server/models/user.js
  60. 29 0
      src/server/models/vo/s2s-message.js
  61. 0 13
      src/server/routes/admin.js
  62. 23 10
      src/server/routes/apiv3/app-settings.js
  63. 67 8
      src/server/routes/apiv3/bookmarks.js
  64. 6 2
      src/server/routes/apiv3/customize-setting.js
  65. 4 4
      src/server/routes/apiv3/export.js
  66. 4 4
      src/server/routes/apiv3/import.js
  67. 27 19
      src/server/routes/apiv3/security-setting.js
  68. 0 5
      src/server/routes/hackmd.js
  69. 1 1
      src/server/routes/index.js
  70. 4 5
      src/server/routes/installer.js
  71. 2 3
      src/server/routes/login.js
  72. 3 35
      src/server/routes/page.js
  73. 65 4
      src/server/service/app.js
  74. 36 0
      src/server/service/config-loader.js
  75. 58 4
      src/server/service/config-manager.js
  76. 57 5
      src/server/service/customize.js
  77. 3 2
      src/server/service/global-notification/global-notification-mail.js
  78. 167 0
      src/server/service/mail.js
  79. 29 0
      src/server/service/page.js
  80. 97 46
      src/server/service/passport.js
  81. 64 0
      src/server/service/s2s-messaging/base.js
  82. 18 0
      src/server/service/s2s-messaging/handlable.js
  83. 46 0
      src/server/service/s2s-messaging/index.js
  84. 194 0
      src/server/service/s2s-messaging/nchan.js
  85. 5 0
      src/server/service/s2s-messaging/redis.js
  86. 7 7
      src/server/service/search-delegator/elasticsearch.js
  87. 2 4
      src/server/service/search.js
  88. 37 0
      src/server/service/socket-io.js
  89. 110 0
      src/server/service/system-events/sync-page-status.js
  90. 0 120
      src/server/util/mailer.js
  91. 3 2
      src/server/views/layout/layout.html
  92. 16 14
      src/server/views/modal/shortcuts.html
  93. 2 0
      src/server/views/search.html
  94. 31 0
      src/server/views/widget/headers/drawio.html
  95. 17 12
      src/server/views/widget/headers/mathjax.html
  96. 1 1
      src/server/views/widget/page_list.html
  97. 178 14
      src/test/models/page.test.js
  98. 58 0
      src/test/service/config-manager.test.js
  99. 106 72
      yarn.lock

+ 22 - 2
CHANGES.md

@@ -1,6 +1,10 @@
 # CHANGES
 
-## v4.1.0-RC
+## v4.1.1-RC
+
+* 
+
+## v4.1.0
 
 ### BREAKING CHANGES
 
@@ -11,16 +15,32 @@ Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/41x.html>
 
 ### Updates
 
+* Feature: Server settings synchronization for multiple GROWI Apps
+* Feature: Page status alert synchronization for multiple GROWI Apps
+* Feature: Smooth scroll for anchor links
+* Feature: Mirror Mode with [Konami Code](https://en.wikipedia.org/wiki/Konami_Code)
+* Improvement: Determine whether the "In Use" badge is displayed or not by attachment ID
+* Improvement: draw.io under NO_CDN environment
+* Fix: Deleting/renaming with recursive option affects pages that are inaccessible to active users
+* Fix: DrawioModal cuts without beginning/ending line
+* Fix: New settings of SMTP and AWS SES are not reflected when server is running
+* Fix: Sidebar layout broken when using Kibela layout
 * Support: Support Node.js v14
+* Support: Update libs
+    * mathjax
 
+## v4.0.11
 
+* Fix: Fab on search result page does not displayed
+* Fix: Adjust margin/padding for search result page
+* Fix: PageAlert broken
+    * Introduced by v4.0.9
 
 ## v4.0.10
 
 * Improvement: Adjust ToC height
 * Fix: Fail to rename/delete a page set as "Anyone with the link"
 
-
 ## v4.0.9
 
 * Feature: Detailed configurations for OpenID Connect

+ 4 - 4
README.md

@@ -111,11 +111,11 @@ See [confirmed versions](https://docs.growi.org/en/dev/startup/dev-env.html#set-
 
 |command|desc|
 |--|--|
-|`npm run build:prod`|Build the client|
-|`npm run server:prod`|Launch the server|
-|`npm start`|Invoke `npm run build:prod` and `npm run server:prod`|
+|`yarn run build:prod`|Build the client|
+|`yarn run server:prod`|Launch the server|
+|`yarn start`|Invoke `yarn run build:prod` and `yarn run server:prod`|
 
-For more info, see [GROWI Docs: List of npm Commands](https://docs.growi.org/en/dev/startup/launch.html#list-of-npm-commands).
+For more info, see [GROWI Docs: List of npm Commands](https://docs.growi.org/en/dev/startup-v2/launch.html#list-of-npm-commands).
 
 
 Documentation

+ 2 - 2
bin/github-actions/update-readme.sh

@@ -2,5 +2,5 @@
 
 cd docker
 
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.0\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}\2\3${RELEASE_VERSION}\4/" README.md
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.0-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}-nocdn\2\3${RELEASE_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.1\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}\2\3${RELEASE_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.1-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}-nocdn\2\3${RELEASE_VERSION}\4/" README.md

+ 2 - 0
config/env.dev.js

@@ -6,10 +6,12 @@ module.exports = {
   // NO_CDN: true,
   MONGO_URI: 'mongodb://mongo:27017/growi',
   // REDIS_URI: 'http://redis:6379',
+  // NCHAN_URI: 'http://nchan',
   ELASTICSEARCH_URI: 'http://elasticsearch:9200/growi',
   HACKMD_URI: 'http://localhost:3010',
   HACKMD_URI_FOR_SERVER: 'http://hackmd:3000',
   // DRAWIO_URI: 'http://localhost:8080/?offline=1&https=0',
+  // S2SMSG_PUBSUB_SERVER_TYPE: 'nchan',
   PLUGIN_NAMES_TOBE_LOADED: [
     // 'growi-plugin-lsx',
     // 'growi-plugin-pukiwiki-like-linker',

+ 2 - 3
config/logger/config.dev.js

@@ -16,7 +16,9 @@ module.exports = {
   'growi:routes:login-passport': 'debug',
   'growi:middleware:safe-redirect': 'debug',
   'growi:service:PassportService': 'debug',
+  'growi:service:s2s-messaging:*': 'debug',
   // 'growi:service:ConfigManager': 'debug',
+  // 'growi:service:mail': 'debug',
   'growi:lib:search': 'debug',
   // 'growi:service:GlobalNotification': 'debug',
   // 'growi:lib:importer': 'debug',
@@ -24,9 +26,6 @@ module.exports = {
   'growi-plugin:*': 'debug',
   // 'growi:InterceptorManager': 'debug',
 
-  // email
-  // 'growi:lib:mailer': 'debug',
-
   /*
    * configure level for client
    */

+ 4 - 2
docker/README.md

@@ -10,8 +10,10 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`4.0.0`, `4.0`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.0.0/docker/Dockerfile)
-* [`4.0.0-nocdn`, `4.0-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.0.0/docker/Dockerfile)
+* [`4.1.0`, `4.1`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.1.0/docker/Dockerfile)
+* [`4.1.0-nocdn`, `4.1-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.1.0/docker/Dockerfile)
+* [`4.0.11`, `4.0`(Dockerfile)](https://github.com/weseek/growi/blob/v4.0.11/docker/Dockerfile)
+* [`4.0.11-nocdn`, `4.0-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.0.11/docker/Dockerfile)
 * [`3.8.0`, `3.8`, `3` (Dockerfile)](https://github.com/weseek/growi/blob/v3.8.0/docker/Dockerfile)
 * [`3.8.0-nocdn`, `3.8-nocdn`, `3-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v3.8.0/docker/Dockerfile)
 

+ 6 - 4
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.1.0-RC",
+  "version": "4.1.1-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -137,10 +137,11 @@
     "passport-twitter": "^1.0.4",
     "react-card-flip": "^1.0.10",
     "react-image-crop": "^8.3.0",
+    "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "rimraf": "^3.0.0",
     "slack-node": "^0.1.8",
-    "socket.io": "^2.0.3",
+    "socket.io": "^2.3.0",
     "stream-to-promise": "^2.2.0",
     "string-width": "^4.1.0",
     "swig-templates": "^2.0.2",
@@ -148,6 +149,7 @@
     "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
     "validator": "^12.0.0",
+    "ws": "^7.3.1",
     "xss": "^1.0.6"
   },
   "devDependencies": {
@@ -202,7 +204,7 @@
     "lodash-webpack-plugin": "^0.11.5",
     "markdown-it": "^10.0.0",
     "markdown-it-blockdiag": "^1.1.1",
-    "markdown-it-drawio-viewer": "^1.2.0",
+    "markdown-it-drawio-viewer": "^1.3.0",
     "markdown-it-emoji": "^1.4.0",
     "markdown-it-footnote": "^3.0.1",
     "markdown-it-mathjax": "^2.0.0",
@@ -239,7 +241,7 @@
     "rs-i18n": "^0.0.9",
     "sass-loader": "^8.0.0",
     "simple-load-script": "^1.0.2",
-    "socket.io-client": "^2.0.3",
+    "socket.io-client": "^2.3.0",
     "sticky-events": "^3.1.3",
     "style-loader": "^1.0.0",
     "styled-components": "^5.0.1",

+ 1 - 1
resource/cdn-manifests.js

@@ -39,7 +39,7 @@ module.exports = {
     },
     {
       name: 'mathjax',
-      url: 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.2/MathJax.js',
+      url: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js',
       args: {
         async: true,
         integrity: '',

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

@@ -318,6 +318,7 @@
       "Edit Page": "Edit Page",
       "Create Page": "Create Page",
       "Show Contributors": "Show Contributors",
+      "MirrorMode": "Mirror Mode",
       "Konami Code": "Konami Code",
       "konami_code_url": "https://en.wikipedia.org/wiki/Konami_Code"
     },

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

@@ -319,6 +319,7 @@
       "Edit Page": "ページ編集",
       "Create Page": "ページ作成",
       "Show Contributors": "コントリビューター<br>を表示",
+      "MirrorMode": "ミラーモード",
       "Konami Code": "コナミコマンド",
       "konami_code_url": "https://ja.wikipedia.org/wiki/コナミコマンド"
     },

+ 3 - 2
src/client/js/admin.jsx

@@ -25,6 +25,7 @@ import AdminNavigation from './components/Admin/Common/AdminNavigation';
 
 import NavigationContainer from './services/NavigationContainer';
 
+import AdminSocketIoContainer from './services/AdminSocketIoContainer';
 import AdminHomeContainer from './services/AdminHomeContainer';
 import AdminCustomizeContainer from './services/AdminCustomizeContainer';
 import AdminUserGroupDetailContainer from './services/AdminUserGroupDetailContainer';
@@ -50,11 +51,11 @@ const logger = loggerFactory('growi:admin');
 appContainer.initContents();
 
 const { i18n } = appContainer;
-const websocketContainer = appContainer.getContainer('WebsocketContainer');
 
 // create unstated container instance
 const navigationContainer = new NavigationContainer(appContainer);
 const adminAppContainer = new AdminAppContainer(appContainer);
+const adminSocketIoContainer = new AdminSocketIoContainer(appContainer);
 const adminHomeContainer = new AdminHomeContainer(appContainer);
 const adminCustomizeContainer = new AdminCustomizeContainer(appContainer);
 const adminUsersContainer = new AdminUsersContainer(appContainer);
@@ -64,9 +65,9 @@ const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
 const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appContainer);
 const injectableContainers = [
   appContainer,
-  websocketContainer,
   navigationContainer,
   adminAppContainer,
+  adminSocketIoContainer,
   adminHomeContainer,
   adminCustomizeContainer,
   adminUsersContainer,

+ 2 - 2
src/client/js/app.jsx

@@ -46,7 +46,7 @@ const logger = loggerFactory('growi:cli:app');
 appContainer.initContents();
 
 const { i18n } = appContainer;
-const websocketContainer = appContainer.getContainer('WebsocketContainer');
+const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 
 // create unstated container instance
 const navigationContainer = new NavigationContainer(appContainer);
@@ -56,7 +56,7 @@ const editorContainer = new EditorContainer(appContainer, defaultEditorOptions,
 const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const injectableContainers = [
-  appContainer, websocketContainer, navigationContainer, pageContainer, commentContainer, editorContainer, tagContainer, personalContainer,
+  appContainer, socketIoContainer, navigationContainer, pageContainer, commentContainer, editorContainer, tagContainer, personalContainer,
 ];
 
 logger.info('unstated containers have been initialized');

+ 5 - 5
src/client/js/base.jsx

@@ -6,11 +6,11 @@ import Xss from '@commons/service/xss';
 import GrowiNavbar from './components/Navbar/GrowiNavbar';
 import GrowiNavbarBottom from './components/Navbar/GrowiNavbarBottom';
 import Sidebar from './components/Sidebar';
+import HotkeysManager from './components/Hotkeys/HotkeysManager';
 import Fab from './components/Fab';
-import StaffCredit from './components/StaffCredit/StaffCredit';
 
 import AppContainer from './services/AppContainer';
-import WebsocketContainer from './services/WebsocketContainer';
+import SocketIoContainer from './services/SocketIoContainer';
 import PageCreateModal from './components/PageCreateModal';
 
 const logger = loggerFactory('growi:cli:app');
@@ -26,7 +26,7 @@ window.xss = xss;
 // create unstated container instance
 const appContainer = new AppContainer();
 // eslint-disable-next-line no-unused-vars
-const websocketContainer = new WebsocketContainer(appContainer);
+const socketIoContainer = new SocketIoContainer(appContainer);
 
 appContainer.initApp();
 
@@ -45,9 +45,9 @@ const componentMappings = {
 
   'grw-sidebar-wrapper': <Sidebar />,
 
-  'grw-fab-container': <Fab />,
+  'grw-hotkeys-manager': <HotkeysManager />,
 
-  'staff-credit': <StaffCredit />,
+  'grw-fab-container': <Fab />,
 };
 
 export { appContainer, componentMappings };

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

@@ -25,7 +25,7 @@ class MailSetting extends React.Component {
 
     try {
       await adminAppContainer.updateMailSettingHandler();
-      toastSuccess(t('toster.update_successed', { target: t('admin:app_setting.mail_settings') }));
+      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.mail_settings') }));
     }
     catch (err) {
       toastError(err);

+ 7 - 7
src/client/js/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx

@@ -4,7 +4,7 @@ import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
-import WebsocketContainer from '../../../services/WebsocketContainer';
+import AdminSocketIoContainer from '../../../services/AdminSocketIoContainer';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
 
 import StatusTable from './StatusTable';
@@ -45,22 +45,22 @@ class ElasticsearchManagement extends React.Component {
   }
 
   initWebSockets() {
-    const socket = this.props.websocketContainer.getWebSocket();
+    const socket = this.props.adminSocketIoContainer.getSocket();
 
-    socket.on('admin:addPageProgress', (data) => {
+    socket.on('addPageProgress', (data) => {
       this.setState({
         isRebuildingProcessing: true,
       });
     });
 
-    socket.on('admin:finishAddPage', (data) => {
+    socket.on('finishAddPage', (data) => {
       this.setState({
         isRebuildingProcessing: false,
         isRebuildingCompleted: true,
       });
     });
 
-    socket.on('admin:rebuildingFailed', (data) => {
+    socket.on('rebuildingFailed', (data) => {
       toastError(new Error(data.error), 'Rebuilding Index has failed.');
     });
   }
@@ -224,12 +224,12 @@ class ElasticsearchManagement extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const ElasticsearchManagementWrapper = withUnstatedContainers(ElasticsearchManagement, [AppContainer, WebsocketContainer]);
+const ElasticsearchManagementWrapper = withUnstatedContainers(ElasticsearchManagement, [AppContainer, AdminSocketIoContainer]);
 
 ElasticsearchManagement.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  websocketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
+  adminSocketIoContainer: PropTypes.instanceOf(AdminSocketIoContainer).isRequired,
 };
 
 export default withTranslation()(ElasticsearchManagementWrapper);

+ 12 - 8
src/client/js/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

@@ -4,7 +4,7 @@ import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
-import WebsocketContainer from '../../../services/WebsocketContainer';
+import AdminSocketIoContainer from '../../../services/AdminSocketIoContainer';
 
 import ProgressBar from '../Common/ProgressBar';
 
@@ -25,17 +25,21 @@ class RebuildIndexControls extends React.Component {
   }
 
   initWebSockets() {
-    const socket = this.props.websocketContainer.getWebSocket();
+    const socket = this.props.adminSocketIoContainer.getSocket();
 
-    socket.on('admin:addPageProgress', (data) => {
+    socket.on('addPageProgress', (data) => {
       this.setState({
-        ...data,
+        total: data.totalCount,
+        current: data.count,
+        skip: data.skipped,
       });
     });
 
-    socket.on('admin:finishAddPage', (data) => {
+    socket.on('finishAddPage', (data) => {
       this.setState({
-        ...data,
+        total: data.totalCount,
+        current: data.count,
+        skip: data.skipped,
       });
     });
 
@@ -97,12 +101,12 @@ class RebuildIndexControls extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const RebuildIndexControlsWrapper = withUnstatedContainers(RebuildIndexControls, [AppContainer, WebsocketContainer]);
+const RebuildIndexControlsWrapper = withUnstatedContainers(RebuildIndexControls, [AppContainer, AdminSocketIoContainer]);
 
 RebuildIndexControls.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  websocketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
+  adminSocketIoContainer: PropTypes.instanceOf(AdminSocketIoContainer).isRequired,
 
   isRebuildingProcessing: PropTypes.bool.isRequired,
   isRebuildingCompleted: PropTypes.bool.isRequired,

+ 4 - 4
src/client/js/components/Admin/ExportArchiveDataPage.jsx

@@ -8,7 +8,7 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 // import { toastSuccess, toastError } from '../../../util/apiNotification';
 
 import AppContainer from '../../services/AppContainer';
-import WebsocketContainer from '../../services/WebsocketContainer';
+import AdminSocketIoContainer from '../../services/AdminSocketIoContainer';
 
 import ProgressBar from './Common/ProgressBar';
 
@@ -67,7 +67,7 @@ class ExportArchiveDataPage extends React.Component {
   }
 
   setupWebsocketEventHandler() {
-    const socket = this.props.websocketContainer.getWebSocket();
+    const socket = this.props.adminSocketIoContainer.getSocket();
 
     // websocket event
     socket.on('admin:onProgressForExport', ({ currentCount, totalCount, progressList }) => {
@@ -248,12 +248,12 @@ class ExportArchiveDataPage extends React.Component {
 ExportArchiveDataPage.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  websocketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
+  adminSocketIoContainer: PropTypes.instanceOf(AdminSocketIoContainer).isRequired,
 };
 
 /**
  * Wrapper component for using unstated
  */
-const ExportArchiveDataPageWrapper = withUnstatedContainers(ExportArchiveDataPage, [AppContainer, WebsocketContainer]);
+const ExportArchiveDataPageWrapper = withUnstatedContainers(ExportArchiveDataPage, [AppContainer, AdminSocketIoContainer]);
 
 export default withTranslation()(ExportArchiveDataPageWrapper);

+ 5 - 5
src/client/js/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -8,7 +8,7 @@ import ImportOptionForRevisions from '@commons/models/admin/import-option-for-re
 
 import { withUnstatedContainers } from '../../../UnstatedUtils';
 import AppContainer from '../../../../services/AppContainer';
-import WebsocketContainer from '../../../../services/WebsocketContainer';
+import AdminSocketIoContainer from '../../../../services/AdminSocketIoContainer';
 import { toastSuccess, toastError } from '../../../../util/apiNotification';
 
 
@@ -102,7 +102,7 @@ class ImportForm extends React.Component {
   }
 
   setupWebsocketEventHandler() {
-    const socket = this.props.websocketContainer.getWebSocket();
+    const socket = this.props.adminSocketIoContainer.getSocket();
 
     // websocket event
     // eslint-disable-next-line object-curly-newline
@@ -142,7 +142,7 @@ class ImportForm extends React.Component {
   }
 
   teardownWebsocketEventHandler() {
-    const socket = this.props.websocketContainer.getWebSocket();
+    const socket = this.props.adminSocketIoContainer.getSocket();
 
     socket.removeAllListeners('admin:onProgressForImport');
     socket.removeAllListeners('admin:onTerminateForImport');
@@ -493,7 +493,7 @@ class ImportForm extends React.Component {
 ImportForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  websocketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
+  adminSocketIoContainer: PropTypes.instanceOf(AdminSocketIoContainer).isRequired,
 
   fileName: PropTypes.string,
   innerFileStats: PropTypes.arrayOf(PropTypes.object).isRequired,
@@ -504,6 +504,6 @@ ImportForm.propTypes = {
 /**
  * Wrapper component for using unstated
  */
-const ImportFormWrapper = withUnstatedContainers(ImportForm, [AppContainer, WebsocketContainer]);
+const ImportFormWrapper = withUnstatedContainers(ImportForm, [AppContainer, AdminSocketIoContainer]);
 
 export default withTranslation()(ImportFormWrapper);

+ 1 - 1
src/client/js/components/Admin/Users/UserInviteModal.jsx

@@ -149,7 +149,7 @@ class UserInviteModal extends React.Component {
 
     return (
       <>
-        <p className="text-warning">{t('admin:user_management.existing_email')}</p>
+        <p className="text-warning">{t('admin:user_management.invite_modal.existing_email')}</p>
         <ul>
           {emailList.map((user) => {
             return (

+ 80 - 0
src/client/js/components/Hotkeys/HotkeysDetector.jsx

@@ -0,0 +1,80 @@
+import React, { useMemo, useCallback } from 'react';
+import PropTypes from 'prop-types';
+
+import { GlobalHotKeys } from 'react-hotkeys';
+
+import HotkeyStroke from '../../models/HotkeyStroke';
+
+const HotkeysDetector = (props) => {
+
+  const { keySet, strokeSet, onDetected } = props;
+
+  // memorize HotkeyStroke instances
+  const hotkeyStrokes = useMemo(
+    () => {
+      const strokes = Array.from(strokeSet);
+      return strokes.map(stroke => new HotkeyStroke(stroke));
+    },
+    [strokeSet],
+  );
+
+  /**
+   * return key expression string includes modifier
+   */
+  const getKeyExpression = useCallback((event) => {
+    let eventKey = event.key;
+
+    if (event.ctrlKey) {
+      eventKey += '+ctrl';
+    }
+    if (event.metaKey) {
+      eventKey += '+meta';
+    }
+    if (event.altKey) {
+      eventKey += '+alt';
+    }
+    if (event.shiftKey) {
+      eventKey += '+shift';
+    }
+
+    return eventKey;
+  }, []);
+
+  /**
+   * evaluate the key user pressed and trigger onDetected
+   */
+  const checkHandler = useCallback((event) => {
+    event.preventDefault();
+
+    const eventKey = getKeyExpression(event);
+
+    hotkeyStrokes.forEach((hotkeyStroke) => {
+      if (hotkeyStroke.evaluate(eventKey)) {
+        onDetected(hotkeyStroke.stroke);
+      }
+    });
+  }, [hotkeyStrokes, getKeyExpression, onDetected]);
+
+  // memorize keyMap for GlobalHotKeys
+  const keyMap = useMemo(() => {
+    return { check: Array.from(keySet) };
+  }, [keySet]);
+
+  // memorize handlers for GlobalHotKeys
+  const handlers = useMemo(() => {
+    return { check: checkHandler };
+  }, [checkHandler]);
+
+  return (
+    <GlobalHotKeys keyMap={keyMap} handlers={handlers} />
+  );
+
+};
+
+HotkeysDetector.propTypes = {
+  onDetected: PropTypes.func.isRequired,
+  keySet: PropTypes.instanceOf(Set).isRequired,
+  strokeSet: PropTypes.instanceOf(Set).isRequired,
+};
+
+export default HotkeysDetector;

+ 79 - 0
src/client/js/components/Hotkeys/HotkeysManager.jsx

@@ -0,0 +1,79 @@
+import React, { useState } from 'react';
+
+import HotkeysDetector from './HotkeysDetector';
+
+import ShowStaffCredit from './Subscribers/ShowStaffCredit';
+import SwitchToMirrorMode from './Subscribers/SwitchToMirrorMode';
+import ShowShortcutsModal from './Subscribers/ShowShortcutsModal';
+import CreatePage from './Subscribers/CreatePage';
+import EditPage from './Subscribers/EditPage';
+
+// define supported components list
+const SUPPORTED_COMPONENTS = [
+  ShowStaffCredit,
+  SwitchToMirrorMode,
+  ShowShortcutsModal,
+  CreatePage,
+  EditPage,
+];
+
+const KEY_SET = new Set();
+const STROKE_SET = new Set();
+const STROKE_TO_COMPONENT_MAP = {};
+
+SUPPORTED_COMPONENTS.forEach((comp) => {
+  const strokes = comp.getHotkeyStrokes();
+
+  strokes.forEach((stroke) => {
+    // register key
+    stroke.forEach(key => KEY_SET.add(key));
+    // register stroke
+    STROKE_SET.add(stroke);
+    // register component
+    const componentList = STROKE_TO_COMPONENT_MAP[stroke] || [];
+    componentList.push(comp);
+    STROKE_TO_COMPONENT_MAP[stroke.toString()] = componentList;
+  });
+});
+
+const HotkeysManager = (props) => {
+  const [view, setView] = useState([]);
+
+  /**
+   * delete the instance in state.view
+   */
+  const deleteRender = (instance) => {
+    const index = view.lastIndexOf(instance);
+
+    const newView = view.slice(); // shallow copy
+    newView.splice(index, 1);
+    setView(newView);
+  };
+
+  /**
+   * activates when one of the hotkey strokes gets determined from HotkeysDetector
+   */
+  const onDetected = (strokeDetermined) => {
+    const key = (Math.random() * 1000).toString();
+    const components = STROKE_TO_COMPONENT_MAP[strokeDetermined.toString()];
+
+    const newViews = components.map(Component => (
+      <Component key={key} onDeleteRender={deleteRender} />
+    ));
+    setView(view.concat(newViews).flat());
+  };
+
+  return (
+    <>
+      <HotkeysDetector
+        onDetected={stroke => onDetected(stroke)}
+        keySet={KEY_SET}
+        strokeSet={STROKE_SET}
+      />
+      {view}
+    </>
+  );
+
+};
+
+export default HotkeysManager;

+ 31 - 0
src/client/js/components/Hotkeys/Subscribers/CreatePage.jsx

@@ -0,0 +1,31 @@
+import React, { useEffect } from 'react';
+import PropTypes from 'prop-types';
+
+import NavigationContainer from '../../../services/NavigationContainer';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+const CreatePage = (props) => {
+
+  // setup effect
+  useEffect(() => {
+    props.navigationContainer.openPageCreateModal();
+
+    // remove this
+    props.onDeleteRender(this);
+  }, [props]);
+
+  return <></>;
+};
+
+CreatePage.propTypes = {
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+  onDeleteRender: PropTypes.func.isRequired,
+};
+
+const CreatePageWrapper = withUnstatedContainers(CreatePage, [NavigationContainer]);
+
+CreatePageWrapper.getHotkeyStrokes = () => {
+  return [['c']];
+};
+
+export default CreatePageWrapper;

+ 30 - 0
src/client/js/components/Hotkeys/Subscribers/EditPage.jsx

@@ -0,0 +1,30 @@
+import React, { useEffect } from 'react';
+import PropTypes from 'prop-types';
+
+const EditPage = (props) => {
+
+  // setup effect
+  useEffect(() => {
+    // ignore when dom that has 'modal in' classes exists
+    if (document.getElementsByClassName('modal in').length > 0) {
+      return;
+    }
+    // show editor
+    $('a[data-toggle="tab"][href="#edit"]').tab('show');
+
+    // remove this
+    props.onDeleteRender(this);
+  }, [props]);
+
+  return <></>;
+};
+
+EditPage.propTypes = {
+  onDeleteRender: PropTypes.func.isRequired,
+};
+
+EditPage.getHotkeyStrokes = () => {
+  return [['e']];
+};
+
+export default EditPage;

+ 26 - 0
src/client/js/components/Hotkeys/Subscribers/ShowShortcutsModal.jsx

@@ -0,0 +1,26 @@
+import React, { useEffect } from 'react';
+import PropTypes from 'prop-types';
+
+const ShowShortcutsModal = (props) => {
+
+  // setup effect
+  useEffect(() => {
+    // show modal to create a page
+    $('#shortcuts-modal').modal('toggle');
+
+    // remove this
+    props.onDeleteRender(this);
+  }, [props]);
+
+  return <></>;
+};
+
+ShowShortcutsModal.propTypes = {
+  onDeleteRender: PropTypes.func.isRequired,
+};
+
+ShowShortcutsModal.getHotkeyStrokes = () => {
+  return [['/+ctrl'], ['/+meta']];
+};
+
+export default ShowShortcutsModal;

+ 20 - 0
src/client/js/components/Hotkeys/Subscribers/ShowStaffCredit.jsx

@@ -0,0 +1,20 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import StaffCredit from '../../StaffCredit/StaffCredit';
+
+const ShowStaffCredit = (props) => {
+
+  return <StaffCredit onClosed={() => props.onDeleteRender(this)} />;
+
+};
+
+ShowStaffCredit.propTypes = {
+  onDeleteRender: PropTypes.func.isRequired,
+};
+
+ShowStaffCredit.getHotkeyStrokes = () => {
+  return [['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a']];
+};
+
+export default ShowStaffCredit;

+ 25 - 0
src/client/js/components/Hotkeys/Subscribers/SwitchToMirrorMode.jsx

@@ -0,0 +1,25 @@
+import React, { useEffect } from 'react';
+import PropTypes from 'prop-types';
+
+const SwitchToMirrorMode = (props) => {
+
+  // setup effect
+  useEffect(() => {
+    document.body.classList.add('mirror');
+
+    // remove this
+    props.onDeleteRender(this);
+  }, [props]);
+
+  return <></>;
+};
+
+SwitchToMirrorMode.propTypes = {
+  onDeleteRender: PropTypes.func.isRequired,
+};
+
+SwitchToMirrorMode.getHotkeyStrokes = () => {
+  return [['x', 'x', 'b', 'b', 'a', 'y', 'a', 'y', 'ArrowDown', 'ArrowLeft']];
+};
+
+export default SwitchToMirrorMode;

+ 13 - 13
src/client/js/components/InstallerForm.jsx

@@ -15,24 +15,24 @@ class InstallerForm extends React.Component {
       isValidUserName: true,
       selectedLang: {},
     };
-    this.checkUserName = this.checkUserName.bind(this);
+    // this.checkUserName = this.checkUserName.bind(this);
   }
 
   componentWillMount() {
     this.changeLanguage(localeMetadatas[0]);
   }
 
-  checkUserName(event) {
-    const axios = require('axios').create({
-      headers: {
-        'Content-Type': 'application/json',
-        'X-Requested-With': 'XMLHttpRequest',
-      },
-      responseType: 'json',
-    });
-    axios.get('/_api/check_username', { params: { username: event.target.value } })
-      .then((res) => { return this.setState({ isValidUserName: res.data.valid }) });
-  }
+  // checkUserName(event) {
+  //   const axios = require('axios').create({
+  //     headers: {
+  //       'Content-Type': 'application/json',
+  //       'X-Requested-With': 'XMLHttpRequest',
+  //     },
+  //     responseType: 'json',
+  //   });
+  //   axios.get('/_api/check_username', { params: { username: event.target.value } })
+  //     .then((res) => { return this.setState({ isValidUserName: res.data.valid }) });
+  // }
 
   changeLanguage(meta) {
     i18next.changeLanguage(meta.id);
@@ -94,7 +94,7 @@ class InstallerForm extends React.Component {
                 placeholder={this.props.t('User ID')}
                 name="registerForm[username]"
                 defaultValue={this.props.userName}
-                onBlur={this.checkUserName}
+                // onBlur={this.checkUserName} // need not to check username before installation -- 2020.07.24 Yuki Takei
                 required
               />
             </div>

+ 25 - 32
src/client/js/components/MyBookmarkList/MyBookmarkList.jsx

@@ -33,39 +33,35 @@ class MyBookmarkList extends React.Component {
     this.getMyBookmarkList(1);
   }
 
-  async handlePage(selectedPage) {
-    await this.getMyBookmarkList(selectedPage);
+  async handlePage(selectPageNumber) {
+    await this.getMyBookmarkList(selectPageNumber);
   }
 
   async getMyBookmarkList(selectPageNumber) {
-    const { appContainer, pageContainer } = this.props;
-    const { pageId } = pageContainer.state;
+    const { appContainer } = this.props;
 
     const userId = appContainer.currentUserId;
-    /* TODO GW-3255 get config from customize settings */
+    /* TODO #1 change variable name in models/config.js */
+    /* TODO #2 change variable name in database keys */
+    /* TODO #3 write migration */
     const limit = appContainer.getConfig().recentCreatedLimit;
-    const offset = (selectPageNumber - 1) * limit;
-
-
-    /* /pages.myBookmarks is not exitst. TODO GW-3251 Create api v3 /pages.myBookmarks */
-
-    // This block is cited from MyDraftList
-    /* await this.props.appContainer.apiGet('/pages.myBookmarks', {
-      page_id: pageId, user: userId, limit, offset,
-    })
-      .then((res) => {
-        const totalPages = res.totalCount;
-        const pages = res.pages;
-        const activePage = selectPageNumber;
-        this.setState({
-          pages,
-          activePage,
-          totalPages,
-          pagingLimit: limit,
-        });
-      }); */
+    const page = selectPageNumber;
+    const params = { page, limit };
+
     try {
-      await pageContainer.retrieveMyBookmarkList(pageId, userId, limit, offset);
+      const { data } = await this.props.appContainer.apiv3.get(`/bookmarks/${userId}`, params);
+      if (data.paginationResult == null) {
+        throw new Error('data must conclude \'paginateResult\' property.');
+      }
+      const {
+        docs: pages, totalDocs: totalPages, limit: pagingLimit, page: activePage,
+      } = data.paginationResult;
+      this.setState({
+        pages,
+        totalPages,
+        pagingLimit,
+        activePage,
+      });
     }
     catch (error) {
       logger.error('failed to fetch data', error);
@@ -80,22 +76,19 @@ class MyBookmarkList extends React.Component {
    *
    */
   generatePageList(pages) {
-    /* TODO GW-3251 */
     return pages.map(page => (
-      <li key={`my-bookmarks-list:list-view:${page._id}`}>
-        <Page page={page} />
+      <li key={`my-bookmarks:${page._id}`}>
+        <Page page={page.page} />
       </li>
     ));
   }
 
 
   render() {
-    const pageList = this.generatePageList(this.state.pages);
-
     return (
       <div className="page-list-container-create">
         <ul className="page-list-ul page-list-ul-flat mb-3">
-          {pageList}
+          {this.generatePageList(this.state.pages)}
         </ul>
         <PaginationWrapper
           activePage={this.state.activePage}

+ 2 - 2
src/client/js/components/Navbar/PersonalDropdown.jsx

@@ -118,10 +118,10 @@ const PersonalDropdown = (props) => {
           </div>
 
           <div className="btn-group btn-block mt-2" role="group">
-            <a className="btn btn-sm btn-outline-secondary" href={`/user/${user.username}`}>
+            <a className="btn btn-sm btn-outline-secondary col" href={`/user/${user.username}`}>
               <i className="icon-fw icon-home"></i>{ t('personal_dropdown.home') }
             </a>
-            <a className="btn btn-sm btn-outline-secondary" href="/me">
+            <a className="btn btn-sm btn-outline-secondary col" href="/me">
               <i className="icon-fw icon-wrench"></i>{ t('personal_dropdown.settings') }
             </a>
           </div>

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

@@ -59,7 +59,7 @@ class Page extends React.Component {
    */
   launchDrawioModal(beginLineNumber, endLineNumber) {
     const markdown = this.props.pageContainer.state.markdown;
-    const drawioMarkdownArray = markdown.split(/\r\n|\r|\n/).slice(beginLineNumber, endLineNumber);
+    const drawioMarkdownArray = markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber);
     const drawioData = drawioMarkdownArray.slice(1, drawioMarkdownArray.length - 1).join('\n').trim();
     this.setState({ currentTargetDrawioArea: { beginLineNumber, endLineNumber } });
     this.drawioModal.current.show(drawioData);

+ 8 - 3
src/client/js/components/Page/RevisionRenderer.jsx

@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
 import PageContainer from '../../services/PageContainer';
+import NavigationContainer from '../../services/NavigationContainer';
 import GrowiRenderer from '../../util/GrowiRenderer';
 
 import RevisionBody from './RevisionBody';
@@ -35,7 +36,7 @@ class RevisionRenderer extends React.PureComponent {
 
   componentDidUpdate(prevProps) {
     const { markdown: prevMarkdown, highlightKeywords: prevHighlightKeywords } = prevProps;
-    const { markdown, highlightKeywords } = this.props;
+    const { markdown, highlightKeywords, navigationContainer } = this.props;
 
     // render only when props.markdown is updated
     if (markdown !== prevMarkdown || highlightKeywords !== prevHighlightKeywords) {
@@ -44,6 +45,10 @@ class RevisionRenderer extends React.PureComponent {
       return;
     }
 
+    const HeaderLink = document.getElementsByClassName('revision-head-link');
+    const HeaderLinkArray = Array.from(HeaderLink);
+    navigationContainer.addSmoothScrollEvent(HeaderLinkArray);
+
     const { interceptorManager } = this.props.appContainer;
 
     interceptorManager.process('postRenderHtml', this.currentRenderingContext);
@@ -115,12 +120,12 @@ class RevisionRenderer extends React.PureComponent {
 /**
  * Wrapper component for using unstated
  */
-const RevisionRendererWrapper = withUnstatedContainers(RevisionRenderer, [AppContainer, PageContainer]);
+const RevisionRendererWrapper = withUnstatedContainers(RevisionRenderer, [AppContainer, PageContainer, NavigationContainer]);
 
 RevisionRenderer.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
   highlightKeywords: PropTypes.string,

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

@@ -51,7 +51,7 @@ class PageAttachment extends React.Component {
   checkIfFileInUse(attachment) {
     const { markdown } = this.props.pageContainer.state;
 
-    if (markdown.match(attachment.filePathProxied)) {
+    if (markdown.match(attachment._id)) {
       return true;
     }
     return false;

+ 3 - 3
src/client/js/components/PageEditor/MarkdownDrawioUtil.js

@@ -139,17 +139,17 @@ class MarkdownDrawioUtil {
    */
   replaceDrawioInMarkdown(drawioData, markdown, beginLineNumber, endLineNumber) {
     const splitMarkdown = markdown.split(/\r\n|\r|\n/);
-    const markdownBeforeDrawio = splitMarkdown.slice(0, beginLineNumber);
+    const markdownBeforeDrawio = splitMarkdown.slice(0, beginLineNumber - 1);
     const markdownAfterDrawio = splitMarkdown.slice(endLineNumber);
 
     let newMarkdown = '';
     if (markdownBeforeDrawio.length > 0) {
       newMarkdown += `${markdownBeforeDrawio.join('\n')}\n`;
-      newMarkdown += '::: drawio\n';
     }
+    newMarkdown += '::: drawio\n';
     newMarkdown += drawioData;
+    newMarkdown += '\n:::';
     if (markdownAfterDrawio.length > 0) {
-      newMarkdown += '\n:::';
       newMarkdown += `\n${markdownAfterDrawio.join('\n')}`;
     }
 

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

@@ -94,7 +94,7 @@ class PageStatusAlert extends React.Component {
 
     // when remote revision is newer than both
     if (isHackmdDocumentOutdated && isRevisionOutdated) {
-      getContentsFunc = this.getContentsFunc;
+      getContentsFunc = this.getContentsForUpdatedAlert;
     }
     // when someone editing with HackMD
     else if (isHackmdDraftUpdatingInRealtime) {

+ 1 - 1
src/client/js/components/Sidebar/RecentChanges.jsx

@@ -59,7 +59,7 @@ class RecentChanges extends React.Component {
     return (
       <li className="list-group-item p-2">
         <div className="d-flex w-100">
-          <UserPicture user={page.lastUpdateUser} size="md" />
+          <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
           <div className="flex-grow-1 ml-2">
             { !dPagePath.isRoot && <FormerLink /> }
             <h5 className="mb-1">

+ 42 - 57
src/client/js/components/StaffCredit/StaffCredit.jsx

@@ -1,16 +1,11 @@
 import React from 'react';
-import { GlobalHotKeys } from 'react-hotkeys';
-
+import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 import {
   Modal, ModalBody,
 } from 'reactstrap';
-
 import contributors from './Contributor';
 
-// px / sec
-const scrollSpeed = 200;
-
 /**
  * Page staff credit component
  *
@@ -19,56 +14,21 @@ const scrollSpeed = 200;
  * @extends {React.Component}
  */
 
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:cli:StaffCredit');
+
 export default class StaffCredit extends React.Component {
 
   constructor(props) {
-    super(props);
-
-    this.logger = loggerFactory('growi:StaffCredit');
 
+    super(props);
     this.state = {
-      isShown: false,
-      userCommand: [],
+      isShown: true,
     };
-    this.konamiCommand = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a'];
     this.deleteCredit = this.deleteCredit.bind(this);
   }
 
-  check(event) {
-    this.logger.debug(`'${event.key}' pressed`);
-
-    // compare keydown and next konamiCommand
-    if (this.konamiCommand[this.state.userCommand.length] === event.key) {
-      const nextValue = this.state.userCommand.concat(event.key);
-      if (nextValue.length === this.konamiCommand.length) {
-        this.setState({
-          isShown: true,
-          userCommand: [],
-        });
-        const target = $('.credit-curtain');
-        const scrollTargetHeight = target.children().innerHeight();
-        const duration = scrollTargetHeight / scrollSpeed * 1000;
-        target.animate({ scrollTop: scrollTargetHeight }, duration, 'linear');
-
-        target.slimScroll({
-          height: target.innerHeight(),
-          // Able to scroll after automatic schooling is complete so set "bottom" to allow scrolling from the bottom.
-          start: 'bottom',
-          color: '#FFFFFF',
-        });
-      }
-      else {
-        // add UserCommand
-        this.setState({ userCommand: nextValue });
-
-        this.logger.debug('userCommand', this.state.userCommand);
-      }
-    }
-    else {
-      this.setState({ userCommand: [] });
-    }
-  }
-
+  // to delete the staffCredit and to inform that to Hotkeys.jsx
   deleteCredit() {
     if (this.state.isShown) {
       this.setState({ isShown: false });
@@ -123,21 +83,46 @@ export default class StaffCredit extends React.Component {
     return null;
   }
 
+  componentDidMount() {
+    setTimeout(() => {
+      // px / sec
+      const scrollSpeed = 200;
+      const target = $('.credit-curtain');
+      const scrollTargetHeight = target.children().innerHeight();
+      const duration = scrollTargetHeight / scrollSpeed * 1000;
+      target.animate({ scrollTop: scrollTargetHeight }, duration, 'linear');
+      target.slimScroll({
+        height: target.innerHeight(),
+        // Able to scroll after automatic schooling is complete so set "bottom" to allow scrolling from the bottom.
+        start: 'bottom',
+        color: '#FFFFFF',
+      });
+    }, 10);
+  }
+
   render() {
-    const keyMap = { check: ['up', 'down', 'right', 'left', 'b', 'a'] };
-    const handlers = { check: (event) => { return this.check(event) } };
+    const { onClosed } = this.props;
+
     return (
-      <GlobalHotKeys keyMap={keyMap} handlers={handlers}>
-        <Modal isOpen={this.state.isShown} toggle={this.deleteCredit} scrollable className="staff-credit">
-          <ModalBody className="credit-curtain">
-            {this.renderContributors()}
-          </ModalBody>
-        </Modal>
-      </GlobalHotKeys>
+      <Modal
+        isOpen={this.state.isShown}
+        onClosed={() => {
+          if (onClosed != null) {
+            onClosed();
+          }
+        }}
+        toggle={this.deleteCredit}
+        scrollable
+        className="staff-credit"
+      >
+        <ModalBody className="credit-curtain">
+          {this.renderContributors()}
+        </ModalBody>
+      </Modal>
     );
   }
 
 }
-
 StaffCredit.propTypes = {
+  onClosed: PropTypes.func,
 };

+ 12 - 3
src/client/js/components/TableOfContents.jsx

@@ -1,10 +1,11 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useEffect } from 'react';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
 import { withTranslation } from 'react-i18next';
 
 import PageContainer from '../services/PageContainer';
+import NavigationContainer from '../services/NavigationContainer';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import StickyStretchableScroller from './StickyStretchableScroller';
@@ -18,7 +19,7 @@ const logger = loggerFactory('growi:TableOfContents');
  */
 const TableOfContents = (props) => {
 
-  const { pageContainer } = props;
+  const { pageContainer, navigationContainer } = props;
 
   const calcViewHeight = useCallback(() => {
     // calculate absolute top of '#revision-toc' element
@@ -31,6 +32,13 @@ const TableOfContents = (props) => {
 
   const { tocHtml } = pageContainer.state;
 
+  // execute after generation toc html
+  useEffect(() => {
+    const tocDom = document.getElementById('revision-toc-content');
+    const anchorsInToc = Array.from(tocDom.getElementsByTagName('a'));
+    navigationContainer.addSmoothScrollEvent(anchorsInToc);
+  }, [tocHtml, navigationContainer]);
+
   return (
     <>
       {/* TODO GW-3253 add four contents */}
@@ -56,10 +64,11 @@ const TableOfContents = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const TableOfContentsWrapper = withUnstatedContainers(TableOfContents, [PageContainer]);
+const TableOfContentsWrapper = withUnstatedContainers(TableOfContents, [PageContainer, NavigationContainer]);
 
 TableOfContents.propTypes = {
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 
 export default withTranslation()(TableOfContentsWrapper);

+ 0 - 1
src/client/js/hackmd-agent.js

@@ -143,7 +143,6 @@ function connectToParentWithPenpal() {
   console.log('[HackMD] Loading GROWI agent for HackMD...');
 
   window.addEventListener('load', (event) => {
-    console.log('loaded');
     addEventListenersToCodemirror();
   });
 

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

@@ -85,22 +85,6 @@ Crowi.modifyScrollTop = function() {
   }, timeout);
 };
 
-Crowi.handleKeyEHandler = (event) => {
-  // ignore when dom that has 'modal in' classes exists
-  if (document.getElementsByClassName('modal in').length > 0) {
-    return;
-  }
-  // show editor
-  $('a[data-toggle="tab"][href="#edit"]').tab('show');
-  event.preventDefault();
-};
-
-Crowi.handleKeyCtrlSlashHandler = (event) => {
-  // show modal to create a page
-  $('#shortcuts-modal').modal('toggle');
-  event.preventDefault();
-};
-
 Crowi.initClassesByOS = function() {
   // add classes to cmd-key by OS
   const platform = navigator.platform.toLowerCase();
@@ -410,30 +394,6 @@ window.addEventListener('hashchange', (e) => {
   }
 });
 
-window.addEventListener('keydown', (event) => {
-  const target = event.target;
-
-  // ignore when target dom is input
-  const inputPattern = /^input|textinput|textarea$/i;
-  if (inputPattern.test(target.tagName) || target.isContentEditable) {
-    return;
-  }
-
-  switch (event.key) {
-    case 'e':
-      if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
-        Crowi.handleKeyEHandler(event);
-      }
-      break;
-    case '/':
-      if (event.ctrlKey || event.metaKey) {
-        Crowi.handleKeyCtrlSlashHandler(event);
-      }
-      break;
-    default:
-  }
-});
-
 // adjust min-height of page for print temporarily
 window.onbeforeprint = function() {
   $('#page-wrapper').css('min-height', '0px');

+ 57 - 0
src/client/js/models/HotkeyStroke.js

@@ -0,0 +1,57 @@
+import loggerFactory from '@alias/logger';
+
+const logger = loggerFactory('growi:cli:HotkeyStroke');
+
+export default class HotkeyStroke {
+
+  constructor(stroke) {
+    this.stroke = stroke;
+    this.activeIndices = [];
+  }
+
+  get firstKey() {
+    return this.stroke[0];
+  }
+
+  /**
+   * Evaluate whether the specified key completes stroke or not
+   * @param {string} key
+   * @return T/F whether the specified key completes stroke or not
+   */
+  evaluate(key) {
+    if (key === this.firstKey) {
+      // add a new active index
+      this.activeIndices.push(0);
+    }
+
+    let isCompleted = false;
+    this.activeIndices = this.activeIndices
+      .map((index) => {
+        // return null when key does not match
+        if (key !== this.stroke[index]) {
+          return null;
+        }
+
+        const nextIndex = index + 1;
+
+        if (this.stroke.length <= nextIndex) {
+          isCompleted = true;
+          return null;
+        }
+
+        return nextIndex;
+      })
+      // exclude null
+      .filter(index => index != null);
+
+    // reset if completed
+    if (isCompleted) {
+      this.activeIndices = [];
+    }
+
+    logger.debug('activeIndices for [', this.stroke, '] => [', this.activeIndices, ']');
+
+    return isCompleted;
+  }
+
+}

+ 19 - 0
src/client/js/services/AdminSocketIoContainer.js

@@ -0,0 +1,19 @@
+import SocketIoContainer from './SocketIoContainer';
+
+/**
+ * A subclass of SocketIoContainer for /admin namespace
+ */
+export default class AdminSocketIoContainer extends SocketIoContainer {
+
+  constructor(appContainer) {
+    super(appContainer, '/admin');
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminSocketIoContainer';
+  }
+
+}

+ 16 - 20
src/client/js/services/NavigationContainer.js

@@ -6,6 +6,7 @@ import { Container } from 'unstated';
  */
 
 const SCROLL_THRES_SKIP = 200;
+const WIKI_HEADER_LINK = 120;
 
 export default class NavigationContainer extends Container {
 
@@ -36,8 +37,6 @@ export default class NavigationContainer extends Container {
 
     this.openPageCreateModal = this.openPageCreateModal.bind(this);
     this.closePageCreateModal = this.closePageCreateModal.bind(this);
-
-    this.initHotkeys();
     this.initDeviceSize();
     this.initScrollEvent();
   }
@@ -49,24 +48,6 @@ export default class NavigationContainer extends Container {
     return 'NavigationContainer';
   }
 
-  initHotkeys() {
-    window.addEventListener('keydown', (event) => {
-      const target = event.target;
-
-      // ignore when target dom is input
-      const inputPattern = /^input|textinput|textarea$/i;
-      if (inputPattern.test(target.tagName) || target.isContentEditable) {
-        return;
-      }
-
-      if (event.key === 'c') {
-        // don't fire when not needed
-        if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
-          this.setState({ isPageCreateModalShown: true });
-        }
-      }
-    });
-  }
 
   initDeviceSize() {
     const mdOrAvobeHandler = async(mql) => {
@@ -171,6 +152,21 @@ export default class NavigationContainer extends Container {
     this.setState({ isPageCreateModalShown: false });
   }
 
+  /**
+   * Function that implements the click event for realizing smooth scroll
+   * @param {array} elements
+   */
+  addSmoothScrollEvent(elements = {}) {
+    elements.forEach(link => link.addEventListener('click', (e) => {
+      e.preventDefault();
+
+      const href = link.getAttribute('href').replace('#', '');
+      window.location.hash = href;
+      const targetDom = document.getElementById(href);
+      this.smoothScrollIntoView(targetDom, WIKI_HEADER_LINK);
+    }));
+  }
+
   smoothScrollIntoView(element = null, offsetTop = 0) {
     const targetElement = element || window.document.body;
 

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

@@ -314,11 +314,11 @@ export default class PageContainer extends Container {
   }
 
   async createPage(pagePath, markdown, tmpParams) {
-    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
 
     // clone
     const params = Object.assign(tmpParams, {
-      socketClientId: websocketContainer.getSocketClientId(),
+      socketClientId: socketIoContainer.getSocketClientId(),
       path: pagePath,
       body: markdown,
     });
@@ -331,11 +331,11 @@ export default class PageContainer extends Container {
   }
 
   async updatePage(pageId, revisionId, markdown, tmpParams) {
-    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
 
     // clone
     const params = Object.assign(tmpParams, {
-      socketClientId: websocketContainer.getSocketClientId(),
+      socketClientId: socketIoContainer.getSocketClientId(),
       page_id: pageId,
       revision_id: revisionId,
       body: markdown,
@@ -349,7 +349,7 @@ export default class PageContainer extends Container {
   }
 
   deletePage(isRecursively, isCompletely) {
-    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
 
     // control flag
     const completely = isCompletely ? true : null;
@@ -360,13 +360,13 @@ export default class PageContainer extends Container {
       completely,
       page_id: this.state.pageId,
       revision_id: this.state.revisionId,
-      socketClientId: websocketContainer.getSocketClientId(),
+      socketClientId: socketIoContainer.getSocketClientId(),
     });
 
   }
 
   revertRemove(isRecursively) {
-    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
 
     // control flag
     const recursively = isRecursively ? true : null;
@@ -374,12 +374,12 @@ export default class PageContainer extends Container {
     return this.appContainer.apiPost('/pages.revertRemove', {
       recursively,
       page_id: this.state.pageId,
-      socketClientId: websocketContainer.getSocketClientId(),
+      socketClientId: socketIoContainer.getSocketClientId(),
     });
   }
 
   rename(pageNameInput, isRenameRecursively, isRenameRedirect, isRenameMetadata) {
-    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
     const isRecursively = isRenameRecursively ? true : null;
     const isRedirect = isRenameRedirect ? true : null;
     const isRemain = isRenameMetadata ? true : null;
@@ -391,7 +391,7 @@ export default class PageContainer extends Container {
       new_path: pageNameInput,
       create_redirect: isRedirect,
       remain_metadata: isRemain,
-      socketClientId: websocketContainer.getSocketClientId(),
+      socketClientId: socketIoContainer.getSocketClientId(),
     });
   }
 
@@ -420,12 +420,12 @@ export default class PageContainer extends Container {
 
   addWebSocketEventHandlers() {
     const pageContainer = this;
-    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
-    const socket = websocketContainer.getWebSocket();
+    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
+    const socket = socketIoContainer.getSocket();
 
     socket.on('page:create', (data) => {
       // skip if triggered myself
-      if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
+      if (data.socketClientId != null && data.socketClientId === socketIoContainer.getSocketClientId()) {
         return;
       }
 
@@ -439,7 +439,7 @@ export default class PageContainer extends Container {
 
     socket.on('page:update', (data) => {
       // skip if triggered myself
-      if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
+      if (data.socketClientId != null && data.socketClientId === socketIoContainer.getSocketClientId()) {
         return;
       }
 
@@ -460,7 +460,7 @@ export default class PageContainer extends Container {
 
     socket.on('page:delete', (data) => {
       // skip if triggered myself
-      if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
+      if (data.socketClientId != null && data.socketClientId === socketIoContainer.getSocketClientId()) {
         return;
       }
 
@@ -474,7 +474,7 @@ export default class PageContainer extends Container {
 
     socket.on('page:editingWithHackmd', (data) => {
       // skip if triggered myself
-      if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
+      if (data.socketClientId != null && data.socketClientId === socketIoContainer.getSocketClientId()) {
         return;
       }
 

+ 9 - 5
src/client/js/services/WebsocketContainer.js → src/client/js/services/SocketIoContainer.js

@@ -6,15 +6,19 @@ import io from 'socket.io-client';
  * Service container related to options for WebSocket
  * @extends {Container} unstated Container
  */
-export default class WebsocketContainer extends Container {
+export default class SocketIoContainer extends Container {
 
-  constructor(appContainer) {
+  constructor(appContainer, namespace) {
     super();
 
     this.appContainer = appContainer;
     this.appContainer.registerContainer(this);
 
-    this.socket = io();
+    const ns = namespace || '/';
+
+    this.socket = io(ns, {
+      transports: ['websocket'],
+    });
     this.socketClientId = Math.floor(Math.random() * 100000);
 
     this.state = {
@@ -26,10 +30,10 @@ export default class WebsocketContainer extends Container {
    * Workaround for the mangling in production build to break constructor.name
    */
   static getClassName() {
-    return 'WebsocketContainer';
+    return 'SocketIoContainer';
   }
 
-  getWebSocket() {
+  getSocket() {
     return this.socket;
   }
 

+ 0 - 16
src/client/js/util/interceptor/drawio-interceptor.js

@@ -18,22 +18,6 @@ export class DrawioInterceptor extends BasicInterceptor {
 
     this.previousPreviewContext = null;
     this.appContainer = appContainer;
-
-    // define callback function invoked by viewer.min.js of draw.io
-    // refs: https://github.com/jgraph/drawio/blob/v12.9.1/etc/build/build.xml#L219-L232
-    window.onDrawioViewerLoad = function() {
-      const DrawioViewer = window.GraphViewer;
-
-      if (DrawioViewer != null) {
-        // disable useResizeSensor and checkVisibleState
-        //   for preventing resize event by viewer.min.js
-        DrawioViewer.useResizeSensor = false;
-        DrawioViewer.prototype.checkVisibleState = false;
-
-        // initialize
-        DrawioViewer.processElements();
-      }
-    };
   }
 
   /**

+ 1 - 6
src/client/styles/scss/_layout_kibela.scss

@@ -5,11 +5,6 @@ body.kibela {
     padding-top: 10px !important;
   }
 
-  /* navbar for kibela */
-  #page-wrapper {
-    margin-top: $grw-navbar-height + $grw-navbar-border-width;
-  }
-
   /* Logo */
   .logo {
     .logo-mark {
@@ -51,7 +46,7 @@ body.kibela {
 
   .kibela-block {
     position: relative;
-    top: 10px;
+    top: 30px;
     right: 100px;
     bottom: 0px;
     left: 0px;

+ 8 - 0
src/client/styles/scss/_me.scss

@@ -1,2 +1,10 @@
 .user-settings-page {
+  .title {
+    padding: 0.5rem 15px;
+
+    line-height: 1em;
+
+    @include variable-font-size(28px);
+    line-height: 1.1em;
+  }
 }

+ 3 - 0
src/client/styles/scss/_mirror_mode.scss

@@ -0,0 +1,3 @@
+body.mirror {
+  transform: scale(-1, 1);
+}

+ 4 - 4
src/client/styles/scss/_search.scss

@@ -193,12 +193,12 @@
   }
 
   .search-result-content {
-    padding-bottom: 32px;
+    padding-bottom: 36px;
 
     .search-result-page {
-      padding-top: 48px;
+      padding-top: 64px;
       // adjust for anchor links by the height of fixed .search-page-input
-      margin-top: -48px;
+      margin-top: -64px;
 
       > h2 {
         margin-right: 10px;
@@ -221,7 +221,7 @@
 
 .search-page-input {
   position: sticky;
-  top: 65px;
+  top: 15px;
   // placed at front-most
   z-index: 15;
 

+ 9 - 11
src/client/styles/scss/_shortcuts.scss

@@ -1,15 +1,4 @@
 #shortcuts-modal {
-  @include media-breakpoint-only(xs) {
-    .modal-dialog {
-      width: 750px;
-    }
-  }
-  @include media-breakpoint-up(sm) {
-    table {
-      table-layout: fixed;
-    }
-  }
-
   h3 {
     margin-bottom: 1em;
   }
@@ -23,6 +12,15 @@
     }
   }
 
+  @include media-breakpoint-up(sm) {
+    table {
+      table-layout: fixed;
+      th {
+        width: 170px;
+      }
+    }
+  }
+
   // see http://coliss.com/articles/build-websites/operation/css/css-apple-keyboard-style-by-nrjmadan.html
   .key {
     /*Box Properties*/

+ 15 - 8
src/client/styles/scss/_staff_credit.scss

@@ -10,30 +10,37 @@
   margin: 10vh 10vw !important;
 
   // see https://css-tricks.com/old-timey-terminal-styling/
-  @mixin old-timey-terminal-styling() {
+  .credit-curtain {
+    padding-top: 80vh;
+
     text-shadow: 0 0 10px #c8c8c8;
     background-color: black;
     background-image: radial-gradient(rgba(50, 100, 100, 0.75), black 120%);
   }
+  &::after {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    content: '';
+    background: repeating-linear-gradient(0deg, rgba(black, 0.15), rgba(black, 0.15) 2px, transparent 2px, transparent 4px);
+  }
 
   h1,
   h2,
   h3,
   h4,
   h5,
-  h6 {
+  h6,
+  .dev-position,
+  .dev-name {
     font-family: 'Press Start 2P', $font-family-for-staff-credit;
     color: white;
   }
 
   $credit-length: -240em;
 
-  // see https://css-tricks.com/old-timey-terminal-styling/
-  .credit-curtain {
-    padding-top: 80vh;
-    @include old-timey-terminal-styling();
-  }
-
   h1 {
     font-size: 3em;
   }

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

@@ -42,6 +42,7 @@
 @import 'layout_kibela';
 @import 'login';
 @import 'me';
+@import 'mirror_mode';
 @import 'navbar';
 @import 'navbar_kibela';
 @import 'notification';

+ 2 - 0
src/server/crowi/express-init.js

@@ -20,6 +20,7 @@ module.exports = function(crowi, app) {
 
   const registerSafeRedirect = require('../middlewares/safe-redirect')();
   const injectCurrentuserToLocalvars = require('../middlewares/inject-currentuser-to-localvars')();
+  const autoReconnectToS2sMsgServer = require('../middlewares/auto-reconnect-to-s2s-msg-server')(crowi);
   const { listLocaleIds } = require('@commons/util/locale-utils');
 
   const avoidSessionRoutes = require('../routes/avoid-session-routes');
@@ -117,6 +118,7 @@ module.exports = function(crowi, app) {
 
   app.use(registerSafeRedirect);
   app.use(injectCurrentuserToLocalvars);
+  app.use(autoReconnectToS2sMsgServer);
 
   const middlewares = require('../util/middlewares')(crowi, app);
   app.use(middlewares.swigFilters(swig));

+ 102 - 41
src/server/crowi/index.js

@@ -1,5 +1,3 @@
-
-
 const debug = require('debug')('growi:crowi');
 const logger = require('@alias/logger')('growi:crowi');
 const pkg = require('@root/package.json');
@@ -10,14 +8,14 @@ const { getMongoUri, mongoOptions } = require('@commons/util/mongoose-utils');
 
 const path = require('path');
 
-const sep = path.sep;
-
 const mongoose = require('mongoose');
 
 const models = require('../models');
 
 const PluginService = require('../plugins/plugin.service');
 
+const sep = path.sep;
+
 function Crowi(rootdir) {
   const self = this;
 
@@ -35,9 +33,12 @@ function Crowi(rootdir) {
   this.tmpDir = path.join(this.rootDir, 'tmp') + sep;
   this.cacheDir = path.join(this.tmpDir, 'cache');
 
+  this.express = null;
+
   this.config = {};
   this.configManager = null;
-  this.mailer = {};
+  this.s2sMessagingService = null;
+  this.mailService = null;
   this.passportService = null;
   this.globalNotificationService = null;
   this.slackNotificationService = null;
@@ -50,6 +51,9 @@ function Crowi(rootdir) {
   this.exportService = null;
   this.importService = null;
   this.searchService = null;
+  this.socketIoService = null;
+  this.pageService = null;
+  this.syncPageStatusService = null;
   this.cdnResourcesService = new CdnResourcesService();
   this.interceptorManager = new InterceptorManager();
   this.xss = new Xss();
@@ -66,7 +70,6 @@ function Crowi(rootdir) {
   this.events = {
     user: new (require(`${self.eventsDir}user`))(this),
     page: new (require(`${self.eventsDir}page`))(this),
-    search: new (require(`${self.eventsDir}search`))(this),
     bookmark: new (require(`${self.eventsDir}bookmark`))(this),
     tag: new (require(`${self.eventsDir}tag`))(this),
     admin: new (require(`${self.eventsDir}admin`))(this),
@@ -79,6 +82,10 @@ Crowi.prototype.init = async function() {
   await this.setupSessionConfig();
   await this.setupConfigManager();
 
+  // setup messaging services
+  await this.setupS2sMessagingService();
+  await this.setupSocketIoService();
+
   // customizeService depends on AppService and XssService
   // passportService depends on appService
   // slack depends on setUpSlacklNotification
@@ -104,6 +111,8 @@ Crowi.prototype.init = async function() {
     this.setupUserGroup(),
     this.setupExport(),
     this.setupImport(),
+    this.setupPageService(),
+    this.setupSyncPageStatusService(),
   ]);
 
   // globalNotification depends on slack and mailer
@@ -252,16 +261,31 @@ Crowi.prototype.setupConfigManager = async function() {
   return this.configManager.loadConfigs();
 };
 
+Crowi.prototype.setupS2sMessagingService = async function() {
+  const s2sMessagingService = require('../service/s2s-messaging')(this);
+  if (s2sMessagingService != null) {
+    s2sMessagingService.subscribe();
+    this.configManager.setS2sMessagingService(s2sMessagingService);
+    // add as a message handler
+    s2sMessagingService.addMessageHandler(this.configManager);
+
+    this.s2sMessagingService = s2sMessagingService;
+  }
+};
+
+Crowi.prototype.setupSocketIoService = async function() {
+  const SocketIoService = require('../service/socket-io');
+  if (this.socketIoService == null) {
+    this.socketIoService = new SocketIoService();
+  }
+};
+
 Crowi.prototype.setupModels = async function() {
   Object.keys(models).forEach((key) => {
     return this.model(key, models[key](this));
   });
 };
 
-Crowi.prototype.getIo = function() {
-  return this.io;
-};
-
 Crowi.prototype.scanRuntimeVersions = async function() {
   const self = this;
 
@@ -277,10 +301,6 @@ Crowi.prototype.scanRuntimeVersions = async function() {
   });
 };
 
-Crowi.prototype.getMailer = function() {
-  return this.mailer;
-};
-
 Crowi.prototype.getSlack = function() {
   return this.slack;
 };
@@ -308,18 +328,24 @@ Crowi.prototype.setupPassport = async function() {
   this.passportService.setupSerializer();
   // setup strategies
   try {
-    this.passportService.setupLocalStrategy();
-    this.passportService.setupLdapStrategy();
-    this.passportService.setupGoogleStrategy();
-    this.passportService.setupGitHubStrategy();
-    this.passportService.setupTwitterStrategy();
-    this.passportService.setupOidcStrategy();
-    this.passportService.setupSamlStrategy();
-    this.passportService.setupBasicStrategy();
+    this.passportService.setupStrategyById('local');
+    this.passportService.setupStrategyById('ldap');
+    this.passportService.setupStrategyById('saml');
+    this.passportService.setupStrategyById('oidc');
+    this.passportService.setupStrategyById('basic');
+    this.passportService.setupStrategyById('google');
+    this.passportService.setupStrategyById('github');
+    this.passportService.setupStrategyById('twitter');
   }
   catch (err) {
     logger.error(err);
   }
+
+  // add as a message handler
+  if (this.s2sMessagingService != null) {
+    this.s2sMessagingService.addMessageHandler(this.passportService);
+  }
+
   return Promise.resolve();
 };
 
@@ -329,11 +355,13 @@ Crowi.prototype.setupSearcher = async function() {
 };
 
 Crowi.prototype.setupMailer = async function() {
-  const self = this;
-  return new Promise(((resolve, reject) => {
-    self.mailer = require('../util/mailer')(self);
-    resolve();
-  }));
+  const MailService = require('@server/service/mail');
+  this.mailService = new MailService(this);
+
+  // add as a message handler
+  if (this.s2sMessagingService != null) {
+    this.s2sMessagingService.addMessageHandler(this.mailService);
+  }
 };
 
 Crowi.prototype.setupSlack = async function() {
@@ -365,7 +393,9 @@ Crowi.prototype.start = async function() {
   }
 
   await this.init();
-  const express = await this.buildServer();
+  await this.buildServer();
+
+  const { express } = this;
 
   // setup plugins
   this.pluginService = new PluginService(this, express);
@@ -381,21 +411,17 @@ Crowi.prototype.start = async function() {
     }
   });
 
-  // setup WebSocket
-  const io = require('socket.io')(serverListening);
-  io.sockets.on('connection', (socket) => {
-  });
-  this.io = io;
+  this.socketIoService.attachServer(serverListening);
 
   // setup Express Routes
-  this.setupRoutesAtLast(express);
+  this.setupRoutesAtLast();
 
   return serverListening;
 };
 
-Crowi.prototype.buildServer = function() {
-  const express = require('express')();
+Crowi.prototype.buildServer = async function() {
   const env = this.node_env;
+  const express = require('express')();
 
   require('./express-init')(this, express);
 
@@ -414,15 +440,20 @@ Crowi.prototype.buildServer = function() {
     express.use(morgan('dev'));
   }
 
-  return Promise.resolve(express);
+  this.express = express;
 };
 
 /**
  * setup Express Routes
  * !! this must be at last because it includes '/*' route !!
  */
-Crowi.prototype.setupRoutesAtLast = function(app) {
-  require('../routes')(this, app);
+Crowi.prototype.setupRoutesAtLast = function() {
+  require('../routes')(this, this.express);
+};
+
+Crowi.prototype.setupAfterInstall = function() {
+  this.pluginService.autoDetectAndLoadPlugins();
+  this.setupRoutesAtLast();
 };
 
 /**
@@ -483,9 +514,14 @@ Crowi.prototype.setUpAcl = async function() {
 Crowi.prototype.setUpCustomize = async function() {
   const CustomizeService = require('../service/customize');
   if (this.customizeService == null) {
-    this.customizeService = new CustomizeService(this.configManager, this.appService, this.xssService);
+    this.customizeService = new CustomizeService(this);
     this.customizeService.initCustomCss();
     this.customizeService.initCustomTitle();
+
+    // add as a message handler
+    if (this.s2sMessagingService != null) {
+      this.s2sMessagingService.addMessageHandler(this.customizeService);
+    }
   }
 };
 
@@ -495,7 +531,13 @@ Crowi.prototype.setUpCustomize = async function() {
 Crowi.prototype.setUpApp = async function() {
   const AppService = require('../service/app');
   if (this.appService == null) {
-    this.appService = new AppService(this.configManager);
+    this.appService = new AppService(this);
+
+    // add as a message handler
+    const isInstalled = this.configManager.getConfig('crowi', 'app:installed');
+    if (this.s2sMessagingService != null && !isInstalled) {
+      this.s2sMessagingService.addMessageHandler(this.appService);
+    }
   }
 };
 
@@ -547,4 +589,23 @@ Crowi.prototype.setupImport = async function() {
   }
 };
 
+Crowi.prototype.setupPageService = async function() {
+  const PageEventService = require('../service/page');
+  if (this.pageService == null) {
+    this.pageService = new PageEventService(this);
+  }
+};
+
+Crowi.prototype.setupSyncPageStatusService = async function() {
+  const SyncPageStatusService = require('../service/system-events/sync-page-status');
+  if (this.syncPageStatusService == null) {
+    this.syncPageStatusService = new SyncPageStatusService(this, this.s2sMessagingService, this.socketIoService);
+
+    // add as a message handler
+    if (this.s2sMessagingService != null) {
+      this.s2sMessagingService.addMessageHandler(this.syncPageStatusService);
+    }
+  }
+};
+
 module.exports = Crowi;

+ 0 - 11
src/server/events/search.js

@@ -1,11 +0,0 @@
-const util = require('util');
-const events = require('events');
-
-function SearchEvent(crowi) {
-  this.crowi = crowi;
-
-  events.EventEmitter.call(this);
-}
-util.inherits(SearchEvent, events.EventEmitter);
-
-module.exports = SearchEvent;

+ 11 - 0
src/server/middlewares/auto-reconnect-to-s2s-msg-server.js

@@ -0,0 +1,11 @@
+module.exports = (crowi) => {
+  const { s2sMessagingService } = crowi;
+
+  return (req, res, next) => {
+    if (s2sMessagingService != null && s2sMessagingService.shouldResubscribe()) {
+      s2sMessagingService.subscribe();
+    }
+
+    return next();
+  };
+};

+ 2 - 0
src/server/models/bookmark.js

@@ -2,6 +2,7 @@
 
 const debug = require('debug')('growi:models:bookmark');
 const mongoose = require('mongoose');
+const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
@@ -18,6 +19,7 @@ module.exports = function(crowi) {
     createdAt: { type: Date, default: Date.now },
   });
   bookmarkSchema.index({ page: 1, user: 1 }, { unique: true });
+  bookmarkSchema.plugin(mongoosePaginate);
   bookmarkSchema.plugin(uniqueValidator);
 
   bookmarkSchema.statics.countByPageId = async function(pageId) {

+ 75 - 75
src/server/models/page.js

@@ -96,14 +96,6 @@ const extractToAncestorsPaths = (pagePath) => {
   return ancestorsPaths;
 };
 
-const addSlashOfEnd = (path) => {
-  let returnPath = path;
-  if (!path.match(/\/$/)) {
-    returnPath += '/';
-  }
-  return returnPath;
-};
-
 /**
  * populate page (Query or Document) to show revision
  * @param {any} page Query or Document
@@ -148,17 +140,52 @@ class PageQueryBuilder {
   }
 
   /**
-   * generate the query to find the page that is match with `path` and its descendants
+   * generate the query to find the pages '{path}/*' and '{path}' self.
+   * If top page, return without doing anything.
    */
   addConditionToListWithDescendants(path, option) {
-    // ignore other pages than descendants
-    // eslint-disable-next-line no-param-reassign
-    path = addSlashOfEnd(path);
+    // No request is set for the top page
+    if (isTopPage(path)) {
+      return this;
+    }
+
+    const pathNormalized = pathUtils.normalizePath(path);
+    const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
+
+    const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
+
+    this.query = this.query
+      .and({
+        $or: [
+          { path: pathNormalized },
+          { path: new RegExp(`^${startsPattern}`) },
+        ],
+      });
 
-    this.addConditionToListByStartWith(path, option);
     return this;
   }
 
+  /**
+   * generate the query to find the pages '{path}/*' (exclude '{path}' self).
+   * If top page, return without doing anything.
+   */
+  addConditionToListOnlyDescendants(path, option) {
+    // No request is set for the top page
+    if (isTopPage(path)) {
+      return this;
+    }
+
+    const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
+
+    const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
+
+    this.query = this.query
+      .and({ path: new RegExp(`^${startsPattern}`) });
+
+    return this;
+
+  }
+
   /**
    * generate the query to find pages that start with `path`
    *
@@ -173,36 +200,11 @@ class PageQueryBuilder {
     if (isTopPage(path)) {
       return this;
     }
-    const pathCondition = [];
 
-    /*
-     * 1. add condition for finding the page completely match with `path` w/o last slash
-     */
-    let pathSlashOmitted = path;
-    if (path.match(/\/$/)) {
-      pathSlashOmitted = path.substr(0, path.length - 1);
-      pathCondition.push({ path: pathSlashOmitted });
-    }
-    /*
-     * 2. add decendants
-     */
-    const pattern = escapeStringRegexp(path); // escape
-
-    let queryReg;
-    try {
-      queryReg = new RegExp(`^${pattern}`);
-    }
-    // if regular expression is invalid
-    catch (e) {
-      // force to escape
-      queryReg = new RegExp(`^${escapeStringRegexp(pattern)}`);
-    }
-    pathCondition.push({ path: queryReg });
+    const startsPattern = escapeStringRegexp(path);
 
     this.query = this.query
-      .and({
-        $or: pathCondition,
-      });
+      .and({ path: new RegExp(`^${startsPattern}`) });
 
     return this;
   }
@@ -700,6 +702,32 @@ module.exports = function(crowi) {
     return await findListFromBuilderAndViewer(builder, user, false, option);
   };
 
+  /**
+   * find pages that is match with `path` and its descendants whitch user is able to manage
+   */
+  pageSchema.statics.findManageableListWithDescendants = async function(page, user, option = {}) {
+    if (user == null) {
+      return null;
+    }
+
+    const builder = new PageQueryBuilder(this.find());
+    builder.addConditionToListWithDescendants(page.path, option);
+    builder.addConditionToExcludeRedirect();
+
+    // add grant conditions
+    await addConditionToFilteringByViewerToEdit(builder, user);
+
+    const { pages } = await findListFromBuilderAndViewer(builder, user, false, option);
+
+    // add page if 'grant' is GRANT_RESTRICTED
+    // because addConditionToListWithDescendants excludes GRANT_RESTRICTED pages
+    if (page.grant === GRANT_RESTRICTED) {
+      pages.push(page);
+    }
+
+    return pages;
+  };
+
   /**
    * find pages that start with `path`
    */
@@ -1094,14 +1122,8 @@ module.exports = function(crowi) {
       throw new Error('This method does NOT supports deleting trashed pages.');
     }
 
-    // find descendants (this array does not include GRANT_RESTRICTED)
-    const result = await this.findListWithDescendants(targetPage.path, user);
-    const pages = result.pages;
-    // add targetPage if 'grant' is GRANT_RESTRICTED
-    //  because findListWithDescendants excludes GRANT_RESTRICTED pages
-    if (targetPage.grant === GRANT_RESTRICTED) {
-      pages.push(targetPage);
-    }
+    // find manageable descendants (this array does not include GRANT_RESTRICTED)
+    const pages = await this.findManageableListWithDescendants(targetPage, user, options);
 
     await Promise.all(pages.map((page) => {
       return this.deletePage(page, user, options);
@@ -1133,8 +1155,7 @@ module.exports = function(crowi) {
 
   pageSchema.statics.revertDeletedPageRecursively = async function(targetPage, user, options = {}) {
     const findOpts = { includeTrashed: true };
-    const result = await this.findListWithDescendants(targetPage.path, user, findOpts);
-    const pages = result.pages;
+    const pages = await this.findManageableListWithDescendants(targetPage, user, findOpts);
 
     let updatedPage = null;
     await Promise.all(pages.map((page) => {
@@ -1183,18 +1204,10 @@ module.exports = function(crowi) {
    * Delete Bookmarks, Attachments, Revisions, Pages and emit delete
    */
   pageSchema.statics.completelyDeletePageRecursively = async function(targetPage, user, options = {}) {
-    const pagePath = targetPage.path;
-
     const findOpts = { includeTrashed: true };
 
-    // find descendants (this array does not include GRANT_RESTRICTED)
-    const result = await this.findListWithDescendants(pagePath, user, findOpts);
-    const pages = result.pages;
-    // add targetPage if 'grant' is GRANT_RESTRICTED
-    //  because findListWithDescendants excludes GRANT_RESTRICTED pages
-    if (targetPage.grant === GRANT_RESTRICTED) {
-      pages.push(targetPage);
-    }
+    // find manageable descendants (this array does not include GRANT_RESTRICTED)
+    const pages = await this.findManageableListWithDescendants(targetPage, user, findOpts);
 
     await Promise.all(pages.map((page) => {
       return this.completelyDeletePage(page, user, options);
@@ -1275,14 +1288,8 @@ module.exports = function(crowi) {
     // sanitize path
     newPagePathPrefix = crowi.xss.process(newPagePathPrefix); // eslint-disable-line no-param-reassign
 
-    // find descendants (this array does not include GRANT_RESTRICTED)
-    const result = await this.findListWithDescendants(path, user, options);
-    const pages = result.pages;
-    // add targetPage if 'grant' is GRANT_RESTRICTED
-    //  because findListWithDescendants excludes GRANT_RESTRICTED pages
-    if (targetPage.grant === GRANT_RESTRICTED) {
-      pages.push(targetPage);
-    }
+    // find manageable descendants
+    const pages = await this.findManageableListWithDescendants(targetPage, user, options);
 
     await Promise.all(pages.map((page) => {
       const newPagePath = page.path.replace(pathRegExp, newPagePathPrefix);
@@ -1386,13 +1393,6 @@ module.exports = function(crowi) {
 
   };
 
-  /**
-   * return path that added slash to the end for specified path
-   */
-  pageSchema.statics.addSlashOfEnd = function(path) {
-    return addSlashOfEnd(path);
-  };
-
   pageSchema.statics.GRANT_PUBLIC = GRANT_PUBLIC;
   pageSchema.statics.GRANT_RESTRICTED = GRANT_RESTRICTED;
   pageSchema.statics.GRANT_SPECIFIED = GRANT_SPECIFIED;

+ 3 - 3
src/server/models/user.js

@@ -604,8 +604,8 @@ module.exports = function(crowi) {
   };
 
   userSchema.statics.sendEmailbyUserList = async function(userList) {
-    const mailer = crowi.getMailer();
-    const appTitle = crowi.appService.getAppTitle();
+    const { appService, mailService } = crowi;
+    const appTitle = appService.getAppTitle();
 
     await Promise.all(userList.map(async(user) => {
       if (user.password == null) {
@@ -613,7 +613,7 @@ module.exports = function(crowi) {
       }
 
       try {
-        return mailer.send({
+        return mailService.send({
           to: user.email,
           subject: `Invitation to ${appTitle}`,
           template: path.join(crowi.localeDir, 'en_US/admin/userInvitation.txt'),

+ 29 - 0
src/server/models/vo/s2s-message.js

@@ -0,0 +1,29 @@
+/**
+ * Server-to-server message VO
+ */
+class S2sMessage {
+
+  constructor(eventName, body = {}) {
+    this.eventName = eventName;
+    for (const [key, value] of Object.entries(body)) {
+      this[key] = value;
+    }
+  }
+
+  setPublisherUid(uid) {
+    this.publisherUid = uid;
+  }
+
+  static parse(messageString) {
+    const body = JSON.parse(messageString);
+
+    if (body.eventName == null) {
+      throw new Error('message body must contain \'eventName\'');
+    }
+
+    return new S2sMessage(body.eventName, body);
+  }
+
+}
+
+module.exports = S2sMessage;

+ 0 - 13
src/server/routes/admin.js

@@ -19,8 +19,6 @@ module.exports = function(crowi, app) {
   const ApiResponse = require('../util/apiResponse');
   const importer = require('../util/importer')(crowi);
 
-  const searchEvent = crowi.event('search');
-
   const MAX_PAGE_LIST = 50;
   const actions = {};
 
@@ -84,17 +82,6 @@ module.exports = function(crowi, app) {
     return pager;
   }
 
-  // setup websocket event for rebuild index
-  searchEvent.on('addPageProgress', (total, current, skip) => {
-    crowi.getIo().sockets.emit('admin:addPageProgress', { total, current, skip });
-  });
-  searchEvent.on('finishAddPage', (total, current, skip) => {
-    crowi.getIo().sockets.emit('admin:finishAddPage', { total, current, skip });
-  });
-  searchEvent.on('rebuildingFailed', (error) => {
-    crowi.getIo().sockets.emit('admin:rebuildingFailed', { error: error.message });
-  });
-
   actions.index = function(req, res) {
     return res.render('admin/index');
   };

+ 23 - 10
src/server/routes/apiv3/app-settings.js

@@ -292,7 +292,7 @@ module.exports = (crowi) => {
    * validate mail setting send test mail
    */
   async function validateMailSetting(req) {
-    const mailer = crowi.mailer;
+    const { mailService } = crowi;
     const option = {
       host: req.body.smtpHost,
       port: req.body.smtpPort,
@@ -307,7 +307,7 @@ module.exports = (crowi) => {
       option.secure = true;
     }
 
-    const smtpClient = mailer.createSMTPClient(option);
+    const smtpClient = mailService.createSMTPClient(option);
     debug('mailer setup for validate SMTP setting', smtpClient);
 
     const mailOptions = {
@@ -344,7 +344,6 @@ module.exports = (crowi) => {
    *                  $ref: '#/components/schemas/MailSettingParams'
    */
   router.put('/mail-setting', loginRequiredStrictly, adminRequired, csrf, validator.mailSetting, apiV3FormValidator, async(req, res) => {
-    // テストメール送信によるバリデート
     try {
       await validateMailSetting(req);
     }
@@ -365,13 +364,20 @@ module.exports = (crowi) => {
     };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestMailSettingParams);
+      const { configManager, mailService } = crowi;
+
+      // update config without publishing S2sMessage
+      await configManager.updateConfigsInTheSameNamespace('crowi', requestMailSettingParams, true);
+
+      await mailService.initialize();
+      mailService.publishUpdatedMessage();
+
       const mailSettingParams = {
-        fromAddress: crowi.configManager.getConfig('crowi', 'mail:from'),
-        smtpHost: crowi.configManager.getConfig('crowi', 'mail:smtpHost'),
-        smtpPort: crowi.configManager.getConfig('crowi', 'mail:smtpPort'),
-        smtpUser: crowi.configManager.getConfig('crowi', 'mail:smtpUser'),
-        smtpPassword: crowi.configManager.getConfig('crowi', 'mail:smtpPassword'),
+        fromAddress: configManager.getConfig('crowi', 'mail:from'),
+        smtpHost: configManager.getConfig('crowi', 'mail:smtpHost'),
+        smtpPort: configManager.getConfig('crowi', 'mail:smtpPort'),
+        smtpUser: configManager.getConfig('crowi', 'mail:smtpUser'),
+        smtpPassword: configManager.getConfig('crowi', 'mail:smtpPassword'),
       };
       return res.apiv3({ mailSettingParams });
     }
@@ -415,7 +421,14 @@ module.exports = (crowi) => {
     };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestAwsSettingParams);
+      const { configManager, mailService } = crowi;
+
+      // update config without publishing S2sMessage
+      await configManager.updateConfigsInTheSameNamespace('crowi', requestAwsSettingParams, true);
+
+      await mailService.initialize();
+      mailService.publishUpdatedMessage();
+
       const awsSettingParams = {
         region: crowi.configManager.getConfig('crowi', 'aws:region'),
         customEndpoint: crowi.configManager.getConfig('crowi', 'aws:customEndpoint'),

+ 67 - 8
src/server/routes/apiv3/bookmarks.js

@@ -3,7 +3,7 @@ const loggerFactory = require('@alias/logger');
 const logger = loggerFactory('growi:routes:apiv3:bookmarks'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
-const { body } = require('express-validator');
+const { body, query } = require('express-validator');
 
 const router = express.Router();
 
@@ -104,14 +104,73 @@ module.exports = (crowi) => {
   });
 
   // select page from bookmark where userid = userid
-
-  router.get('/userId', /* accessTokenParser, loginRequired, csrf, */ apiV3FormValidator, async(req, res) => {
-    const { userId } = req.query;
+  /**
+   * @swagger
+   *
+   *    /bookmarks/{userId}:
+   *      get:
+   *        tags: [Bookmarks]
+   *        summary: /bookmarks/{userId}
+   *        description: Get my bookmarked status
+   *        operationId: getMyBookmarkedStatus
+   *        parameters:
+   *          - name: userId
+   *            in: path
+   *            required: true
+   *            description: user id
+   *            schema:
+   *              type: string
+   *          - name: page
+   *            in: query
+   *            description: selected page number
+   *            schema:
+   *              type: number
+   *          - name: limit
+   *            in: query
+   *            description: page item limit
+   *            schema:
+   *              type: number
+   *          - name: offset
+   *            in: query
+   *            description: page item offset
+   *            schema:
+   *              type: number
+   *        responses:
+   *          200:
+   *            description: Succeeded to get my bookmarked status.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/Bookmark'
+   */
+  validator.myBookmarkList = [
+    query('page').isInt({ min: 1 }),
+  ];
+
+  router.get('/:userId', accessTokenParser, loginRequired, validator.myBookmarkList, apiV3FormValidator, async(req, res) => {
+    const { userId } = req.params;
+    const { page, limit } = req.query;
+    if (userId == null) {
+      return res.apiv3Err('User id is not found or forbidden', 400);
+    }
+    if (limit == null) {
+      return res.apiv3Err('Could not catch page limit', 400);
+    }
     try {
-      const bookmark = await Bookmark
-        .find({ user: userId })
-        .populate({ path: 'page', model: 'Page' });
-      return res.apiv3({ bookmark });
+      const paginationResult = await Bookmark.paginate(
+        {
+          user: { $in: userId },
+        },
+        {
+          populate: {
+            path: 'page',
+            model: 'Page',
+          },
+          page,
+          limit,
+        },
+      );
+      return res.apiv3({ paginationResult });
     }
     catch (err) {
       logger.error('get-bookmark-failed', err);

+ 6 - 2
src/server/routes/apiv3/customize-setting.js

@@ -375,7 +375,9 @@ module.exports = (crowi) => {
     };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams, true);
+      crowi.customizeService.publishUpdatedMessage();
+
       const customizedParams = {
         customizeTitle: await crowi.configManager.getConfig('crowi', 'customize:title'),
       };
@@ -458,7 +460,9 @@ module.exports = (crowi) => {
       'customize:css': req.body.customizeCss,
     };
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams, true);
+      crowi.customizeService.publishUpdatedMessage();
+
       const customizedParams = {
         customizeCss: await crowi.configManager.getConfig('crowi', 'customize:css'),
       };

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

@@ -43,19 +43,19 @@ module.exports = (crowi) => {
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
 
-  const { exportService } = crowi;
+  const { exportService, socketIoService } = crowi;
 
   this.adminEvent = crowi.event('admin');
 
   // setup event
   this.adminEvent.on('onProgressForExport', (data) => {
-    crowi.getIo().sockets.emit('admin:onProgressForExport', data);
+    socketIoService.getAdminSocket().emit('admin:onProgressForExport', data);
   });
   this.adminEvent.on('onStartZippingForExport', (data) => {
-    crowi.getIo().sockets.emit('admin:onStartZippingForExport', data);
+    socketIoService.getAdminSocket().emit('admin:onStartZippingForExport', data);
   });
   this.adminEvent.on('onTerminateForExport', (data) => {
-    crowi.getIo().sockets.emit('admin:onTerminateForExport', data);
+    socketIoService.getAdminSocket().emit('admin:onTerminateForExport', data);
   });
 
 

+ 4 - 4
src/server/routes/apiv3/import.js

@@ -60,7 +60,7 @@ const generateOverwriteParams = (collectionName, req, options) => {
 };
 
 module.exports = (crowi) => {
-  const { growiBridgeService, importService } = crowi;
+  const { growiBridgeService, importService, socketIoService } = crowi;
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
@@ -70,13 +70,13 @@ module.exports = (crowi) => {
 
   // setup event
   this.adminEvent.on('onProgressForImport', (data) => {
-    crowi.getIo().sockets.emit('admin:onProgressForImport', data);
+    socketIoService.getAdminSocket().emit('admin:onProgressForImport', data);
   });
   this.adminEvent.on('onTerminateForImport', (data) => {
-    crowi.getIo().sockets.emit('admin:onTerminateForImport', data);
+    socketIoService.getAdminSocket().emit('admin:onTerminateForImport', data);
   });
   this.adminEvent.on('onErrorForImport', (data) => {
-    crowi.getIo().sockets.emit('admin:onErrorForImport', data);
+    socketIoService.getAdminSocket().emit('admin:onErrorForImport', data);
   });
 
   const uploads = multer({

+ 27 - 19
src/server/routes/apiv3/security-setting.js

@@ -324,6 +324,16 @@ module.exports = (crowi) => {
   const csrf = require('../../middlewares/csrf')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
+  async function updateAndReloadStrategySettings(authId, params) {
+    const { configManager, passportService } = crowi;
+
+    // update config without publishing S2sMessage
+    await configManager.updateConfigsInTheSameNamespace('crowi', params, true);
+
+    await passportService.setupStrategyById(authId);
+    passportService.publishUpdatedMessage(authId);
+  }
+
   /**
    * @swagger
    *
@@ -489,9 +499,7 @@ module.exports = (crowi) => {
     const enableParams = { [`security:passport-${authId}:isEnabled`]: isEnabled };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', enableParams);
-
-      await crowi.passportService.setupStrategyById(authId);
+      await updateAndReloadStrategySettings(authId, enableParams);
 
       const responseParams = {
         [`security:passport-${authId}:isEnabled`]: await crowi.configManager.getConfig('crowi', `security:passport-${authId}:isEnabled`),
@@ -613,8 +621,8 @@ module.exports = (crowi) => {
       'security:registrationWhiteList': req.body.registrationWhiteList,
     };
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
-      await crowi.passportService.setupStrategyById('local');
+      await updateAndReloadStrategySettings('local', requestParams);
+
       const localSettingParams = {
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
         registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
@@ -666,8 +674,8 @@ module.exports = (crowi) => {
     };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
-      await crowi.passportService.setupStrategyById('ldap');
+      await updateAndReloadStrategySettings('ldap', requestParams);
+
       const securitySettingParams = {
         serverUrl: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:serverUrl'),
         isUserBind: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:isUserBind'),
@@ -757,8 +765,8 @@ module.exports = (crowi) => {
     };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
-      await crowi.passportService.setupStrategyById('saml');
+      await updateAndReloadStrategySettings('saml', requestParams);
+
       const securitySettingParams = {
         missingMandatoryConfigKeys: await crowi.passportService.getSamlMissingMandatoryConfigKeys(),
         samlEntryPoint: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:entryPoint'),
@@ -826,8 +834,8 @@ module.exports = (crowi) => {
     };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
-      await crowi.passportService.setupStrategyById('oidc');
+      await updateAndReloadStrategySettings('oidc', requestParams);
+
       const securitySettingParams = {
         oidcProviderName: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:providerName'),
         oidcIssuerHost: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:issuerHost'),
@@ -884,8 +892,8 @@ module.exports = (crowi) => {
     };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
-      await crowi.passportService.setupStrategyById('basic');
+      await updateAndReloadStrategySettings('basic', requestParams);
+
       const securitySettingParams = {
         isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-basic:isSameUsernameTreatedAsIdenticalUser'),
       };
@@ -927,8 +935,8 @@ module.exports = (crowi) => {
     };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
-      await crowi.passportService.setupStrategyById('google');
+      await updateAndReloadStrategySettings('google', requestParams);
+
       const securitySettingParams = {
         googleClientId: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientId'),
         googleClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientSecret'),
@@ -972,8 +980,8 @@ module.exports = (crowi) => {
     };
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
-      await crowi.passportService.setupStrategyById('github');
+      await updateAndReloadStrategySettings('github', requestParams);
+
       const securitySettingParams = {
         githubClientId: await crowi.configManager.getConfig('crowi', 'security:passport-github:clientId'),
         githubClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-github:clientSecret'),
@@ -1022,8 +1030,8 @@ module.exports = (crowi) => {
     requestParams = removeNullPropertyFromObject(requestParams);
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
-      await crowi.passportService.setupStrategyById('twitter');
+      await updateAndReloadStrategySettings('twitter', requestParams);
+
       const securitySettingParams = {
         twitterConsumerId: await crowi.configManager.getConfig('crowi', 'security:passport-twitter:consumerKey'),
         twitterConsumerSecret: await crowi.configManager.getConfig('crowi', 'security:passport-twitter:consumerSecret'),

+ 0 - 5
src/server/routes/hackmd.js

@@ -40,11 +40,6 @@ module.exports = function(crowi, app) {
   let agentScriptContentTpl;
   let stylesScriptContentTpl;
 
-  // init 'saveOnHackmd' event
-  pageEvent.on('saveOnHackmd', (page) => {
-    crowi.getIo().sockets.emit('page:editingWithHackmd', { page });
-  });
-
   /**
    * GET /_hackmd/load-agent
    *

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

@@ -20,7 +20,6 @@ module.exports = function(crowi, app) {
   const logout = require('./logout')(crowi, app);
   const me = require('./me')(crowi, app);
   const admin = require('./admin')(crowi, app);
-  const installer = require('./installer')(crowi, app);
   const user = require('./user')(crowi, app);
   const attachment = require('./attachment')(crowi, app);
   const comment = require('./comment')(crowi, app);
@@ -41,6 +40,7 @@ module.exports = function(crowi, app) {
 
   // installer
   if (!isInstalled) {
+    const installer = require('./installer')(crowi);
     app.get('/installer'               , applicationNotInstalled , installer.index);
     app.post('/installer'              , applicationNotInstalled , form.register , csrf, installer.install);
     return;

+ 4 - 5
src/server/routes/installer.js

@@ -1,4 +1,4 @@
-module.exports = function(crowi, app) {
+module.exports = function(crowi) {
   const logger = require('@alias/logger')('growi:routes:installer');
   const path = require('path');
   const fs = require('graceful-fs');
@@ -84,10 +84,9 @@ module.exports = function(crowi, app) {
     }
     // create initial pages
     await createInitialPages(adminUser, language);
-    // init plugins
-    crowi.pluginService.autoDetectAndLoadPlugins();
-    // setup routes
-    crowi.setupRoutesAtLast(app);
+
+    crowi.setupAfterInstall();
+    appService.publishPostInstallationMessage();
 
     // login with passport
     req.logIn(adminUser, (err) => {

+ 2 - 3
src/server/routes/login.js

@@ -7,9 +7,8 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:login');
   const logger = require('@alias/logger')('growi:routes:login');
   const path = require('path');
-  const mailer = crowi.getMailer();
   const User = crowi.model('User');
-  const { configManager, appService, aclService } = crowi;
+  const { configManager, appService, aclService, mailService } = crowi;
 
   const actions = {};
 
@@ -158,7 +157,7 @@ module.exports = function(crowi, app) {
     const appTitle = appService.getAppTitle();
 
     const promises = admins.map((admin) => {
-      return mailer.send({
+      return mailService.send({
         to: admin.email,
         subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
         template: path.join(crowi.localeDir, 'en_US/admin/userWaitingActivation.txt'),

+ 3 - 35
src/server/routes/page.js

@@ -149,42 +149,10 @@ module.exports = function(crowi, app) {
   const { slackNotificationService, configManager } = crowi;
   const interceptorManager = crowi.getInterceptorManager();
   const globalNotificationService = crowi.getGlobalNotificationService();
+  const pageService = crowi.pageService;
 
   const actions = {};
 
-  // register page events
-
-  const pageEvent = crowi.event('page');
-  pageEvent.on('create', (page, user, socketClientId) => {
-    page = serializeToObj(page); // eslint-disable-line no-param-reassign
-    crowi.getIo().sockets.emit('page:create', { page, user, socketClientId });
-  });
-  pageEvent.on('update', (page, user, socketClientId) => {
-    page = serializeToObj(page); // eslint-disable-line no-param-reassign
-    crowi.getIo().sockets.emit('page:update', { page, user, socketClientId });
-  });
-  pageEvent.on('delete', (page, user, socketClientId) => {
-    page = serializeToObj(page); // eslint-disable-line no-param-reassign
-    crowi.getIo().sockets.emit('page:delete', { page, user, socketClientId });
-  });
-
-
-  function serializeToObj(page) {
-    const returnObj = page.toObject();
-    if (page.revisionHackmdSynced != null && page.revisionHackmdSynced._id != null) {
-      returnObj.revisionHackmdSynced = page.revisionHackmdSynced._id;
-    }
-
-    if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
-      returnObj.lastUpdateUser = page.lastUpdateUser.toObject();
-    }
-    if (page.creator != null && page.creator instanceof User) {
-      returnObj.creator = page.creator.toObject();
-    }
-
-    return returnObj;
-  }
-
   function getPathFromRequest(req) {
     return pathUtils.normalizePath(req.params[0] || '');
   }
@@ -742,7 +710,7 @@ module.exports = function(crowi, app) {
       savedTags = await PageTagRelation.listTagNamesByPage(createdPage.id);
     }
 
-    const result = { page: serializeToObj(createdPage), tags: savedTags };
+    const result = { page: pageService.serializeToObj(createdPage), tags: savedTags };
     res.json(ApiResponse.success(result));
 
     // update scopes for descendants
@@ -871,7 +839,7 @@ module.exports = function(crowi, app) {
       savedTags = await PageTagRelation.listTagNamesByPage(pageId);
     }
 
-    const result = { page: serializeToObj(page), tags: savedTags };
+    const result = { page: pageService.serializeToObj(page), tags: savedTags };
     res.json(ApiResponse.success(result));
 
     // update scopes for descendants

+ 65 - 4
src/server/service/app.js

@@ -1,13 +1,74 @@
 const logger = require('@alias/logger')('growi:service:AppService'); // eslint-disable-line no-unused-vars
 const { pathUtils } = require('growi-commons');
 
+
+const S2sMessage = require('../models/vo/s2s-message');
+const S2sMessageHandlable = require('./s2s-messaging/handlable');
+
 /**
  * the service class of AppService
  */
-class AppService {
+class AppService extends S2sMessageHandlable {
+
+  constructor(crowi) {
+    super();
+
+    this.crowi = crowi;
+    this.configManager = crowi.configManager;
+    this.s2sMessagingService = crowi.s2sMessagingService;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleS2sMessage(s2sMessage) {
+    const { eventName } = s2sMessage;
+    if (eventName !== 'systemInstalled') {
+      return false;
+    }
+
+    const isInstalled = this.crowi.configManager.getConfig('crowi', 'app:installed');
+
+    return !isInstalled;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleS2sMessage(s2sMessage) {
+    logger.info('Invoke post installation process by pubsub notification');
+
+    const { crowi, configManager, s2sMessagingService } = this;
+
+    // load config and setup
+    await configManager.loadConfigs();
+
+    const isInstalled = this.crowi.configManager.getConfig('crowi', 'app:installed');
+    if (isInstalled) {
+      crowi.setupAfterInstall();
+
+      // remove message handler
+      s2sMessagingService.removeMessageHandler(this);
+    }
+  }
+
+  async publishPostInstallationMessage() {
+    const { s2sMessagingService } = this;
+
+    if (s2sMessagingService != null) {
+      const s2sMessage = new S2sMessage('systemInstalled');
+
+      try {
+        await s2sMessagingService.publish(s2sMessage);
+      }
+      catch (e) {
+        logger.error('Failed to publish post installation message with S2sMessagingService: ', e.message);
+      }
+
+      // remove message handler
+      s2sMessagingService.removeMessageHandler(this);
+    }
 
-  constructor(configManager) {
-    this.configManager = configManager;
   }
 
   getAppTitle() {
@@ -49,7 +110,7 @@ class AppService {
   async initDB(globalLang) {
     const initialConfig = this.configManager.configModel.getConfigsObjectForInstalling();
     initialConfig['app:globalLang'] = globalLang;
-    await this.configManager.updateConfigsInTheSameNamespace('crowi', initialConfig);
+    await this.configManager.updateConfigsInTheSameNamespace('crowi', initialConfig, true);
   }
 
   async isDBInitialized() {

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

@@ -107,6 +107,18 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
   //   type:    ,
   //   default:
   // },
+  DRAWIO_URI: {
+    ns:      'crowi',
+    key:     'app:drawioUri',
+    type:    TYPES.STRING,
+    default: null,
+  },
+  NCHAN_URI: {
+    ns:      'crowi',
+    key:     'app:nchanUri',
+    type:    TYPES.STRING,
+    default: null,
+  },
   APP_SITE_URL: {
     ns:      'crowi',
     key:     'app:siteUrl',
@@ -119,6 +131,30 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.BOOLEAN,
     default: false,
   },
+  S2SMSG_PUBSUB_SERVER_TYPE: {
+    ns:      'crowi',
+    key:     's2sMessagingPubsub:serverType',
+    type:    TYPES.STRING,
+    default: null,
+  },
+  S2SMSG_PUBSUB_NCHAN_PUBLISH_PATH: {
+    ns:      'crowi',
+    key:     's2sMessagingPubsub:nchan:publishPath',
+    type:    TYPES.STRING,
+    default: '/pubsub',
+  },
+  S2SMSG_PUBSUB_NCHAN_SUBSCRIBE_PATH: {
+    ns:      'crowi',
+    key:     's2sMessagingPubsub:nchan:subscribePath',
+    type:    TYPES.STRING,
+    default: '/pubsub',
+  },
+  S2SMSG_PUBSUB_NCHAN_CHANNEL_ID: {
+    ns:      'crowi',
+    key:     's2sMessagingPubsub:nchan:channelId',
+    type:    TYPES.STRING,
+    default: null,
+  },
   MAX_FILE_SIZE: {
     ns:      'crowi',
     key:     'app:maxFileSize',

+ 58 - 4
src/server/service/config-manager.js

@@ -1,5 +1,11 @@
 const logger = require('@alias/logger')('growi:service:ConfigManager');
-const ConfigLoader = require('../service/config-loader');
+
+const parseISO = require('date-fns/parseISO');
+
+const S2sMessage = require('../models/vo/s2s-message');
+const S2sMessageHandlable = require('./s2s-messaging/handlable');
+
+const ConfigLoader = require('./config-loader');
 
 const KEYS_FOR_LOCAL_STRATEGY_USE_ONLY_ENV_OPTION = [
   'security:passport-local:isEnabled',
@@ -18,13 +24,16 @@ const KEYS_FOR_SAML_USE_ONLY_ENV_OPTION = [
   'security:passport-saml:ABLCRule',
 ];
 
-class ConfigManager {
+class ConfigManager extends S2sMessageHandlable {
 
   constructor(configModel) {
+    super();
+
     this.configModel = configModel;
     this.configLoader = new ConfigLoader(this.configModel);
     this.configObject = null;
     this.configKeys = [];
+    this.lastLoadedAt = null;
 
     this.getConfig = this.getConfig.bind(this);
   }
@@ -38,6 +47,16 @@ class ConfigManager {
 
     // cache all config keys
     this.reloadConfigKeys();
+
+    this.lastLoadedAt = new Date();
+  }
+
+  /**
+   * Set S2sMessagingServiceDelegator instance
+   * @param {S2sMessagingServiceDelegator} s2sMessagingService
+   */
+  async setS2sMessagingService(s2sMessagingService) {
+    this.s2sMessagingService = s2sMessagingService;
   }
 
   /**
@@ -163,7 +182,7 @@ class ConfigManager {
    *  );
    * ```
    */
-  async updateConfigsInTheSameNamespace(namespace, configs) {
+  async updateConfigsInTheSameNamespace(namespace, configs, withoutPublishingS2sMessage) {
     const queries = [];
     for (const key of Object.keys(configs)) {
       queries.push({
@@ -177,7 +196,11 @@ class ConfigManager {
     await this.configModel.bulkWrite(queries);
 
     await this.loadConfigs();
-    this.reloadConfigKeys();
+
+    // publish updated date after reloading
+    if (this.s2sMessagingService != null && !withoutPublishingS2sMessage) {
+      this.publishUpdateMessage();
+    }
   }
 
   /**
@@ -287,6 +310,37 @@ class ConfigManager {
     return JSON.stringify(value === '' ? null : value);
   }
 
+  async publishUpdateMessage() {
+    const s2sMessage = new S2sMessage('configUpdated', { updatedAt: new Date() });
+
+    try {
+      await this.s2sMessagingService.publish(s2sMessage);
+    }
+    catch (e) {
+      logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+    }
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleS2sMessage(s2sMessage) {
+    const { eventName, updatedAt } = s2sMessage;
+    if (eventName !== 'configUpdated' || updatedAt == null) {
+      return false;
+    }
+
+    return this.lastLoadedAt == null || this.lastLoadedAt < parseISO(s2sMessage.updatedAt);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleS2sMessage(s2sMessage) {
+    logger.info('Reload configs by pubsub notification');
+    return this.loadConfigs();
+  }
+
 }
 
 module.exports = ConfigManager;

+ 57 - 5
src/server/service/customize.js

@@ -3,15 +3,63 @@ const logger = require('@alias/logger')('growi:service:CustomizeService');
 
 const DevidedPagePath = require('@commons/models/devided-page-path');
 
+const S2sMessage = require('../models/vo/s2s-message');
+const S2sMessageHandlable = require('./s2s-messaging/handlable');
+
+
 /**
  * the service class of CustomizeService
  */
-class CustomizeService {
+class CustomizeService extends S2sMessageHandlable {
+
+  constructor(crowi) {
+    super();
+
+    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 !== 'customizeServiceUpdated' || updatedAt == null) {
+      return false;
+    }
+
+    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleS2sMessage(s2sMessage) {
+    const { configManager } = this;
 
-  constructor(configManager, appService, xssService) {
-    this.configManager = configManager;
-    this.appService = appService;
-    this.xssService = xssService;
+    logger.info('Reset customized value by pubsub notification');
+    await configManager.loadConfigs();
+    this.initCustomCss();
+    this.initCustomTitle();
+  }
+
+  async publishUpdatedMessage() {
+    const { s2sMessagingService } = this;
+
+    if (s2sMessagingService != null) {
+      const s2sMessage = new S2sMessage('customizeServiceUpdated', { updatedAt: new Date() });
+
+      try {
+        await s2sMessagingService.publish(s2sMessage);
+      }
+      catch (e) {
+        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      }
+    }
   }
 
   /**
@@ -24,6 +72,8 @@ class CustomizeService {
 
     // uglify and store
     this.customCss = uglifycss.processString(rawCss);
+
+    this.lastLoadedAt = new Date();
   }
 
   getCustomCss() {
@@ -42,6 +92,8 @@ class CustomizeService {
     }
 
     this.customTitleTemplate = configValue;
+
+    this.lastLoadedAt = new Date();
   }
 
   generateCustomTitle(pageOrPath) {

+ 3 - 2
src/server/service/global-notification/global-notification-mail.js

@@ -8,7 +8,6 @@ class GlobalNotificationMailService {
 
   constructor(crowi) {
     this.crowi = crowi;
-    this.mailer = crowi.getMailer();
     this.type = crowi.model('GlobalNotificationSetting').TYPE.MAIL;
     this.event = crowi.model('GlobalNotificationSetting').EVENT;
   }
@@ -24,13 +23,15 @@ class GlobalNotificationMailService {
    * @param {{ comment: Comment, oldPath: string }} _ event specific vars
    */
   async fire(event, path, triggeredBy, vars) {
+    const { mailService } = this.crowi;
+
     const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
     const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, this.type);
 
     const option = this.generateOption(event, path, triggeredBy, vars);
 
     await Promise.all(notifications.map((notification) => {
-      return this.mailer.send({ ...option, to: notification.toEmail });
+      return mailService.send({ ...option, to: notification.toEmail });
     }));
   }
 

+ 167 - 0
src/server/service/mail.js

@@ -0,0 +1,167 @@
+const logger = require('@alias/logger')('growi:service:mail');
+
+const nodemailer = require('nodemailer');
+const swig = require('swig-templates');
+
+
+const S2sMessage = require('../models/vo/s2s-message');
+const S2sMessageHandlable = require('./s2s-messaging/handlable');
+
+
+class MailService extends S2sMessageHandlable {
+
+  constructor(crowi) {
+    super();
+
+    this.appService = crowi.appService;
+    this.configManager = crowi.configManager;
+    this.s2sMessagingService = crowi.s2sMessagingService;
+
+    this.mailConfig = {};
+    this.mailer = {};
+
+    this.initialize();
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleS2sMessage(s2sMessage) {
+    const { eventName, updatedAt } = s2sMessage;
+    if (eventName !== 'mailServiceUpdated' || updatedAt == null) {
+      return false;
+    }
+
+    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleS2sMessage(s2sMessage) {
+    const { configManager } = this;
+
+    logger.info('Initialize mail settings by pubsub notification');
+    await configManager.loadConfigs();
+    this.initialize();
+  }
+
+  async publishUpdatedMessage() {
+    const { s2sMessagingService } = this;
+
+    if (s2sMessagingService != null) {
+      const s2sMessage = new S2sMessage('mailServiceUpdated', { updatedAt: new Date() });
+
+      try {
+        await s2sMessagingService.publish(s2sMessage);
+      }
+      catch (e) {
+        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      }
+    }
+  }
+
+
+  initialize() {
+    const { appService, configManager } = this;
+
+    if (!configManager.getConfig('crowi', 'mail:from')) {
+      this.mailer = null;
+      return;
+    }
+
+    // Priority 1. SMTP
+    if (configManager.getConfig('crowi', 'mail:smtpHost') && configManager.getConfig('crowi', 'mail:smtpPort')) {
+      this.mailer = this.createSMTPClient();
+    }
+    // Priority 2. SES
+    else if (configManager.getConfig('crowi', 'aws:accessKeyId') && configManager.getConfig('crowi', 'aws:secretAccessKey')) {
+      this.mailer = this.createSESClient();
+    }
+    else {
+      this.mailer = null;
+    }
+
+    this.mailConfig.from = configManager.getConfig('crowi', 'mail:from');
+    this.mailConfig.subject = `${appService.getAppTitle()}からのメール`;
+
+    logger.debug('mailer initialized');
+  }
+
+  createSMTPClient(option) {
+    const { configManager } = this;
+
+    logger.debug('createSMTPClient option', option);
+    if (!option) {
+      option = { // eslint-disable-line no-param-reassign
+        host: configManager.getConfig('crowi', 'mail:smtpHost'),
+        port: configManager.getConfig('crowi', 'mail:smtpPort'),
+      };
+
+      if (configManager.getConfig('crowi', 'mail:smtpUser') && configManager.getConfig('crowi', 'mail:smtpPassword')) {
+        option.auth = {
+          user: configManager.getConfig('crowi', 'mail:smtpUser'),
+          pass: configManager.getConfig('crowi', 'mail:smtpPassword'),
+        };
+      }
+      if (option.port === 465) {
+        option.secure = true;
+      }
+    }
+    option.tls = { rejectUnauthorized: false };
+
+    const client = nodemailer.createTransport(option);
+
+    logger.debug('mailer set up for SMTP', client);
+    return client;
+  }
+
+  createSESClient(option) {
+    const { configManager } = this;
+
+    if (!option) {
+      option = { // eslint-disable-line no-param-reassign
+        accessKeyId: configManager.getConfig('crowi', 'aws:accessKeyId'),
+        secretAccessKey: configManager.getConfig('crowi', 'aws:secretAccessKey'),
+      };
+    }
+
+    const ses = require('nodemailer-ses-transport');
+    const client = nodemailer.createTransport(ses(option));
+
+    logger.debug('mailer set up for SES', client);
+    return client;
+  }
+
+  setupMailConfig(overrideConfig) {
+    const c = overrideConfig;
+
+    let mc = {};
+    mc = this.mailConfig;
+
+    mc.to = c.to;
+    mc.from = c.from || this.mailConfig.from;
+    mc.text = c.text;
+    mc.subject = c.subject || this.mailConfig.subject;
+
+    return mc;
+  }
+
+  async send(config) {
+    if (this.mailer == null) {
+      throw new Error('Mailer is not completed to set up. Please set up SMTP or AWS setting.');
+    }
+
+    const templateVars = config.vars || {};
+    const output = await swig.renderFile(
+      config.template,
+      templateVars,
+    );
+
+    config.text = output;
+    return this.mailer.sendMail(this.setupMailConfig(config));
+  }
+
+}
+
+module.exports = MailService;

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

@@ -0,0 +1,29 @@
+class PageService {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  serializeToObj(page) {
+    const { User } = this.crowi.models;
+
+    const returnObj = page.toObject();
+
+    // set the ObjectID to revisionHackmdSynced
+    if (page.revisionHackmdSynced != null && page.revisionHackmdSynced._id != null) {
+      returnObj.revisionHackmdSynced = page.revisionHackmdSynced._id;
+    }
+
+    if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
+      returnObj.lastUpdateUser = page.lastUpdateUser.toObject();
+    }
+    if (page.creator != null && page.creator instanceof User) {
+      returnObj.creator = page.creator.toObject();
+    }
+
+    return returnObj;
+  }
+
+}
+
+module.exports = PageService;

+ 97 - 46
src/server/service/passport.js

@@ -1,6 +1,7 @@
-const debug = require('debug')('growi:service:PassportService');
+const logger = require('@alias/logger')('growi:service:PassportService');
 const urljoin = require('url-join');
 const luceneQueryParser = require('lucene-query-parser');
+
 const passport = require('passport');
 const LocalStrategy = require('passport-local').Strategy;
 const LdapStrategy = require('passport-ldapauth');
@@ -12,10 +13,13 @@ const SamlStrategy = require('passport-saml').Strategy;
 const OIDCIssuer = require('openid-client').Issuer;
 const BasicStrategy = require('passport-http').BasicStrategy;
 
+const S2sMessage = require('../models/vo/s2s-message');
+const S2sMessageHandlable = require('./s2s-messaging/handlable');
+
 /**
  * the service class of Passport
  */
-class PassportService {
+class PassportService extends S2sMessageHandlable {
 
   // see '/lib/form/login.js'
   static get USERNAME_FIELD() { return 'loginForm[username]' }
@@ -23,7 +27,10 @@ class PassportService {
   static get PASSWORD_FIELD() { return 'loginForm[password]' }
 
   constructor(crowi) {
+    super();
+
     this.crowi = crowi;
+    this.lastLoadedAt = null;
 
     /**
      * the flag whether LocalStrategy is set up successfully
@@ -118,6 +125,49 @@ class PassportService {
     };
   }
 
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleS2sMessage(s2sMessage) {
+    const { eventName, updatedAt, strategyId } = s2sMessage;
+    if (eventName !== 'passportServiceUpdated' || updatedAt == null || strategyId == null) {
+      return false;
+    }
+
+    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleS2sMessage(s2sMessage) {
+    const { configManager } = this.crowi;
+    const { strategyId } = s2sMessage;
+
+    logger.info('Reset strategy by pubsub notification');
+    await configManager.loadConfigs();
+    return this.setupStrategyById(strategyId);
+  }
+
+  async publishUpdatedMessage(strategyId) {
+    const { s2sMessagingService } = this.crowi;
+
+    if (s2sMessagingService != null) {
+      const s2sMessage = new S2sMessage('passportStrategyReloaded', {
+        updatedAt: new Date(),
+        strategyId,
+      });
+
+      try {
+        await s2sMessagingService.publish(s2sMessage);
+      }
+      catch (e) {
+        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      }
+    }
+  }
+
   /**
    * get SetupStrategies
    *
@@ -152,17 +202,18 @@ class PassportService {
   /**
    * setup strategy by target name
    */
-  setupStrategyById(authId) {
+  async setupStrategyById(authId) {
     const func = this.getSetupFunction(authId);
 
     try {
       this[func.setup]();
     }
     catch (err) {
-      debug(err);
+      logger.debug(err);
       this[func.reset]();
     }
 
+    this.lastLoadedAt = new Date();
   }
 
   /**
@@ -171,7 +222,7 @@ class PassportService {
    * @memberof PassportService
    */
   resetLocalStrategy() {
-    debug('LocalStrategy: reset');
+    logger.debug('LocalStrategy: reset');
     passport.unuse('local');
     this.isLocalStrategySetup = false;
   }
@@ -194,7 +245,7 @@ class PassportService {
       return;
     }
 
-    debug('LocalStrategy: setting up..');
+    logger.debug('LocalStrategy: setting up..');
 
     const User = this.crowi.model('User');
 
@@ -217,7 +268,7 @@ class PassportService {
     ));
 
     this.isLocalStrategySetup = true;
-    debug('LocalStrategy: setup is done');
+    logger.debug('LocalStrategy: setup is done');
   }
 
   /**
@@ -226,7 +277,7 @@ class PassportService {
    * @memberof PassportService
    */
   resetLdapStrategy() {
-    debug('LdapStrategy: reset');
+    logger.debug('LdapStrategy: reset');
     passport.unuse('ldapauth');
     this.isLdapStrategySetup = false;
   }
@@ -250,11 +301,11 @@ class PassportService {
       return;
     }
 
-    debug('LdapStrategy: setting up..');
+    logger.debug('LdapStrategy: setting up..');
 
     passport.use(new LdapStrategy(this.getLdapConfigurationFunc(config, { passReqToCallback: true }),
       (req, ldapAccountInfo, done) => {
-        debug('LDAP authentication has succeeded', ldapAccountInfo);
+        logger.debug('LDAP authentication has succeeded', ldapAccountInfo);
 
         // store ldapAccountInfo to req
         req.ldapAccountInfo = ldapAccountInfo;
@@ -263,7 +314,7 @@ class PassportService {
       }));
 
     this.isLdapStrategySetup = true;
-    debug('LdapStrategy: setup is done');
+    logger.debug('LdapStrategy: setup is done');
   }
 
   /**
@@ -335,23 +386,23 @@ class PassportService {
     // see: https://regex101.com/r/0tuYBB/1
     const match = serverUrl.match(/(ldaps?:\/\/[^/]+)\/(.*)?/);
     if (match == null || match.length < 1) {
-      debug('LdapStrategy: serverUrl is invalid');
+      logger.debug('LdapStrategy: serverUrl is invalid');
       return (req, callback) => { callback({ message: 'serverUrl is invalid' }) };
     }
     const url = match[1];
     const searchBase = match[2] || '';
 
-    debug(`LdapStrategy: url=${url}`);
-    debug(`LdapStrategy: searchBase=${searchBase}`);
-    debug(`LdapStrategy: isUserBind=${isUserBind}`);
+    logger.debug(`LdapStrategy: url=${url}`);
+    logger.debug(`LdapStrategy: searchBase=${searchBase}`);
+    logger.debug(`LdapStrategy: isUserBind=${isUserBind}`);
     if (!isUserBind) {
-      debug(`LdapStrategy: bindDN=${bindDN}`);
-      debug(`LdapStrategy: bindCredentials=${bindCredentials}`);
+      logger.debug(`LdapStrategy: bindDN=${bindDN}`);
+      logger.debug(`LdapStrategy: bindCredentials=${bindCredentials}`);
     }
-    debug(`LdapStrategy: searchFilter=${searchFilter}`);
-    debug(`LdapStrategy: groupSearchBase=${groupSearchBase}`);
-    debug(`LdapStrategy: groupSearchFilter=${groupSearchFilter}`);
-    debug(`LdapStrategy: groupDnProperty=${groupDnProperty}`);
+    logger.debug(`LdapStrategy: searchFilter=${searchFilter}`);
+    logger.debug(`LdapStrategy: groupSearchBase=${groupSearchBase}`);
+    logger.debug(`LdapStrategy: groupSearchFilter=${groupSearchFilter}`);
+    logger.debug(`LdapStrategy: groupDnProperty=${groupDnProperty}`);
 
     return (req, callback) => {
       // get credentials from form data
@@ -385,7 +436,7 @@ class PassportService {
           passwordField: PassportService.PASSWORD_FIELD,
           server: serverOpt,
         }, opts);
-        debug('ldap configuration: ', mergedOpts);
+        logger.debug('ldap configuration: ', mergedOpts);
 
         // store configuration to req
         req.ldapConfiguration = mergedOpts;
@@ -412,7 +463,7 @@ class PassportService {
       return;
     }
 
-    debug('GoogleStrategy: setting up..');
+    logger.debug('GoogleStrategy: setting up..');
     passport.use(
       new GoogleStrategy(
         {
@@ -434,7 +485,7 @@ class PassportService {
     );
 
     this.isGoogleStrategySetup = true;
-    debug('GoogleStrategy: setup is done');
+    logger.debug('GoogleStrategy: setup is done');
   }
 
   /**
@@ -443,7 +494,7 @@ class PassportService {
    * @memberof PassportService
    */
   resetGoogleStrategy() {
-    debug('GoogleStrategy: reset');
+    logger.debug('GoogleStrategy: reset');
     passport.unuse('google');
     this.isGoogleStrategySetup = false;
   }
@@ -460,7 +511,7 @@ class PassportService {
       return;
     }
 
-    debug('GitHubStrategy: setting up..');
+    logger.debug('GitHubStrategy: setting up..');
     passport.use(
       new GitHubStrategy(
         {
@@ -482,7 +533,7 @@ class PassportService {
     );
 
     this.isGitHubStrategySetup = true;
-    debug('GitHubStrategy: setup is done');
+    logger.debug('GitHubStrategy: setup is done');
   }
 
   /**
@@ -491,7 +542,7 @@ class PassportService {
    * @memberof PassportService
    */
   resetGitHubStrategy() {
-    debug('GitHubStrategy: reset');
+    logger.debug('GitHubStrategy: reset');
     passport.unuse('github');
     this.isGitHubStrategySetup = false;
   }
@@ -508,7 +559,7 @@ class PassportService {
       return;
     }
 
-    debug('TwitterStrategy: setting up..');
+    logger.debug('TwitterStrategy: setting up..');
     passport.use(
       new TwitterStrategy(
         {
@@ -530,7 +581,7 @@ class PassportService {
     );
 
     this.isTwitterStrategySetup = true;
-    debug('TwitterStrategy: setup is done');
+    logger.debug('TwitterStrategy: setup is done');
   }
 
   /**
@@ -539,7 +590,7 @@ class PassportService {
    * @memberof PassportService
    */
   resetTwitterStrategy() {
-    debug('TwitterStrategy: reset');
+    logger.debug('TwitterStrategy: reset');
     passport.unuse('twitter');
     this.isTwitterStrategySetup = false;
   }
@@ -556,7 +607,7 @@ class PassportService {
       return;
     }
 
-    debug('OidcStrategy: setting up..');
+    logger.debug('OidcStrategy: setting up..');
 
     // setup client
     // extend oidc request timeouts
@@ -568,7 +619,7 @@ class PassportService {
       ? urljoin(this.crowi.appService.getSiteUrl(), '/passport/oidc/callback')
       : configManager.getConfig('crowi', 'security:passport-oidc:callbackUrl'); // DEPRECATED: backward compatible with v3.2.3 and below
     const oidcIssuer = await OIDCIssuer.discover(issuerHost);
-    debug('Discovered issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
+    logger.debug('Discovered issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
 
     const authorizationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:authorizationEndpoint');
     if (authorizationEndpoint) {
@@ -602,7 +653,7 @@ class PassportService {
     if (jwksUri) {
       oidcIssuer.metadata.jwks_uri = jwksUri;
     }
-    debug('Configured issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
+    logger.debug('Configured issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
 
     const client = new oidcIssuer.Client({
       client_id: clientId,
@@ -625,7 +676,7 @@ class PassportService {
     })));
 
     this.isOidcStrategySetup = true;
-    debug('OidcStrategy: setup is done');
+    logger.debug('OidcStrategy: setup is done');
   }
 
   /**
@@ -634,7 +685,7 @@ class PassportService {
    * @memberof PassportService
    */
   resetOidcStrategy() {
-    debug('OidcStrategy: reset');
+    logger.debug('OidcStrategy: reset');
     passport.unuse('oidc');
     this.isOidcStrategySetup = false;
   }
@@ -651,7 +702,7 @@ class PassportService {
       return;
     }
 
-    debug('SamlStrategy: setting up..');
+    logger.debug('SamlStrategy: setting up..');
     passport.use(
       new SamlStrategy(
         {
@@ -673,7 +724,7 @@ class PassportService {
     );
 
     this.isSamlStrategySetup = true;
-    debug('SamlStrategy: setup is done');
+    logger.debug('SamlStrategy: setup is done');
   }
 
   /**
@@ -682,7 +733,7 @@ class PassportService {
    * @memberof PassportService
    */
   resetSamlStrategy() {
-    debug('SamlStrategy: reset');
+    logger.debug('SamlStrategy: reset');
     passport.unuse('saml');
     this.isSamlStrategySetup = false;
   }
@@ -718,15 +769,15 @@ class PassportService {
   verifySAMLResponseByABLCRule(response) {
     const rule = this.crowi.configManager.getConfig('crowi', 'security:passport-saml:ABLCRule');
     if (rule == null) {
-      debug('There is no ABLCRule.');
+      logger.debug('There is no ABLCRule.');
       return true;
     }
 
     const luceneRule = this.parseABLCRule(rule);
-    debug({ 'Parsed Rule': JSON.stringify(luceneRule, null, 2) });
+    logger.debug({ 'Parsed Rule': JSON.stringify(luceneRule, null, 2) });
 
     const attributes = this.extractAttributesFromSAMLResponse(response);
-    debug({ 'Extracted Attributes': JSON.stringify(attributes, null, 2) });
+    logger.debug({ 'Extracted Attributes': JSON.stringify(attributes, null, 2) });
 
     return this.evaluateRuleForSamlAttributes(attributes, luceneRule);
   }
@@ -827,7 +878,7 @@ class PassportService {
    * @memberof PassportService
    */
   resetBasicStrategy() {
-    debug('BasicStrategy: reset');
+    logger.debug('BasicStrategy: reset');
     passport.unuse('basic');
     this.isBasicStrategySetup = false;
   }
@@ -849,7 +900,7 @@ class PassportService {
       return;
     }
 
-    debug('BasicStrategy: setting up..');
+    logger.debug('BasicStrategy: setting up..');
 
     passport.use(new BasicStrategy(
       (userId, password, done) => {
@@ -861,7 +912,7 @@ class PassportService {
     ));
 
     this.isBasicStrategySetup = true;
-    debug('BasicStrategy: setup is done');
+    logger.debug('BasicStrategy: setup is done');
   }
 
   /**
@@ -875,7 +926,7 @@ class PassportService {
       throw new Error('serializer/deserializer have already been set up');
     }
 
-    debug('setting up serializer and deserializer');
+    logger.debug('setting up serializer and deserializer');
 
     const User = this.crowi.model('User');
 

+ 64 - 0
src/server/service/s2s-messaging/base.js

@@ -0,0 +1,64 @@
+const logger = require('@alias/logger')('growi:service:s2s-messaging:base');
+
+const S2sMessageHandlable = require('./handlable');
+
+class S2sMessagingServiceDelegator {
+
+  constructor(uri) {
+    this.uid = Math.floor(Math.random() * 100000);
+    this.uri = uri;
+
+    if (uri == null) {
+      throw new Error('uri must be set');
+    }
+
+    this.handlableList = [];
+  }
+
+  shouldResubscribe() {
+    throw new Error('implement this');
+  }
+
+  subscribe(forceReconnect) {
+    throw new Error('implement this');
+  }
+
+  /**
+   * Publish message
+   * @param {S2sMessage} s2sMessage
+   */
+  async publish(s2sMessage) {
+    s2sMessage.setPublisherUid(this.uid);
+  }
+
+  /**
+   * Add message handler
+   * @param {S2sMessageHandlable} handlable
+   */
+  addMessageHandler(handlable) {
+    if (!(handlable instanceof S2sMessageHandlable)) {
+      logger.warn('Unsupported instance');
+      logger.debug('Unsupported instance: ', handlable);
+      return;
+    }
+
+    this.handlableList.push(handlable);
+  }
+
+  /**
+   * Remove message handler
+   * @param {S2sMessageHandlable} handlable
+   */
+  removeMessageHandler(handlable) {
+    if (!(handlable instanceof S2sMessageHandlable)) {
+      logger.warn('Unsupported instance');
+      logger.debug('Unsupported instance: ', handlable);
+      return;
+    }
+
+    this.handlableList = this.handlableList.filter(h => h !== handlable);
+  }
+
+}
+
+module.exports = S2sMessagingServiceDelegator;

+ 18 - 0
src/server/service/s2s-messaging/handlable.js

@@ -0,0 +1,18 @@
+// TODO: make interface with TS
+
+/**
+ * The interface to handle server-to-server message
+ */
+class S2sMessageHandlable {
+
+  shouldHandleS2sMessage(s2sMessage) {
+    throw new Error('implement this');
+  }
+
+  async handleS2sMessage(s2sMessage) {
+    throw new Error('implement this');
+  }
+
+}
+
+module.exports = S2sMessageHandlable;

+ 46 - 0
src/server/service/s2s-messaging/index.js

@@ -0,0 +1,46 @@
+const logger = require('@alias/logger')('growi:service:s2s-messaging:S2sMessagingServiceFactory');
+
+const envToModuleMappings = {
+  redis:   'redis',
+  nchan:   'nchan',
+};
+
+/**
+ * Instanciate server-to-server messaging service
+ */
+class S2sMessagingServiceFactory {
+
+  initializeDelegator(crowi) {
+    const type = crowi.configManager.getConfig('crowi', 's2sMessagingPubsub:serverType');
+
+    if (type == null) {
+      logger.info('Config pub/sub server is not defined.');
+      return;
+    }
+
+    logger.info(`Config pub/sub server type '${type}' is set.`);
+
+    const module = envToModuleMappings[type];
+
+    const modulePath = `./${module}`;
+    this.delegator = require(modulePath)(crowi);
+
+    if (this.delegator == null) {
+      logger.warn('Failed to initialize config pub/sub delegator.');
+    }
+  }
+
+  getDelegator(crowi) {
+    if (this.delegator == null) {
+      this.initializeDelegator(crowi);
+    }
+    return this.delegator;
+  }
+
+}
+
+const factory = new S2sMessagingServiceFactory();
+
+module.exports = (crowi) => {
+  return factory.getDelegator(crowi);
+};

+ 194 - 0
src/server/service/s2s-messaging/nchan.js

@@ -0,0 +1,194 @@
+const logger = require('@alias/logger')('growi:service:s2s-messaging:nchan');
+
+const path = require('path');
+const axios = require('axios');
+const WebSocket = require('ws');
+const ReconnectingWebSocket = require('reconnecting-websocket');
+
+const S2sMessage = require('../../models/vo/s2s-message');
+const S2sMessagingServiceDelegator = require('./base');
+
+
+class NchanDelegator extends S2sMessagingServiceDelegator {
+
+  constructor(uri, publishPath, subscribePath, channelId) {
+    super(uri);
+
+    this.publishPath = publishPath;
+    this.subscribePath = subscribePath;
+
+    this.channelId = channelId;
+
+    /**
+     * A list of S2sMessageHandlable instance
+     */
+    this.handlableToEventListenerMap = {};
+
+    this.socket = null;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldResubscribe() {
+    return this.socket.readyState === ReconnectingWebSocket.CLOSED;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  subscribe(forceReconnect = false) {
+    if (forceReconnect) {
+      logger.info('Force reconnecting is requested. Try to reconnect...');
+    }
+    else if (this.socket != null && this.shouldResubscribe()) {
+      logger.info('The connection to config pubsub server is offline. Try to reconnect...');
+    }
+
+    // init client
+    if (this.socket == null) {
+      this.initClient();
+    }
+
+    // connect
+    if (forceReconnect || this.shouldResubscribe()) {
+      this.socket.reconnect();
+    }
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async publish(s2sMessage) {
+    await super.publish(s2sMessage);
+
+    const url = this.constructUrl(this.publishPath).toString();
+
+    logger.debug('Publish message', s2sMessage, `to ${url}`);
+
+    return axios.post(url, s2sMessage);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  addMessageHandler(handlable) {
+    if (this.socket == null) {
+      logger.error('socket has not initialized yet.');
+      return;
+    }
+
+    super.addMessageHandler(handlable);
+    this.registerMessageHandlerToSocket(handlable);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  removeMessageHandler(handlable) {
+    if (this.socket == null) {
+      logger.error('socket has not initialized yet.');
+      return;
+    }
+
+    super.removeMessageHandler(handlable);
+
+    const eventListener = this.handlableToEventListenerMap[handlable];
+    if (eventListener != null) {
+      this.socket.removeEventListener('message', eventListener);
+      delete this.handlableToEventListenerMap[handlable];
+    }
+  }
+
+  registerMessageHandlerToSocket(handlable) {
+    const eventListener = (messageObj) => {
+      this.handleMessage(messageObj, handlable);
+    };
+
+    this.socket.addEventListener('message', eventListener);
+    this.handlableToEventListenerMap[handlable] = eventListener;
+  }
+
+  constructUrl(basepath) {
+    const pathname = this.channelId == null
+      ? basepath //                                 /pubsub
+      : path.join(basepath, this.channelId); //     /pubsub/my-channel-id
+
+    return new URL(pathname, this.uri);
+  }
+
+  initClient() {
+    // const client = new WebSocketClient();
+    const url = this.constructUrl(this.publishPath).toString();
+    const socket = new ReconnectingWebSocket(url, [], {
+      WebSocket,
+      maxRetries: 3,
+      startClosed: true,
+    });
+
+    socket.addEventListener('close', () => {
+      logger.info('WebSocket client disconnected');
+    });
+    socket.addEventListener('error', (error) => {
+      logger.error('WebSocket error occured:', error.message);
+    });
+
+    socket.addEventListener('open', () => {
+      logger.info('WebSocket client connected.');
+    });
+
+    this.handlableList.forEach(handlable => this.registerMessageHandlerToSocket(handlable));
+
+    this.socket = socket;
+  }
+
+  /**
+   * Handle message string with the specified S2sMessageHandlable
+   *
+   * @see https://github.com/theturtle32/WebSocket-Node/blob/1f7ffba2f7a6f9473bcb39228264380ce2772ba7/docs/WebSocketConnection.md#message
+   *
+   * @param {object} message WebSocket-Node message object
+   * @param {S2sMessageHandlable} handlable
+   */
+  handleMessage(message, handlable) {
+    try {
+      const s2sMessage = S2sMessage.parse(message.data);
+
+      // check uid
+      if (s2sMessage.publisherUid === this.uid) {
+        logger.debug(`Skip processing by ${handlable.constructor.name} because this message is sent by the publisher itself:`, `from ${this.uid}`);
+        return;
+      }
+
+      // check shouldHandleS2sMessage
+      const shouldHandle = handlable.shouldHandleS2sMessage(s2sMessage);
+      logger.debug(`${handlable.constructor.name}.shouldHandleS2sMessage(`, s2sMessage, `) => ${shouldHandle}`);
+
+      if (shouldHandle) {
+        handlable.handleS2sMessage(s2sMessage);
+      }
+    }
+    catch (err) {
+      logger.warn('Could not handle a message: ', err.message);
+    }
+  }
+
+}
+
+module.exports = function(crowi) {
+  const { configManager } = crowi;
+
+  const uri = configManager.getConfig('crowi', 'app:nchanUri');
+
+  // when nachan server URI is not set
+  if (uri == null) {
+    logger.warn('NCHAN_URI is not specified.');
+    return;
+  }
+
+  const publishPath = configManager.getConfig('crowi', 's2sMessagingPubsub:nchan:publishPath');
+  const subscribePath = configManager.getConfig('crowi', 's2sMessagingPubsub:nchan:subscribePath');
+  const channelId = configManager.getConfig('crowi', 's2sMessagingPubsub:nchan:channelId');
+
+  return new NchanDelegator(uri, publishPath, subscribePath, channelId);
+};

+ 5 - 0
src/server/service/s2s-messaging/redis.js

@@ -0,0 +1,5 @@
+const logger = require('@alias/logger')('growi:service:s2s-messaging:redis');
+
+module.exports = function(crowi) {
+  logger.warn('Config pub/sub with Redis has not implemented yet.');
+};

+ 7 - 7
src/server/service/search-delegator/elasticsearch.js

@@ -17,9 +17,9 @@ const BULK_REINDEX_SIZE = 100;
 
 class ElasticsearchDelegator {
 
-  constructor(configManager, searchEvent) {
+  constructor(configManager, socketIoService) {
     this.configManager = configManager;
-    this.searchEvent = searchEvent;
+    this.socketIoService = socketIoService;
 
     this.client = null;
 
@@ -225,8 +225,8 @@ class ElasticsearchDelegator {
     catch (error) {
       logger.warn('An error occured while \'rebuildIndex\', normalize indices anyway.');
 
-      const { searchEvent } = this;
-      searchEvent.emit('rebuildingFailed', error);
+      const socket = this.socketIoService.getAdminSocket();
+      socket.emit('rebuildingFailed', { error: error.message });
 
       throw error;
     }
@@ -360,7 +360,7 @@ class ElasticsearchDelegator {
     const Bookmark = mongoose.model('Bookmark');
     const PageTagRelation = mongoose.model('PageTagRelation');
 
-    const { searchEvent } = this;
+    const socket = this.socketIoService.getAdminSocket();
 
     // prepare functions invoked from custom streams
     const prepareBodyForCreate = this.prepareBodyForCreate.bind(this);
@@ -458,7 +458,7 @@ class ElasticsearchDelegator {
           logger.info(`Adding pages progressing: (count=${count}, errors=${res.errors}, took=${res.took}ms)`);
 
           if (isEmittingProgressEvent) {
-            searchEvent.emit('addPageProgress', totalCount, count, skipped);
+            socket.emit('addPageProgress', { totalCount, count, skipped });
           }
         }
         catch (err) {
@@ -471,7 +471,7 @@ class ElasticsearchDelegator {
         logger.info(`Adding pages has completed: (totalCount=${totalCount}, skipped=${skipped})`);
 
         if (isEmittingProgressEvent) {
-          searchEvent.emit('finishAddPage', totalCount, count, skipped);
+          socket.emit('finishAddPage', { totalCount, count, skipped });
         }
         callback();
       },

+ 2 - 4
src/server/service/search.js

@@ -42,17 +42,15 @@ class SearchService {
   initDelegator() {
     logger.info('Initializing search delegator');
 
-    const searchEvent = this.crowi.event('search');
-
     if (this.isSearchboxEnabled) {
       logger.info('Searchbox is enabled');
       const SearchboxDelegator = require('./search-delegator/searchbox.js');
-      return new SearchboxDelegator(this.configManager, searchEvent);
+      return new SearchboxDelegator(this.configManager, this.crowi.socketIoService);
     }
     if (this.isElasticsearchEnabled) {
       logger.info('Elasticsearch (not Searchbox) is enabled');
       const ElasticsearchDelegator = require('./search-delegator/elasticsearch.js');
-      return new ElasticsearchDelegator(this.configManager, searchEvent);
+      return new ElasticsearchDelegator(this.configManager, this.crowi.socketIoService);
     }
 
   }

+ 37 - 0
src/server/service/socket-io.js

@@ -0,0 +1,37 @@
+const socketIo = require('socket.io');
+
+/**
+ * Serve socket.io for server-to-client messaging
+ */
+class SocketIoService {
+
+  get isInitialized() {
+    return (this.io != null);
+  }
+
+  attachServer(server) {
+    this.io = socketIo(server, {
+      transports: ['websocket'],
+    });
+
+    // create namespace for admin
+    this.adminNamespace = this.io.of('/admin');
+  }
+
+  getDefaultSocket() {
+    if (this.io == null) {
+      throw new Error('Http server has not attached yet.');
+    }
+    return this.io.sockets;
+  }
+
+  getAdminSocket() {
+    if (this.io == null) {
+      throw new Error('Http server has not attached yet.');
+    }
+    return this.adminNamespace;
+  }
+
+}
+
+module.exports = SocketIoService;

+ 110 - 0
src/server/service/system-events/sync-page-status.js

@@ -0,0 +1,110 @@
+const logger = require('@alias/logger')('growi:service:system-events:SyncPageStatusService');
+
+const S2sMessage = require('../../models/vo/s2s-message');
+const S2sMessageHandlable = require('../s2s-messaging/handlable');
+
+/**
+ * This service notify page status
+ *  to clients who are connecting to this server
+ *  and also notify to clients connecting to other GROWI servers
+ *
+ * Sequence:
+ *  1. A client A1 connecting to GROWI server A updates a page
+ *  2. GROWI server A notify the information
+ *    2.1 -- to client A2, A3, ... with SocketIoService
+ *    2.2 -- to other GROWI servers with S2sMessagingService
+ *  3. GROWI server B, C, ... relay the information to clients B1, B2, .. C1, C2, ... with SocketIoService
+ *
+ */
+class SyncPageStatusService extends S2sMessageHandlable {
+
+  constructor(crowi, s2sMessagingService, socketIoService) {
+    super();
+
+    this.crowi = crowi;
+    this.s2sMessagingService = s2sMessagingService;
+    this.socketIoService = socketIoService;
+
+    this.emitter = crowi.events.page;
+
+    this.initSystemEventListeners();
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleS2sMessage(s2sMessage) {
+    const { eventName } = s2sMessage;
+    if (eventName !== 'pageStatusUpdated') {
+      return false;
+    }
+
+    return true;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleS2sMessage(s2sMessage) {
+    const { socketIoEventName, page, user } = s2sMessage;
+    const { socketIoService } = this;
+
+    // emit the updated information to clients
+    if (socketIoService.isInitialized) {
+      socketIoService.getDefaultSocket().emit(socketIoEventName, { page, user });
+    }
+  }
+
+  async publishToOtherServers(socketIoEventName, page, user) {
+    const { s2sMessagingService } = this;
+
+    if (s2sMessagingService != null) {
+      const s2sMessage = new S2sMessage('pageStatusUpdated', { socketIoEventName, page, user });
+
+      try {
+        await s2sMessagingService.publish(s2sMessage);
+      }
+      catch (e) {
+        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      }
+    }
+  }
+
+  initSystemEventListeners() {
+    const { socketIoService } = this;
+    const { pageService } = this.crowi;
+
+    // register events
+    this.emitter.on('create', (page, user, socketClientId) => {
+      logger.debug('\'create\' event emitted.');
+
+      page = pageService.serializeToObj(page); // eslint-disable-line no-param-reassign
+      socketIoService.getDefaultSocket().emit('page:create', { page, user, socketClientId });
+
+      this.publishToOtherServers('page:create', page, user);
+    });
+    this.emitter.on('update', (page, user, socketClientId) => {
+      logger.debug('\'update\' event emitted.');
+
+      page = pageService.serializeToObj(page); // eslint-disable-line no-param-reassign
+      socketIoService.getDefaultSocket().emit('page:update', { page, user, socketClientId });
+
+      this.publishToOtherServers('page:update', page, user);
+    });
+    this.emitter.on('delete', (page, user, socketClientId) => {
+      logger.debug('\'delete\' event emitted.');
+
+      page = pageService.serializeToObj(page); // eslint-disable-line no-param-reassign
+      socketIoService.getDefaultSocket().emit('page:delete', { page, user, socketClientId });
+
+      this.publishToOtherServers('page:delete', page, user);
+    });
+    this.emitter.on('saveOnHackmd', (page) => {
+      socketIoService.getDefaultSocket().emit('page:editingWithHackmd', { page });
+      this.publishToOtherServers('page:editingWithHackmd', page);
+    });
+  }
+
+}
+
+module.exports = SyncPageStatusService;

+ 0 - 120
src/server/util/mailer.js

@@ -1,120 +0,0 @@
-/**
- * mailer
- */
-
-module.exports = function(crowi) {
-  const logger = require('@alias/logger')('growi:lib:mailer');
-  const nodemailer = require('nodemailer');
-  const swig = require('swig-templates');
-
-  const { configManager, appService } = crowi;
-
-  const mailConfig = {};
-  let mailer = {};
-
-  function createSMTPClient(option) {
-    logger.debug('createSMTPClient option', option);
-    if (!option) {
-      option = { // eslint-disable-line no-param-reassign
-        host: configManager.getConfig('crowi', 'mail:smtpHost'),
-        port: configManager.getConfig('crowi', 'mail:smtpPort'),
-      };
-
-      if (configManager.getConfig('crowi', 'mail:smtpUser') && configManager.getConfig('crowi', 'mail:smtpPassword')) {
-        option.auth = {
-          user: configManager.getConfig('crowi', 'mail:smtpUser'),
-          pass: configManager.getConfig('crowi', 'mail:smtpPassword'),
-        };
-      }
-      if (option.port === 465) {
-        option.secure = true;
-      }
-    }
-    option.tls = { rejectUnauthorized: false };
-
-    const client = nodemailer.createTransport(option);
-
-    logger.debug('mailer set up for SMTP', client);
-    return client;
-  }
-
-  function createSESClient(option) {
-    if (!option) {
-      option = { // eslint-disable-line no-param-reassign
-        accessKeyId: configManager.getConfig('crowi', 'aws:accessKeyId'),
-        secretAccessKey: configManager.getConfig('crowi', 'aws:secretAccessKey'),
-      };
-    }
-
-    const ses = require('nodemailer-ses-transport');
-    const client = nodemailer.createTransport(ses(option));
-
-    logger.debug('mailer set up for SES', client);
-    return client;
-  }
-
-  function initialize() {
-    if (!configManager.getConfig('crowi', 'mail:from')) {
-      mailer = undefined;
-      return;
-    }
-
-    if (configManager.getConfig('crowi', 'mail:smtpHost') && configManager.getConfig('crowi', 'mail:smtpPort')
-    ) {
-      // SMTP 設定がある場合はそれを優先
-      mailer = createSMTPClient();
-    }
-    else if (configManager.getConfig('crowi', 'aws:accessKeyId') && configManager.getConfig('crowi', 'aws:secretAccessKey')) {
-      // AWS 設定がある場合はSESを設定
-      mailer = createSESClient();
-    }
-    else {
-      mailer = undefined;
-    }
-
-    mailConfig.from = configManager.getConfig('crowi', 'mail:from');
-    mailConfig.subject = `${appService.getAppTitle()}からのメール`;
-
-    logger.debug('mailer initialized');
-  }
-
-  function setupMailConfig(overrideConfig) {
-    const c = overrideConfig;
-
-
-    let mc = {};
-    mc = mailConfig;
-
-    mc.to = c.to;
-    mc.from = c.from || mailConfig.from;
-    mc.text = c.text;
-    mc.subject = c.subject || mailConfig.subject;
-
-    return mc;
-  }
-
-  async function send(config) {
-    if (mailer == null) {
-      throw new Error('Mailer is not completed to set up. Please set up SMTP or AWS setting.');
-    }
-
-    const templateVars = config.vars || {};
-    const output = await swig.renderFile(
-      config.template,
-      templateVars,
-    );
-
-    config.text = output;
-    return mailer.sendMail(setupMailConfig(config));
-  }
-
-
-  initialize();
-
-  return {
-    createSMTPClient,
-    createSESClient,
-    mailer,
-    send,
-  };
-};

+ 3 - 2
src/server/views/layout/layout.html

@@ -23,6 +23,8 @@
     {% include '../widget/headers/mathjax.html' %}
   {% endif %}
 
+  {% include '../widget/headers/drawio.html' %}
+
   {% include '../widget/headers/scripts-for-dev.html' %}
 
   <script src="{{ webpack_asset('js/boot.js') }}"></script>
@@ -97,8 +99,7 @@
 <div id="grw-fab-container"></div>
 {% endblock %}
 
-<!-- /#staff-credit -->
-<div id="staff-credit"></div>
+<div id="grw-hotkeys-manager"></div>
 
 {% include '../widget/system-version.html' %}
 

+ 16 - 14
src/server/views/modal/shortcuts.html

@@ -13,7 +13,7 @@
             <div class="col-lg-6">
               <h3><strong>{{ t('modal_shortcuts.global.title') }}</strong></h3>
 
-                <table class="table table-responsive">
+                <table class="table">
                   <tr>
                     <th>{{ t('modal_shortcuts.global.Open/Close shortcut help') }}:</th>
                     <td><span class="key cmd-key"></span> + <span class="key">/</span></td>
@@ -37,6 +37,17 @@
                       <span class="key key-small">B</span>&nbsp;<span class="key key-small">A</span>
                     </td>
                   </tr>
+                  <tr>
+                    <th>{{ t('modal_shortcuts.global.MirrorMode') }}:</th>
+                    <td>
+                      <a href="{{ t('modal_shortcuts.global.konami_code_url') }}" target="_blank">{{ t('modal_shortcuts.global.Konami Code') }}</a><br>
+                      <span class="key key-small">X</span>&nbsp;<span class="key key-small">X</span>
+                      <span class="key key-small">B</span>&nbsp;<span class="key key-small">B</span>
+                      <span class="key key-small">A</span><br><span class="key key-small">Y</span>
+                      <span class="key key-small">A</span>&nbsp;<span class="key key-small">Y</span>
+                      <span class="key key-small">↓</span>&nbsp;<span class="key key-small">←</span>
+                    </td>
+                  </tr>
                 </table>
             </div><!-- /.col-lg-6 -->
 
@@ -44,7 +55,7 @@
             <div class="col-lg-6">
               <h3><strong>{{ t('modal_shortcuts.editor.title') }}</strong></h3>
 
-              <table class="table table-responsive">
+              <table class="table">
                 <tr>
                   <th>{{ t('modal_shortcuts.editor.Indent') }}:</th>
                   <td><span class="key key-longer">Tab</span></td>
@@ -62,20 +73,10 @@
                   <td><span class="key cmd-key"></span> + <span class="key">D</span></td>
                 </tr>
               </table>
-            </div><!-- /.col-lg-6 -->
-          </div><!-- /.row -->
-        </div><!-- /.container -->
 
-        <div class="container">
-          <div class="row">
-            <div class="col-lg-6">
-              <h3><strong></strong></h3>
-            </div><!-- /.col-lg-6 -->
-
-            <div class="col-lg-6">
               <h3><strong>{{ t('modal_shortcuts.commentform.title') }}</strong></h3>
 
-              <table class="table table-responsive">
+              <table class="table">
                 <tr>
                   <th>{{ t('modal_shortcuts.commentform.Post') }}:</th>
                   <td><span class="key cmd-key"></span> + <span class="key key-longer">{% include '../widget/icon-keyboard-return-enter.html' %}</span></td>
@@ -85,10 +86,11 @@
                   <td><span class="key cmd-key"></span> + <span class="key">D</span></td>
                 </tr>
               </table>
-            </div><!-- /.col-lg-6 -->
 
+            </div><!-- /.col-lg-6 -->
           </div><!-- /.row -->
         </div><!-- /.container -->
+
       </div>
     </div>
 

+ 2 - 0
src/server/views/search.html

@@ -12,6 +12,8 @@
 {% endblock %}
 
 {% block layout_main %}
+<div id="grw-fav-sticky-trigger" class="sticky-top"></div>
+
 <div class="container-fluid">
 
   <div class="row">

+ 31 - 0
src/server/views/widget/headers/drawio.html

@@ -0,0 +1,31 @@
+<!-- draw.io -->
+{% if getConfig('crowi', 'app:drawioUri') %}
+<script type="text/javascript">
+  // refs: https://github.com/jgraph/drawio/blob/v13.4.3/etc/build/build.xml#L35-L38
+  let url = new URL("{{ getConfig('crowi', 'app:drawioUri') }}");
+  let origin = url.origin;
+  window.DRAWIO_BASE_URL = origin;
+  window.DRAWIO_LIGHTBOX_URL = origin;
+  window.STENCIL_PATH = [origin, 'stencils'].join('/');
+  window.SHAPES_PATH = [origin, 'shapes'].join('/');
+  window.mxBasePath = [origin, 'mxgraph'].join('/');
+</script>
+{% endif %}
+
+<script type="text/javascript">
+  // define callback function invoked by viewer.min.js of draw.io
+  // refs: https://github.com/jgraph/drawio/blob/v12.9.1/etc/build/build.xml#L219-L232
+  window.onDrawioViewerLoad = function() {
+    const DrawioViewer = window.GraphViewer;
+
+    if (DrawioViewer != null) {
+      // disable useResizeSensor and checkVisibleState
+      //   for preventing resize event by viewer.min.js
+      DrawioViewer.useResizeSensor = false;
+      DrawioViewer.prototype.checkVisibleState = false;
+
+      // initialize
+      DrawioViewer.processElements();
+    }
+  };
+</script>

+ 17 - 12
src/server/views/widget/headers/mathjax.html

@@ -1,16 +1,21 @@
 <!-- Mathjax -->
-<script type="text/x-mathjax-config" async>
-  MathJax.Hub.Config({
-    skipStartupTypeset: true,
-    extensions: ["tex2jax.js"],
-    jax: ["input/TeX", "output/SVG"],
-    tex2jax: {
-      processEscapes: true
+<script type="text/javascript">
+  window.MathJax = {
+    startup: {
+      typeset: false
     },
-    showMathMenu: false,
-    showMathMenuMSIE: false,
-    showProcessingMessages: false,
-    messageStyle: "none"
-  });
+    tex: {
+      processEscapes: true,
+      inlineMath: [['$', '$'], ['\\(', '\\)']]
+    },
+    options: {
+      renderActions: {
+        addMenu: [],
+        checkLoading: []
+      },
+      ignoreHtmlClass: 'tex2jax_ignore',
+      processHtmlClass: 'tex2jax_process'
+    }
+  };
 </script>
 {{ cdnScriptTag('mathjax') }}

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

@@ -8,7 +8,7 @@
 {% endif %}
 
 <li>
-  <img src="{{ listPage.lastUpdateUser.imageUrlCached }}" class="picture rounded-circle">
+  <img src="{{ listPage.lastUpdateUser.imageUrlCached|default('/images/icons/user.svg') }}" class="picture rounded-circle">
   <a href="{{ encodeURI(listPage.path) }}" class="text-break ml-1">
     {{ listPage.path | preventXss }}
   </a>

+ 178 - 14
src/test/models/page.test.js

@@ -6,11 +6,13 @@ let testUser0;
 let testUser1;
 let testUser2;
 let testGroup0;
+let parentPage;
 
 describe('Page', () => {
   // eslint-disable-next-line no-unused-vars
   let crowi;
   let Page;
+  let PageQueryBuilder;
   let User;
   let UserGroup;
   let UserGroupRelation;
@@ -22,6 +24,8 @@ describe('Page', () => {
     UserGroup = mongoose.model('UserGroup');
     UserGroupRelation = mongoose.model('UserGroupRelation');
     Page = mongoose.model('Page');
+    PageQueryBuilder = Page.PageQueryBuilder;
+
 
     await User.insertMany([
       { name: 'Anon 0', username: 'anonymous0', email: 'anonymous0@example.com' },
@@ -58,6 +62,12 @@ describe('Page', () => {
         grantedUsers: [testUser0],
         creator: testUser0,
       },
+      {
+        path: '/grant',
+        grant: Page.GRANT_PUBLIC,
+        grantedUsers: [testUser0],
+        creator: testUser0,
+      },
       {
         path: '/grant/public',
         grant: Page.GRANT_PUBLIC,
@@ -112,6 +122,8 @@ describe('Page', () => {
       },
     ]);
 
+    parentPage = await Page.findOne({ path: '/grant' });
+
     done();
   });
 
@@ -297,42 +309,194 @@ describe('Page', () => {
     });
   });
 
-  describe('findListWithDescendants', () => {
-    test('should return only /page/', async() => {
-      const result = await Page.findListWithDescendants('/page/', testUser0, { isRegExpEscapedFromPath: true });
+  describe('PageQueryBuilder.addConditionToListWithDescendants', () => {
+    test('can retrieve descendants of /page', async() => {
+      const builder = new PageQueryBuilder(Page.find());
+      builder.addConditionToListWithDescendants('/page');
+
+      const result = await builder.query.exec();
 
       // assert totalCount
-      expect(result.totalCount).toEqual(1);
+      expect(result.length).toEqual(1);
       // assert paths
-      const pagePaths = result.pages.map((page) => { return page.path });
+      const pagePaths = result.map((page) => { return page.path });
       expect(pagePaths).toContainEqual('/page/for/extended');
     });
 
-    test('should return only /page1/', async() => {
-      const result = await Page.findListWithDescendants('/page1/', testUser0, { isRegExpEscapedFromPath: true });
+    test('can retrieve descendants of /page1', async() => {
+      const builder = new PageQueryBuilder(Page.find());
+      builder.addConditionToListWithDescendants('/page1/');
+
+      const result = await builder.query.exec();
 
       // assert totalCount
-      expect(result.totalCount).toEqual(2);
+      expect(result.length).toEqual(2);
       // assert paths
-      const pagePaths = result.pages.map((page) => { return page.path });
+      const pagePaths = result.map((page) => { return page.path });
       expect(pagePaths).toContainEqual('/page1');
       expect(pagePaths).toContainEqual('/page1/child1');
     });
   });
 
-  describe('findListByStartWith', () => {
-    test('should return pages which starts with /page', async() => {
-      const result = await Page.findListByStartWith('/page', testUser0, {});
+  describe('PageQueryBuilder.addConditionToListOnlyDescendants', () => {
+    test('can retrieve only descendants of /page', async() => {
+      const builder = new PageQueryBuilder(Page.find());
+      builder.addConditionToListOnlyDescendants('/page');
+
+      const result = await builder.query.exec();
 
       // assert totalCount
-      expect(result.totalCount).toEqual(4);
+      expect(result.length).toEqual(1);
       // assert paths
-      const pagePaths = result.pages.map((page) => { return page.path });
+      const pagePaths = result.map((page) => { return page.path });
+      expect(pagePaths).toContainEqual('/page/for/extended');
+    });
+
+    test('can retrieve only descendants of /page1', async() => {
+      const builder = new PageQueryBuilder(Page.find());
+      builder.addConditionToListOnlyDescendants('/page1');
+
+      const result = await builder.query.exec();
+
+      // assert totalCount
+      expect(result.length).toEqual(1);
+      // assert paths
+      const pagePaths = result.map((page) => { return page.path });
+      expect(pagePaths).toContainEqual('/page1/child1');
+    });
+  });
+
+  describe('PageQueryBuilder.addConditionToListByStartWith', () => {
+    test('can retrieve pages which starts with /page', async() => {
+      const builder = new PageQueryBuilder(Page.find());
+      builder.addConditionToListByStartWith('/page');
+
+      const result = await builder.query.exec();
+
+      // assert totalCount
+      expect(result.length).toEqual(4);
+      // assert paths
+      const pagePaths = result.map((page) => { return page.path });
       expect(pagePaths).toContainEqual('/page/for/extended');
       expect(pagePaths).toContainEqual('/page1');
       expect(pagePaths).toContainEqual('/page1/child1');
       expect(pagePaths).toContainEqual('/page2');
     });
+  });
+
+  describe('.findListWithDescendants', () => {
+    test('can retrieve all pages with testUser0', async() => {
+      const result = await Page.findListWithDescendants('/grant', testUser0);
+      const { pages } = result;
+
+      // assert totalCount
+      expect(pages.length).toEqual(5);
+
+      // assert paths
+      const pagePaths = await pages.map((page) => { return page.path });
+      expect(pagePaths).toContainEqual('/grant/groupacl');
+      expect(pagePaths).toContainEqual('/grant/specified');
+      expect(pagePaths).toContainEqual('/grant/owner');
+      expect(pagePaths).toContainEqual('/grant/public');
+      expect(pagePaths).toContainEqual('/grant');
+    });
+
+    test('can retrieve all pages with testUser1', async() => {
+      const result = await Page.findListWithDescendants('/grant', testUser1);
+      const { pages } = result;
 
+      // assert totalCount
+      expect(pages.length).toEqual(5);
+
+      // assert paths
+      const pagePaths = await pages.map((page) => { return page.path });
+      expect(pagePaths).toContainEqual('/grant/groupacl');
+      expect(pagePaths).toContainEqual('/grant/specified');
+      expect(pagePaths).toContainEqual('/grant/owner');
+      expect(pagePaths).toContainEqual('/grant/public');
+      expect(pagePaths).toContainEqual('/grant');
+    });
+
+    test('can retrieve all pages with testUser2', async() => {
+      const result = await Page.findListWithDescendants('/grant', testUser2);
+      const { pages } = result;
+
+      // assert totalCount
+      expect(pages.length).toEqual(5);
+
+      // assert paths
+      const pagePaths = await pages.map((page) => { return page.path });
+      expect(pagePaths).toContainEqual('/grant/groupacl');
+      expect(pagePaths).toContainEqual('/grant/specified');
+      expect(pagePaths).toContainEqual('/grant/owner');
+      expect(pagePaths).toContainEqual('/grant/public');
+      expect(pagePaths).toContainEqual('/grant');
+    });
+
+    test('can retrieve all pages without user', async() => {
+      const result = await Page.findListWithDescendants('/grant', null);
+      const { pages } = result;
+
+      // assert totalCount
+      expect(pages.length).toEqual(5);
+
+      // assert paths
+      const pagePaths = await pages.map((page) => { return page.path });
+      expect(pagePaths).toContainEqual('/grant/groupacl');
+      expect(pagePaths).toContainEqual('/grant/specified');
+      expect(pagePaths).toContainEqual('/grant/owner');
+      expect(pagePaths).toContainEqual('/grant/public');
+      expect(pagePaths).toContainEqual('/grant');
+    });
+  });
+
+  describe('.findManageableListWithDescendants', () => {
+    test('can retrieve all pages with testUser0', async() => {
+      const pages = await Page.findManageableListWithDescendants(parentPage, testUser0);
+
+      // assert totalCount
+      expect(pages.length).toEqual(5);
+
+      // assert paths
+      const pagePaths = await pages.map((page) => { return page.path });
+      expect(pagePaths).toContainEqual('/grant/groupacl');
+      expect(pagePaths).toContainEqual('/grant/specified');
+      expect(pagePaths).toContainEqual('/grant/owner');
+      expect(pagePaths).toContainEqual('/grant/public');
+      expect(pagePaths).toContainEqual('/grant');
+    });
+
+    test('can retrieve group page and public page which starts with testUser1', async() => {
+      const pages = await Page.findManageableListWithDescendants(parentPage, testUser1);
+
+      // assert totalCount
+      expect(pages.length).toEqual(3);
+
+      // assert paths
+      const pagePaths = await pages.map((page) => { return page.path });
+      expect(pagePaths).toContainEqual('/grant/groupacl');
+      expect(pagePaths).toContainEqual('/grant/public');
+      expect(pagePaths).toContainEqual('/grant');
+    });
+
+    test('can retrieve only public page which starts with testUser2', async() => {
+      const pages = await Page.findManageableListWithDescendants(parentPage, testUser2);
+
+      // assert totalCount
+      expect(pages.length).toEqual(2);
+
+      // assert paths
+      const pagePaths = await pages.map((page) => { return page.path });
+      expect(pagePaths).toContainEqual('/grant/public');
+      expect(pagePaths).toContainEqual('/grant');
+    });
+
+    test('can retrieve only public page which starts without user', async() => {
+      const pages = await Page.findManageableListWithDescendants(parentPage, null);
+
+      // assert totalCount
+      expect(pages).toBeNull();
+    });
   });
+
 });

+ 58 - 0
src/test/service/config-manager.test.js

@@ -0,0 +1,58 @@
+const { getInstance } = require('../setup-crowi');
+
+describe('ConfigManager test', () => {
+  let crowi;
+  let configManager;
+
+  beforeEach(async(done) => {
+    process.env.CONFIG_PUBSUB_SERVER_TYPE = 'nchan';
+
+    crowi = await getInstance();
+    configManager = crowi.configManager;
+    done();
+  });
+
+
+  describe('updateConfigsInTheSameNamespace()', () => {
+
+    const configModelMock = {};
+
+    beforeEach(async(done) => {
+      configManager.s2sMessagingService = {};
+
+      // prepare mocks for updateConfigsInTheSameNamespace method
+      configManager.configModel = configModelMock;
+
+      done();
+    });
+
+    test('invoke publishUpdateMessage()', async() => {
+      configModelMock.bulkWrite = jest.fn();
+      configManager.loadConfigs = jest.fn();
+      configManager.publishUpdateMessage = jest.fn();
+
+      const dummyConfig = { dummyKey: 'dummyValue' };
+      await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig);
+
+      expect(configModelMock.bulkWrite).toHaveBeenCalledTimes(1);
+      expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
+      expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
+    });
+
+    test('does not invoke publishUpdateMessage()', async() => {
+      configModelMock.bulkWrite = jest.fn();
+      configManager.loadConfigs = jest.fn();
+      configManager.publishUpdateMessage = jest.fn();
+
+      const dummyConfig = { dummyKey: 'dummyValue' };
+      await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig, true);
+
+      expect(configModelMock.bulkWrite).toHaveBeenCalledTimes(1);
+      expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
+      expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
+    });
+
+  });
+
+
+});

+ 106 - 72
yarn.lock

@@ -1668,6 +1668,11 @@
   resolved "https://registry.yarnpkg.com/@kaishuu0123/markdown-it-fence/-/markdown-it-fence-0.2.0.tgz#f46722bfce4ab7eb3e051def5090dcae1bd6e36b"
   integrity sha512-mdqKA+bXfJPl7gAg9tis8fGlea2oppBM068YbMDSXKWM6H18nVSZLrVKPHXpPWBgSv1ceeKkoWj8K1ntpIHlrw==
 
+"@kaishuu0123/markdown-it-fence@^1.0.0":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@kaishuu0123/markdown-it-fence/-/markdown-it-fence-1.0.0.tgz#07525441b731e9ba518d886e203da2557e533f0e"
+  integrity sha512-4e+1JVCN3Qg2KvZkyvlmaay929bfeqf3MyA9agyx49gXKYhp5fvFQD0/0moBP52Kj/u0LCdVEnNWXn1s8Zi5sQ==
+
 "@lykmapipo/common@>=0.34.2", "@lykmapipo/common@>=0.34.3":
   version "0.34.3"
   resolved "https://registry.yarnpkg.com/@lykmapipo/common/-/common-0.34.3.tgz#eb74fa4af14f2f1e59ddd42491f05ab69f96bd71"
@@ -2166,13 +2171,6 @@ abort-controller@^3.0.0:
   dependencies:
     event-target-shim "^5.0.0"
 
-accepts@1.3.3:
-  version "1.3.3"
-  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.3.tgz#c3ca7434938648c3e0d9c1e328dd68b622c284ca"
-  dependencies:
-    mime-types "~2.1.11"
-    negotiator "0.6.1"
-
 accepts@~1.3.4:
   version "1.3.4"
   resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.4.tgz#86246758c7dd6d21a6474ff084a4740ec05eb21f"
@@ -2890,6 +2888,11 @@ base64id@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/base64id/-/base64id-1.0.0.tgz#47688cb99bb6804f0e06d3e763b1c32e57d8e6b6"
 
+base64id@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6"
+  integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==
+
 base64url@^3.0.0, base64url@^3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d"
@@ -3031,6 +3034,11 @@ blob@0.0.4:
   version "0.0.4"
   resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.4.tgz#bcf13052ca54463f30f9fc7e95b9a47630a94921"
 
+blob@0.0.5:
+  version "0.0.5"
+  resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
+  integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==
+
 block-stream@*:
   version "0.0.9"
   resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a"
@@ -4001,6 +4009,11 @@ component-emitter@1.2.1, component-emitter@^1.2.1, component-emitter@~1.2.0:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
 
+component-emitter@~1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
+  integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==
+
 component-ie@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/component-ie/-/component-ie-1.0.0.tgz#0f9582ccb078a687592cc29eb46b3186e6fe637f"
@@ -4683,7 +4696,7 @@ debounce@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.1.0.tgz#6a1a4ee2a9dc4b7c24bb012558dbcdb05b37f408"
 
-debug@2, debug@2.6.9, debug@^2.0.0, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9, debug@~2.6.4, debug@~2.6.6, debug@~2.6.9:
+debug@2, debug@2.6.9, debug@^2.0.0, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
   version "2.6.9"
   resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
   dependencies:
@@ -4703,7 +4716,7 @@ debug@^3.1.0, debug@^3.2.6:
   dependencies:
     ms "^2.1.1"
 
-debug@^4.0.1, debug@^4.1.0, debug@^4.1.1:
+debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@~4.1.0:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
   dependencies:
@@ -5177,13 +5190,13 @@ end-of-stream@~1.1.0:
   dependencies:
     once "~1.3.0"
 
-engine.io-client@~3.1.0:
-  version "3.1.4"
-  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.1.4.tgz#4fcf1370b47163bd2ce9be2733972430350d4ea1"
+engine.io-client@~3.2.0:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.2.1.tgz#6f54c0475de487158a1a7c77d10178708b6add36"
   dependencies:
     component-emitter "1.2.1"
     component-inherit "0.0.3"
-    debug "~2.6.9"
+    debug "~3.1.0"
     engine.io-parser "~2.1.1"
     has-cors "1.1.0"
     indexof "0.0.1"
@@ -5193,9 +5206,9 @@ engine.io-client@~3.1.0:
     xmlhttprequest-ssl "~1.5.4"
     yeast "0.1.2"
 
-engine.io-client@~3.2.0:
-  version "3.2.1"
-  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.2.1.tgz#6f54c0475de487158a1a7c77d10178708b6add36"
+engine.io-client@~3.3.1:
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.3.2.tgz#04e068798d75beda14375a264bb3d742d7bc33aa"
   dependencies:
     component-emitter "1.2.1"
     component-inherit "0.0.3"
@@ -5205,18 +5218,19 @@ engine.io-client@~3.2.0:
     indexof "0.0.1"
     parseqs "0.0.5"
     parseuri "0.0.5"
-    ws "~3.3.1"
+    ws "~6.1.0"
     xmlhttprequest-ssl "~1.5.4"
     yeast "0.1.2"
 
-engine.io-client@~3.3.1:
-  version "3.3.2"
-  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.3.2.tgz#04e068798d75beda14375a264bb3d742d7bc33aa"
+engine.io-client@~3.4.0:
+  version "3.4.3"
+  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.4.3.tgz#192d09865403e3097e3575ebfeb3861c4d01a66c"
+  integrity sha512-0NGY+9hioejTEJCaSJZfWZLk4FPI9dN+1H1C4+wj2iuFba47UgZbJzfWs4aNFajnX/qAaYKbe2lLTfEEWzCmcw==
   dependencies:
-    component-emitter "1.2.1"
+    component-emitter "~1.3.0"
     component-inherit "0.0.3"
-    debug "~3.1.0"
-    engine.io-parser "~2.1.1"
+    debug "~4.1.0"
+    engine.io-parser "~2.2.0"
     has-cors "1.1.0"
     indexof "0.0.1"
     parseqs "0.0.5"
@@ -5235,18 +5249,16 @@ engine.io-parser@~2.1.0, engine.io-parser@~2.1.1:
     blob "0.0.4"
     has-binary2 "~1.0.2"
 
-engine.io@~3.1.0:
-  version "3.1.4"
-  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.1.4.tgz#3d0211b70a552ce841ffc7da8627b301a9a4162e"
+engine.io-parser@~2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.2.0.tgz#312c4894f57d52a02b420868da7b5c1c84af80ed"
+  integrity sha512-6I3qD9iUxotsC5HEMuuGsKA0cXerGz+4uGcXQEkfBidgKf0amsjrrtwcbwK/nzpZBxclXlV7gGl9dgWvu4LF6w==
   dependencies:
-    accepts "1.3.3"
-    base64id "1.0.0"
-    cookie "0.3.1"
-    debug "~2.6.9"
-    engine.io-parser "~2.1.0"
-    ws "~3.3.1"
-  optionalDependencies:
-    uws "~0.14.4"
+    after "0.8.2"
+    arraybuffer.slice "~0.0.7"
+    base64-arraybuffer "0.1.5"
+    blob "0.0.5"
+    has-binary2 "~1.0.2"
 
 engine.io@~3.2.0:
   version "3.2.1"
@@ -5259,6 +5271,18 @@ engine.io@~3.2.0:
     engine.io-parser "~2.1.0"
     ws "~3.3.1"
 
+engine.io@~3.4.0:
+  version "3.4.2"
+  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.4.2.tgz#8fc84ee00388e3e228645e0a7d3dfaeed5bd122c"
+  integrity sha512-b4Q85dFkGw+TqgytGPrGgACRUhsdKc9S9ErRAXpPGy/CXKs4tYoHDkvIRdsseAF7NjfVwjRFIn6KTnbw7LwJZg==
+  dependencies:
+    accepts "~1.3.4"
+    base64id "2.0.0"
+    cookie "0.3.1"
+    debug "~4.1.0"
+    engine.io-parser "~2.2.0"
+    ws "^7.1.2"
+
 enhanced-resolve@4.1.0, enhanced-resolve@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.1.0.tgz#41c7e0bfdfe74ac1ffe1e57ad6a5c6c9f3742a7f"
@@ -9076,12 +9100,12 @@ markdown-it-blockdiag@^1.1.1:
     url-join "^4.0.0"
     utf8-bytes "0.0.1"
 
-markdown-it-drawio-viewer@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/markdown-it-drawio-viewer/-/markdown-it-drawio-viewer-1.2.0.tgz#d47648c039f12e4c5ca706ed4d0f5dc19400c9a2"
-  integrity sha512-Hu9jxqKLVfFhk2T8J4ayaVbuoW2RSugRrXIsREMW7MMWFDciBgs9C8ADKaTav7JITY5fp7q6KJU7pqP/5dMRnA==
+markdown-it-drawio-viewer@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-drawio-viewer/-/markdown-it-drawio-viewer-1.3.0.tgz#bd70b5df7655080afbbe83a2d3bc9ac10f4e433e"
+  integrity sha512-aGm1sa9kWsuSDXwRMMSma6c026GRqcsKyFldO7hRv3vywE3SSDWFXUWDJ6j7kU5nXbQTd1LtcBokCyfn6JyunQ==
   dependencies:
-    "@kaishuu0123/markdown-it-fence" "^0.2.0"
+    "@kaishuu0123/markdown-it-fence" "^1.0.0"
     xmldoc "^1.1.2"
 
 markdown-it-emoji@^1.4.0:
@@ -9372,7 +9396,7 @@ mime-types@^2.1.3, mime-types@~2.1.18:
   dependencies:
     mime-db "~1.33.0"
 
-mime-types@~2.1.11, mime-types@~2.1.15, mime-types@~2.1.16, mime-types@~2.1.17:
+mime-types@~2.1.15, mime-types@~2.1.16, mime-types@~2.1.17:
   version "2.1.17"
   resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.17.tgz#09d7a393f03e995a79f8af857b70a9e0ab16557a"
   dependencies:
@@ -12177,6 +12201,11 @@ realpath-native@^1.1.0:
   dependencies:
     util.promisify "^1.0.0"
 
+reconnecting-websocket@^4.4.0:
+  version "4.4.0"
+  resolved "https://registry.yarnpkg.com/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz#3b0e5b96ef119e78a03135865b8bb0af1b948783"
+  integrity sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==
+
 redent@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde"
@@ -13269,41 +13298,43 @@ socket.io-adapter@~1.1.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.1.tgz#2a805e8a14d6372124dd9159ad4502f8cb07f06b"
 
-socket.io-client@2.0.4, socket.io-client@^2.0.3:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.0.4.tgz#0918a552406dc5e540b380dcd97afc4a64332f8e"
+socket.io-client@2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.1.1.tgz#dcb38103436ab4578ddb026638ae2f21b623671f"
   dependencies:
     backo2 "1.0.2"
     base64-arraybuffer "0.1.5"
     component-bind "1.0.0"
     component-emitter "1.2.1"
-    debug "~2.6.4"
-    engine.io-client "~3.1.0"
+    debug "~3.1.0"
+    engine.io-client "~3.2.0"
+    has-binary2 "~1.0.2"
     has-cors "1.1.0"
     indexof "0.0.1"
     object-component "0.0.3"
     parseqs "0.0.5"
     parseuri "0.0.5"
-    socket.io-parser "~3.1.1"
+    socket.io-parser "~3.2.0"
     to-array "0.1.4"
 
-socket.io-client@2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.1.1.tgz#dcb38103436ab4578ddb026638ae2f21b623671f"
+socket.io-client@2.3.0, socket.io-client@^2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.3.0.tgz#14d5ba2e00b9bcd145ae443ab96b3f86cbcc1bb4"
+  integrity sha512-cEQQf24gET3rfhxZ2jJ5xzAOo/xhZwK+mOqtGRg5IowZsMgwvHwnf/mCRapAAkadhM26y+iydgwsXGObBB5ZdA==
   dependencies:
     backo2 "1.0.2"
     base64-arraybuffer "0.1.5"
     component-bind "1.0.0"
     component-emitter "1.2.1"
-    debug "~3.1.0"
-    engine.io-client "~3.2.0"
+    debug "~4.1.0"
+    engine.io-client "~3.4.0"
     has-binary2 "~1.0.2"
     has-cors "1.1.0"
     indexof "0.0.1"
     object-component "0.0.3"
     parseqs "0.0.5"
     parseuri "0.0.5"
-    socket.io-parser "~3.2.0"
+    socket.io-parser "~3.3.0"
     to-array "0.1.4"
 
 socket.io-client@^2.0.4:
@@ -13325,15 +13356,6 @@ socket.io-client@^2.0.4:
     socket.io-parser "~3.3.0"
     to-array "0.1.4"
 
-socket.io-parser@~3.1.1:
-  version "3.1.2"
-  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.1.2.tgz#dbc2282151fc4faebbe40aeedc0772eba619f7f2"
-  dependencies:
-    component-emitter "1.2.1"
-    debug "~2.6.4"
-    has-binary2 "~1.0.2"
-    isarray "2.0.1"
-
 socket.io-parser@~3.2.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.2.0.tgz#e7c6228b6aa1f814e6148aea325b51aa9499e077"
@@ -13350,6 +13372,15 @@ socket.io-parser@~3.3.0:
     debug "~3.1.0"
     isarray "2.0.1"
 
+socket.io-parser@~3.4.0:
+  version "3.4.1"
+  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.4.1.tgz#b06af838302975837eab2dc980037da24054d64a"
+  integrity sha512-11hMgzL+WCLWf1uFtHSNvliI++tcRUWdoeYuwIl+Axvwy9z2gQM+7nJyN3STj1tLj5JyIUH8/gpDGxzAlDdi0A==
+  dependencies:
+    component-emitter "1.2.1"
+    debug "~4.1.0"
+    isarray "2.0.1"
+
 socket.io@2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.1.1.tgz#a069c5feabee3e6b214a75b40ce0652e1cfb9980"
@@ -13361,15 +13392,17 @@ socket.io@2.1.1:
     socket.io-client "2.1.1"
     socket.io-parser "~3.2.0"
 
-socket.io@^2.0.3:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.0.4.tgz#c1a4590ceff87ecf13c72652f046f716b29e6014"
+socket.io@^2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.3.0.tgz#cd762ed6a4faeca59bc1f3e243c0969311eb73fb"
+  integrity sha512-2A892lrj0GcgR/9Qk81EaY2gYhCBxurV0PfmmESO6p27QPrUK1J3zdns+5QPqvUYK2q657nSj0guoIil9+7eFg==
   dependencies:
-    debug "~2.6.6"
-    engine.io "~3.1.0"
+    debug "~4.1.0"
+    engine.io "~3.4.0"
+    has-binary2 "~1.0.2"
     socket.io-adapter "~1.1.0"
-    socket.io-client "2.0.4"
-    socket.io-parser "~3.1.1"
+    socket.io-client "2.3.0"
+    socket.io-parser "~3.4.0"
 
 sort-keys@^1.0.0:
   version "1.1.2"
@@ -14917,10 +14950,6 @@ uuid@^3.3.2:
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
   integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
 
-uws@~0.14.4:
-  version "0.14.5"
-  resolved "https://registry.yarnpkg.com/uws/-/uws-0.14.5.tgz#67aaf33c46b2a587a5f6666d00f7691328f149dc"
-
 v8-compile-cache@2.0.3:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.0.3.tgz#00f7494d2ae2b688cfe2899df6ed2c54bef91dbe"
@@ -15368,6 +15397,11 @@ ws@^7.0.0:
   resolved "https://registry.yarnpkg.com/ws/-/ws-7.2.1.tgz#03ed52423cd744084b2cf42ed197c8b65a936b8e"
   integrity sha512-sucePNSafamSKoOqoNfBd8V0StlkzJKL2ZAhGQinCfNQ+oacw+Pk7lcdAElecBF2VkLNZRiIb5Oi1Q5lVUVt2A==
 
+ws@^7.1.2, ws@^7.3.1:
+  version "7.3.1"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-7.3.1.tgz#d0547bf67f7ce4f12a72dfe31262c68d7dc551c8"
+  integrity sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA==
+
 ws@~3.3.1:
   version "3.3.3"
   resolved "https://registry.yarnpkg.com/ws/-/ws-3.3.3.tgz#f1cf84fe2d5e901ebce94efaece785f187a228f2"