2
0
Эх сурвалжийг харах

Merge branch 'kaishuu0123-master' into fix/2509-drawio-and-no-cdn-rich-icon

Koki Oyatsu 5 жил өмнө
parent
commit
54439e1ab1
62 өөрчлөгдсөн 1728 нэмэгдсэн , 526 устгасан
  1. 5 0
      CHANGES.md
  2. 2 0
      config/env.dev.js
  3. 2 3
      config/logger/config.dev.js
  4. 1 0
      package.json
  5. 1 0
      resource/locales/en_US/translation.json
  6. 1 0
      resource/locales/ja_JP/translation.json
  7. 3 3
      src/client/js/base.jsx
  8. 1 1
      src/client/js/components/Admin/App/MailSetting.jsx
  9. 1 1
      src/client/js/components/Admin/Users/UserInviteModal.jsx
  10. 80 0
      src/client/js/components/Hotkeys/HotkeysDetector.jsx
  11. 79 0
      src/client/js/components/Hotkeys/HotkeysManager.jsx
  12. 31 0
      src/client/js/components/Hotkeys/Subscribers/CreatePage.jsx
  13. 30 0
      src/client/js/components/Hotkeys/Subscribers/EditPage.jsx
  14. 26 0
      src/client/js/components/Hotkeys/Subscribers/ShowShortcutsModal.jsx
  15. 20 0
      src/client/js/components/Hotkeys/Subscribers/ShowStaffCredit.jsx
  16. 25 0
      src/client/js/components/Hotkeys/Subscribers/SwitchToMirrorMode.jsx
  17. 13 13
      src/client/js/components/InstallerForm.jsx
  18. 2 2
      src/client/js/components/Navbar/PersonalDropdown.jsx
  19. 8 3
      src/client/js/components/Page/RevisionRenderer.jsx
  20. 1 1
      src/client/js/components/PageAttachment.jsx
  21. 1 1
      src/client/js/components/Sidebar/RecentChanges.jsx
  22. 42 57
      src/client/js/components/StaffCredit/StaffCredit.jsx
  23. 12 3
      src/client/js/components/TableOfContents.jsx
  24. 0 40
      src/client/js/legacy/crowi.js
  25. 57 0
      src/client/js/models/HotkeyStroke.js
  26. 16 20
      src/client/js/services/NavigationContainer.js
  27. 8 0
      src/client/styles/scss/_me.scss
  28. 3 0
      src/client/styles/scss/_mirror_mode.scss
  29. 9 11
      src/client/styles/scss/_shortcuts.scss
  30. 15 8
      src/client/styles/scss/_staff_credit.scss
  31. 1 0
      src/client/styles/scss/style-app.scss
  32. 2 0
      src/server/crowi/express-init.js
  33. 61 28
      src/server/crowi/index.js
  34. 11 0
      src/server/middlewares/auto-reconnect-to-config-pubsub.js
  35. 71 75
      src/server/models/page.js
  36. 3 3
      src/server/models/user.js
  37. 26 0
      src/server/models/vo/config-pubsub-message.js
  38. 23 10
      src/server/routes/apiv3/app-settings.js
  39. 6 2
      src/server/routes/apiv3/customize-setting.js
  40. 27 19
      src/server/routes/apiv3/security-setting.js
  41. 1 1
      src/server/routes/index.js
  42. 4 5
      src/server/routes/installer.js
  43. 2 3
      src/server/routes/login.js
  44. 64 4
      src/server/service/app.js
  45. 30 0
      src/server/service/config-loader.js
  46. 56 4
      src/server/service/config-manager.js
  47. 62 0
      src/server/service/config-pubsub/base.js
  48. 14 0
      src/server/service/config-pubsub/handlable.js
  49. 43 0
      src/server/service/config-pubsub/index.js
  50. 195 0
      src/server/service/config-pubsub/nchan.js
  51. 5 0
      src/server/service/config-pubsub/redis.js
  52. 57 5
      src/server/service/customize.js
  53. 3 2
      src/server/service/global-notification/global-notification-mail.js
  54. 167 0
      src/server/service/mail.js
  55. 97 46
      src/server/service/passport.js
  56. 0 120
      src/server/util/mailer.js
  57. 1 2
      src/server/views/layout/layout.html
  58. 16 14
      src/server/views/modal/shortcuts.html
  59. 1 1
      src/server/views/widget/page_list.html
  60. 54 15
      src/test/models/page.test.js
  61. 58 0
      src/test/service/config-manager.test.js
  62. 72 0
      yarn.lock

+ 5 - 0
CHANGES.md

@@ -11,6 +11,11 @@ Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/41x.html>
 
 ### Updates
 
+* Feature: Config 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
+* Fix: New settings of SMTP and AWS SES are not reflected when server is running
 * Support: Support Node.js v14
 
 

+ 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',
+  // CONFIG_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:config-pubsub:*': '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
    */

+ 1 - 0
package.json

@@ -148,6 +148,7 @@
     "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
     "validator": "^12.0.0",
+    "websocket": "^1.0.31",
     "xss": "^1.0.6"
   },
   "devDependencies": {

+ 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 - 3
src/client/js/base.jsx

@@ -6,8 +6,8 @@ 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';
@@ -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);

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

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

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

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

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

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

+ 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 autoReconnectToConfigPubsub = require('../middlewares/auto-reconnect-to-config-pubsub')(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(autoReconnectToConfigPubsub);
 
   const middlewares = require('../util/middlewares')(crowi, app);
   app.use(middlewares.swigFilters(swig));

+ 61 - 28
src/server/crowi/index.js

@@ -35,9 +35,11 @@ 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.mailService = null;
   this.passportService = null;
   this.globalNotificationService = null;
   this.slackNotificationService = null;
@@ -249,7 +251,16 @@ Crowi.prototype.setupSessionConfig = async function() {
 Crowi.prototype.setupConfigManager = async function() {
   const ConfigManager = require('../service/config-manager');
   this.configManager = new ConfigManager(this.model('Config'));
-  return this.configManager.loadConfigs();
+  await this.configManager.loadConfigs();
+
+  // setup pubsub
+  this.configPubsub = require('../service/config-pubsub')(this);
+  if (this.configPubsub != null) {
+    this.configPubsub.subscribe();
+    this.configManager.setPubsub(this.configPubsub);
+    // add as a message handler
+    this.configPubsub.addMessageHandler(this.configManager);
+  }
 };
 
 Crowi.prototype.setupModels = async function() {
@@ -277,10 +288,6 @@ Crowi.prototype.scanRuntimeVersions = async function() {
   });
 };
 
-Crowi.prototype.getMailer = function() {
-  return this.mailer;
-};
-
 Crowi.prototype.getSlack = function() {
   return this.slack;
 };
@@ -308,18 +315,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.configPubsub != null) {
+    this.configPubsub.addMessageHandler(this.passportService);
+  }
+
   return Promise.resolve();
 };
 
@@ -329,11 +342,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.configPubsub != null) {
+    this.configPubsub.addMessageHandler(this.mailService);
+  }
 };
 
 Crowi.prototype.setupSlack = async function() {
@@ -365,7 +380,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);
@@ -388,14 +405,14 @@ Crowi.prototype.start = async function() {
   this.io = io;
 
   // 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 +431,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 +505,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.configPubsub != null) {
+      this.configPubsub.addMessageHandler(this.customizeService);
+    }
   }
 };
 
@@ -495,7 +522,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.configPubsub != null && !isInstalled) {
+      this.configPubsub.addMessageHandler(this.appService);
+    }
   }
 };
 

+ 11 - 0
src/server/middlewares/auto-reconnect-to-config-pubsub.js

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

+ 71 - 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,28 @@ 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 = {}) {
+    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 +1118,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 +1151,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 +1200,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 +1284,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 +1389,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'),

+ 26 - 0
src/server/models/vo/config-pubsub-message.js

@@ -0,0 +1,26 @@
+class ConfigPubsubMessage {
+
+  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 ConfigPubsubMessage(body.eventName, body);
+  }
+
+}
+
+module.exports = ConfigPubsubMessage;

+ 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 ConfigPubsubMessage
+      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 ConfigPubsubMessage
+      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'),

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

+ 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 ConfigPubsubMessage
+    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'),

+ 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'),

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

@@ -1,13 +1,73 @@
 const logger = require('@alias/logger')('growi:service:AppService'); // eslint-disable-line no-unused-vars
 const { pathUtils } = require('growi-commons');
 
+
+const ConfigPubsubMessage = require('../models/vo/config-pubsub-message');
+const ConfigPubsubMessageHandlable = require('./config-pubsub/handlable');
+
 /**
  * the service class of AppService
  */
-class AppService {
+class AppService extends ConfigPubsubMessageHandlable {
+
+  constructor(crowi) {
+    super();
+
+    this.crowi = crowi;
+    this.configManager = crowi.configManager;
+    this.configPubsub = crowi.configPubsub;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleConfigPubsubMessage(configPubsubMessage) {
+    const { eventName } = configPubsubMessage;
+    if (eventName !== 'systemInstalled') {
+      return false;
+    }
+
+    const isInstalled = this.crowi.configManager.getConfig('crowi', 'app:installed');
+
+    return !isInstalled;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleConfigPubsubMessage(configPubsubMessage) {
+    logger.info('Invoke post installation process by pubsub notification');
+
+    const { crowi, configManager, configPubsub } = this;
+
+    // load config and setup
+    await configManager.loadConfigs();
+
+    const isInstalled = this.crowi.configManager.getConfig('crowi', 'app:installed');
+    if (isInstalled) {
+      crowi.setupAfterInstall();
+
+      // remove message handler
+      configPubsub.removeMessageHandler(this);
+    }
+  }
+
+  async publishPostInstallationMessage() {
+    const { configPubsub } = this;
+
+    if (configPubsub != null) {
+      const configPubsubMessage = new ConfigPubsubMessage('systemInstalled');
+
+      try {
+        await configPubsub.publish(configPubsubMessage);
+      }
+      catch (e) {
+        logger.error('Failed to publish post installation message with configPubsub: ', e.message);
+      }
+    }
 
-  constructor(configManager) {
-    this.configManager = configManager;
+    // remove message handler
+    configPubsub.removeMessageHandler(this);
   }
 
   getAppTitle() {
@@ -49,7 +109,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() {

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

@@ -107,6 +107,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
   //   type:    ,
   //   default:
   // },
+  NCHAN_URI: {
+    ns:      'crowi',
+    key:     'app:nchanUri',
+    type:    TYPES.STRING,
+    default: null,
+  },
   APP_SITE_URL: {
     ns:      'crowi',
     key:     'app:siteUrl',
@@ -119,6 +125,30 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.BOOLEAN,
     default: false,
   },
+  CONFIG_PUBSUB_SERVER_TYPE: {
+    ns:      'crowi',
+    key:     'configPubsub:serverType',
+    type:    TYPES.STRING,
+    default: null,
+  },
+  CONFIG_PUBSUB_NCHAN_PUBLISH_PATH: {
+    ns:      'crowi',
+    key:     'configPubsub:nchan:publishPath',
+    type:    TYPES.STRING,
+    default: '/pubsub',
+  },
+  CONFIG_PUBSUB_NCHAN_SUBSCRIBE_PATH: {
+    ns:      'crowi',
+    key:     'configPubsub:nchan:subscribePath',
+    type:    TYPES.STRING,
+    default: '/pubsub',
+  },
+  CONFIG_PUBSUB_NCHAN_CHANNEL_ID: {
+    ns:      'crowi',
+    key:     'configPubsub:nchan:channelId',
+    type:    TYPES.STRING,
+    default: null,
+  },
   MAX_FILE_SIZE: {
     ns:      'crowi',
     key:     'app:maxFileSize',

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

@@ -1,5 +1,9 @@
 const logger = require('@alias/logger')('growi:service:ConfigManager');
-const ConfigLoader = require('../service/config-loader');
+
+const ConfigPubsubMessage = require('../models/vo/config-pubsub-message');
+const ConfigPubsubMessageHandlable = require('./config-pubsub/handlable');
+
+const ConfigLoader = require('./config-loader');
 
 const KEYS_FOR_LOCAL_STRATEGY_USE_ONLY_ENV_OPTION = [
   'security:passport-local:isEnabled',
@@ -18,13 +22,16 @@ const KEYS_FOR_SAML_USE_ONLY_ENV_OPTION = [
   'security:passport-saml:ABLCRule',
 ];
 
-class ConfigManager {
+class ConfigManager extends ConfigPubsubMessageHandlable {
 
   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 +45,16 @@ class ConfigManager {
 
     // cache all config keys
     this.reloadConfigKeys();
+
+    this.lastLoadedAt = new Date();
+  }
+
+  /**
+   * Set ConfigPubsubDelegator instance
+   * @param {ConfigPubsubDelegator} configPubsub
+   */
+  async setPubsub(configPubsub) {
+    this.configPubsub = configPubsub;
   }
 
   /**
@@ -163,7 +180,7 @@ class ConfigManager {
    *  );
    * ```
    */
-  async updateConfigsInTheSameNamespace(namespace, configs) {
+  async updateConfigsInTheSameNamespace(namespace, configs, withoutPublishingConfigPubsubMessage) {
     const queries = [];
     for (const key of Object.keys(configs)) {
       queries.push({
@@ -177,7 +194,11 @@ class ConfigManager {
     await this.configModel.bulkWrite(queries);
 
     await this.loadConfigs();
-    this.reloadConfigKeys();
+
+    // publish updated date after reloading
+    if (this.configPubsub != null && !withoutPublishingConfigPubsubMessage) {
+      this.publishUpdateMessage();
+    }
   }
 
   /**
@@ -287,6 +308,37 @@ class ConfigManager {
     return JSON.stringify(value === '' ? null : value);
   }
 
+  async publishUpdateMessage() {
+    const configPubsubMessage = new ConfigPubsubMessage('configUpdated', { updatedAt: new Date() });
+
+    try {
+      await this.configPubsub.publish(configPubsubMessage);
+    }
+    catch (e) {
+      logger.error('Failed to publish update message with configPubsub: ', e.message);
+    }
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleConfigPubsubMessage(configPubsubMessage) {
+    const { eventName, updatedAt } = configPubsubMessage;
+    if (eventName !== 'configUpdated' || updatedAt == null) {
+      return false;
+    }
+
+    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(configPubsubMessage.updatedAt);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleConfigPubsubMessage(configPubsubMessage) {
+    logger.info('Reload configs by pubsub notification');
+    return this.loadConfigs();
+  }
+
 }
 
 module.exports = ConfigManager;

+ 62 - 0
src/server/service/config-pubsub/base.js

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

+ 14 - 0
src/server/service/config-pubsub/handlable.js

@@ -0,0 +1,14 @@
+// TODO: make interface with TS
+class ConfigPubsubMessageHandlable {
+
+  shouldHandleConfigPubsubMessage(configPubsubMessage) {
+    throw new Error('implement this');
+  }
+
+  async handleConfigPubsubMessage(configPubsubMessage) {
+    throw new Error('implement this');
+  }
+
+}
+
+module.exports = ConfigPubsubMessageHandlable;

+ 43 - 0
src/server/service/config-pubsub/index.js

@@ -0,0 +1,43 @@
+const logger = require('@alias/logger')('growi:service:ConfigPubsubFactory');
+
+const envToModuleMappings = {
+  redis:   'redis',
+  nchan:   'nchan',
+};
+
+class ConfigPubsubFactory {
+
+  initializeDelegator(crowi) {
+    const type = crowi.configManager.getConfig('crowi', 'configPubsub: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 ConfigPubsubFactory();
+
+module.exports = (crowi) => {
+  return factory.getDelegator(crowi);
+};

+ 195 - 0
src/server/service/config-pubsub/nchan.js

@@ -0,0 +1,195 @@
+const logger = require('@alias/logger')('growi:service:config-pubsub:nchan');
+
+const path = require('path');
+const axios = require('axios');
+const WebSocketClient = require('websocket').client;
+
+const ConfigPubsubMessage = require('../../models/vo/config-pubsub-message');
+const ConfigPubsubDelegator = require('./base');
+
+
+class NchanDelegator extends ConfigPubsubDelegator {
+
+  constructor(uri, publishPath, subscribePath, channelId) {
+    super(uri);
+
+    this.publishPath = publishPath;
+    this.subscribePath = subscribePath;
+
+    this.channelId = channelId;
+    this.isConnecting = false;
+
+    /**
+     * A list of ConfigPubsubHandler instance
+     */
+    this.handlableList = [];
+
+    this.client = null;
+    this.connection = null;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldResubscribe() {
+    if (this.connection != null && this.connection.connected) {
+      return false;
+    }
+
+    return !this.isConnecting;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  subscribe(forceReconnect = false) {
+    if (forceReconnect) {
+      if (this.connection != null && this.connection.connected) {
+        this.connection.close();
+      }
+    }
+
+    if (this.client != null && this.shouldResubscribe()) {
+      logger.info('The connection to config pubsub server is offline. Try to reconnect...');
+    }
+
+    // init client
+    if (this.client == null) {
+      this.initClient();
+    }
+
+    // connect
+    this.isConnecting = true;
+    const url = this.constructUrl(this.subscribePath).toString();
+    logger.debug(`Subscribe to ${url}`);
+    this.client.connect(url.toString());
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async publish(configPubsubMessage) {
+    await super.publish(configPubsubMessage);
+
+    const url = this.constructUrl(this.publishPath).toString();
+
+    logger.debug('Publish message', configPubsubMessage, `to ${url}`);
+
+    return axios.post(url, JSON.stringify(configPubsubMessage));
+  }
+
+  /**
+   * @inheritdoc
+   */
+  addMessageHandler(handlable) {
+    super.addMessageHandler(handlable);
+    this.registerMessageHandlerToConnection(handlable);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  removeMessageHandler(handlable) {
+    super.removeMessageHandler(handlable);
+    this.subscribe(true);
+  }
+
+  registerMessageHandlerToConnection(handlable) {
+    if (this.connection != null) {
+      this.connection.on('message', (messageObj) => {
+        this.handleMessage(messageObj, handlable);
+      });
+    }
+  }
+
+  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();
+
+    client.on('connectFailed', (error) => {
+      logger.warn(`Connect Error: ${error.toString()}`);
+      this.isConnecting = false;
+    });
+
+    client.on('connect', (connection) => {
+      this.isConnecting = false;
+      this.connection = connection;
+
+      logger.info('WebSocket client connected');
+
+      connection.on('error', (error) => {
+        this.isConnecting = false;
+        logger.error(`Connection Error: ${error.toString()}`);
+      });
+      connection.on('close', () => {
+        logger.info('WebSocket connection closed');
+      });
+
+      // register all message handlers
+      this.handlableList.forEach(handler => this.registerMessageHandlerToConnection(handler));
+    });
+
+    this.client = client;
+  }
+
+  /**
+   * Handle message string with the specified ConfigPubsubHandler
+   *
+   * @see https://github.com/theturtle32/WebSocket-Node/blob/1f7ffba2f7a6f9473bcb39228264380ce2772ba7/docs/WebSocketConnection.md#message
+   *
+   * @param {object} message WebSocket-Node message object
+   * @param {ConfigPubsubHandler} handlable
+   */
+  handleMessage(message, handlable) {
+    if (message.type !== 'utf8') {
+      logger.warn('Only utf8 message is supported.');
+    }
+
+    try {
+      const configPubsubMessage = ConfigPubsubMessage.parse(message.utf8Data);
+
+      // check uid
+      if (configPubsubMessage.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 shouldHandleConfigPubsubMessage
+      const shouldHandle = handlable.shouldHandleConfigPubsubMessage(configPubsubMessage);
+      logger.debug(`${handlable.constructor.name}.shouldHandleConfigPubsubMessage(`, configPubsubMessage, `) => ${shouldHandle}`);
+
+      if (shouldHandle) {
+        handlable.handleConfigPubsubMessage(configPubsubMessage);
+      }
+    }
+    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', 'configPubsub:nchan:publishPath');
+  const subscribePath = configManager.getConfig('crowi', 'configPubsub:nchan:subscribePath');
+  const channelId = configManager.getConfig('crowi', 'configPubsub:nchan:channelId');
+
+  return new NchanDelegator(uri, publishPath, subscribePath, channelId);
+};

+ 5 - 0
src/server/service/config-pubsub/redis.js

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

+ 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 ConfigPubsubMessage = require('../models/vo/config-pubsub-message');
+const ConfigPubsubMessageHandlable = require('./config-pubsub/handlable');
+
+
 /**
  * the service class of CustomizeService
  */
-class CustomizeService {
+class CustomizeService extends ConfigPubsubMessageHandlable {
+
+  constructor(crowi) {
+    super();
+
+    this.configManager = crowi.configManager;
+    this.configPubsub = crowi.configPubsub;
+    this.appService = crowi.appService;
+    this.xssService = crowi.xssService;
+
+    this.lastLoadedAt = null;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleConfigPubsubMessage(configPubsubMessage) {
+    const { eventName, updatedAt } = configPubsubMessage;
+    if (eventName !== 'customizeServiceUpdated' || updatedAt == null) {
+      return false;
+    }
+
+    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(configPubsubMessage.updatedAt);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleConfigPubsubMessage(configPubsubMessage) {
+    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 { configPubsub } = this;
+
+    if (configPubsub != null) {
+      const configPubsubMessage = new ConfigPubsubMessage('customizeServiceUpdated', { updatedAt: new Date() });
+
+      try {
+        await configPubsub.publish(configPubsubMessage);
+      }
+      catch (e) {
+        logger.error('Failed to publish update message with configPubsub: ', 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 ConfigPubsubMessage = require('../models/vo/config-pubsub-message');
+const ConfigPubsubMessageHandlable = require('./config-pubsub/handlable');
+
+
+class MailService extends ConfigPubsubMessageHandlable {
+
+  constructor(crowi) {
+    super();
+
+    this.appService = crowi.appService;
+    this.configManager = crowi.configManager;
+    this.configPubsub = crowi.configPubsub;
+
+    this.mailConfig = {};
+    this.mailer = {};
+
+    this.initialize();
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleConfigPubsubMessage(configPubsubMessage) {
+    const { eventName, updatedAt } = configPubsubMessage;
+    if (eventName !== 'mailServiceUpdated' || updatedAt == null) {
+      return false;
+    }
+
+    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(configPubsubMessage.updatedAt);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleConfigPubsubMessage(configPubsubMessage) {
+    const { configManager } = this;
+
+    logger.info('Initialize mail settings by pubsub notification');
+    await configManager.loadConfigs();
+    this.initialize();
+  }
+
+  async publishUpdatedMessage() {
+    const { configPubsub } = this;
+
+    if (configPubsub != null) {
+      const configPubsubMessage = new ConfigPubsubMessage('mailServiceUpdated', { updatedAt: new Date() });
+
+      try {
+        await configPubsub.publish(configPubsubMessage);
+      }
+      catch (e) {
+        logger.error('Failed to publish update message with configPubsub: ', 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;

+ 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 ConfigPubsubMessage = require('../models/vo/config-pubsub-message');
+const ConfigPubsubMessageHandlable = require('./config-pubsub/handlable');
+
 /**
  * the service class of Passport
  */
-class PassportService {
+class PassportService extends ConfigPubsubMessageHandlable {
 
   // 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
+   */
+  shouldHandleConfigPubsubMessage(configPubsubMessage) {
+    const { eventName, updatedAt, strategyId } = configPubsubMessage;
+    if (eventName !== 'passportServiceUpdated' || updatedAt == null || strategyId == null) {
+      return false;
+    }
+
+    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(configPubsubMessage.updatedAt);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleConfigPubsubMessage(configPubsubMessage) {
+    const { configManager } = this.crowi;
+    const { strategyId } = configPubsubMessage;
+
+    logger.info('Reset strategy by pubsub notification');
+    await configManager.loadConfigs();
+    return this.setupStrategyById(strategyId);
+  }
+
+  async publishUpdatedMessage(strategyId) {
+    const { configPubsub } = this.crowi;
+
+    if (configPubsub != null) {
+      const configPubsubMessage = new ConfigPubsubMessage('passportStrategyReloaded', {
+        updatedAt: new Date(),
+        strategyId,
+      });
+
+      try {
+        await configPubsub.publish(configPubsubMessage);
+      }
+      catch (e) {
+        logger.error('Failed to publish update message with configPubsub: ', 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');
 

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

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

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

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

+ 54 - 15
src/test/models/page.test.js

@@ -11,6 +11,7 @@ describe('Page', () => {
   // eslint-disable-next-line no-unused-vars
   let crowi;
   let Page;
+  let PageQueryBuilder;
   let User;
   let UserGroup;
   let UserGroupRelation;
@@ -22,6 +23,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' },
@@ -297,42 +300,78 @@ 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.length).toEqual(1);
+      // assert paths
+      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.totalCount).toEqual(4);
+      expect(result.length).toEqual(4);
       // 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');
       expect(pagePaths).toContainEqual('/page1');
       expect(pagePaths).toContainEqual('/page1/child1');
       expect(pagePaths).toContainEqual('/page2');
     });
-
   });
 });

+ 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.configPubsub = {};
+
+      // 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();
+    });
+
+  });
+
+
+});

+ 72 - 0
yarn.lock

@@ -4624,6 +4624,14 @@ cyclist@~0.2.2:
   version "0.2.2"
   resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640"
 
+d@1, d@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a"
+  integrity sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==
+  dependencies:
+    es5-ext "^0.10.50"
+    type "^1.0.1"
+
 dashdash@^1.12.0, dashdash@^1.14.0:
   version "1.14.1"
   resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
@@ -5400,6 +5408,24 @@ es-to-primitive@^1.2.0:
     is-date-object "^1.0.1"
     is-symbol "^1.0.2"
 
+es5-ext@^0.10.35, es5-ext@^0.10.50:
+  version "0.10.53"
+  resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.53.tgz#93c5a3acfdbef275220ad72644ad02ee18368de1"
+  integrity sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==
+  dependencies:
+    es6-iterator "~2.0.3"
+    es6-symbol "~3.1.3"
+    next-tick "~1.0.0"
+
+es6-iterator@~2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7"
+  integrity sha1-p96IkUGgWpSwhUQDstCg+/qY87c=
+  dependencies:
+    d "1"
+    es5-ext "^0.10.35"
+    es6-symbol "^3.1.1"
+
 es6-object-assign@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/es6-object-assign/-/es6-object-assign-1.1.0.tgz#c2c3582656247c39ea107cb1e6652b6f9f24523c"
@@ -5426,6 +5452,14 @@ es6-promisify@^5.0.0:
   dependencies:
     es6-promise "^4.0.3"
 
+es6-symbol@^3.1.1, es6-symbol@~3.1.3:
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.3.tgz#bad5d3c1bcdac28269f4cb331e431c78ac705d18"
+  integrity sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==
+  dependencies:
+    d "^1.0.1"
+    ext "^1.1.2"
+
 esa-nodejs@^0.0.7:
   version "0.0.7"
   resolved "https://registry.yarnpkg.com/esa-nodejs/-/esa-nodejs-0.0.7.tgz#c4749412605ad430d5da17aa4928291927561b42"
@@ -5905,6 +5939,13 @@ express@^4.16.3:
     utils-merge "1.0.1"
     vary "~1.1.2"
 
+ext@^1.1.2:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/ext/-/ext-1.4.0.tgz#89ae7a07158f79d35517882904324077e4379244"
+  integrity sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==
+  dependencies:
+    type "^2.0.0"
+
 extend-shallow@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f"
@@ -9751,6 +9792,11 @@ neo-async@^2.6.1:
   resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c"
   integrity sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==
 
+next-tick@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"
+  integrity sha1-yobR/ogoFpsBICCOPchCS524NCw=
+
 nice-try@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.4.tgz#d93962f6c52f2c1558c0fbda6d512819f1efe1c4"
@@ -14547,6 +14593,16 @@ type-is@~1.6.16:
     media-typer "0.3.0"
     mime-types "~2.1.18"
 
+type@^1.0.1:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/type/-/type-1.2.0.tgz#848dd7698dafa3e54a6c479e759c4bc3f18847a0"
+  integrity sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==
+
+type@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/type/-/type-2.0.0.tgz#5f16ff6ef2eb44f260494dae271033b29c09a9c3"
+  integrity sha512-KBt58xCHry4Cejnc2ISQAF7QY+ORngsWfxezO68+12hKV6lQY8P/psIkcbjeHWn7MqcgciWJyCCevFMJdIXpow==
+
 typed-styles@^0.0.7:
   version "0.0.7"
   resolved "https://registry.yarnpkg.com/typed-styles/-/typed-styles-0.0.7.tgz#93392a008794c4595119ff62dde6809dbc40a3d9"
@@ -15194,6 +15250,17 @@ webpack@^4.39.3:
     watchpack "^1.6.0"
     webpack-sources "^1.4.1"
 
+websocket@^1.0.31:
+  version "1.0.31"
+  resolved "https://registry.yarnpkg.com/websocket/-/websocket-1.0.31.tgz#e5d0f16c3340ed87670e489ecae6144c79358730"
+  integrity sha512-VAouplvGKPiKFDTeCCO65vYHsyay8DqoBSlzIO3fayrfOgU94lQN5a1uWVnFrMLceTJw/+fQXR5PGbUVRaHshQ==
+  dependencies:
+    debug "^2.2.0"
+    es5-ext "^0.10.50"
+    nan "^2.14.0"
+    typedarray-to-buffer "^3.1.5"
+    yaeti "^0.0.6"
+
 whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0"
@@ -15514,6 +15581,11 @@ y18n@^3.2.1:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
 
+yaeti@^0.0.6:
+  version "0.0.6"
+  resolved "https://registry.yarnpkg.com/yaeti/-/yaeti-0.0.6.tgz#f26f484d72684cf42bedfb76970aa1608fbf9577"
+  integrity sha1-8m9ITXJoTPQr7ft2lwqhYI+/lXc=
+
 yallist@^2.1.2:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"