Jelajahi Sumber

Merge branch 'master' into imprv/4614-refactor-file-uploader

itizawa 5 tahun lalu
induk
melakukan
2c461c3427

+ 9 - 1
CHANGES.md

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

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

@@ -161,7 +161,7 @@
     "page_not_exist_alert": "This page does not exist. Please create a new page."
     "page_not_exist_alert": "This page does not exist. Please create a new page."
   },
   },
   "custom_navigation": {
   "custom_navigation": {
-    "no_page_list": "There are no pages under <a href='{{path}}'><strong>{{ path }}</strong></a>."
+    "no_page_list": "There are no pages under this page."
   },
   },
   "installer": {
   "installer": {
     "setup": "Setup",
     "setup": "Setup",

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

@@ -164,7 +164,7 @@
     "page_not_exist_alert": "このページは存在しません。新たに作成する必要があります。"
     "page_not_exist_alert": "このページは存在しません。新たに作成する必要があります。"
   },
   },
   "custom_navigation": {
   "custom_navigation": {
-    "no_page_list": "<a href='{{path}}'><strong>{{ path }}</strong></a>の配下にはページが存在しません。"
+    "no_page_list": "このページの配下にはページが存在しません。"
   },
   },
   "installer": {
   "installer": {
     "setup": "セットアップ",
     "setup": "セットアップ",

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

@@ -162,7 +162,7 @@
     "page_not_exist_alert": "该页面不存在,请创建一个新页面"
     "page_not_exist_alert": "该页面不存在,请创建一个新页面"
   },
   },
   "custom_navigation": {
   "custom_navigation": {
-    "no_page_list": "There are no pages under <a href='{{path}}'><strong>{{ path }}</strong></a>."
+    "no_page_list": "There are no pages under this page."
   },
   },
 	"installer": {
 	"installer": {
 		"setup": "安装",
 		"setup": "安装",

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -132,7 +132,7 @@ Crowi.prototype.initForTest = async function() {
   // // slack depends on setUpSlacklNotification
   // // slack depends on setUpSlacklNotification
   await Promise.all([
   await Promise.all([
     this.setUpApp(),
     this.setUpApp(),
-    // this.setUpXss(),
+    this.setUpXss(),
     // this.setUpSlacklNotification(),
     // this.setUpSlacklNotification(),
     // this.setUpGrowiBridge(),
     // this.setUpGrowiBridge(),
   ]);
   ]);

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

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

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

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

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

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

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

@@ -4,6 +4,7 @@ const autoReap = require('multer-autoreap');
 autoReap.options.reapOnError = true; // continue reaping the file even if an error occurs
 autoReap.options.reapOnError = true; // continue reaping the file even if an error occurs
 
 
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
+  const autoReconnectToSearch = require('../middlewares/auto-reconnect-to-search')(crowi);
   const applicationNotInstalled = require('../middlewares/application-not-installed')(crowi);
   const applicationNotInstalled = require('../middlewares/application-not-installed')(crowi);
   const applicationInstalled = require('../middlewares/application-installed')(crowi);
   const applicationInstalled = require('../middlewares/application-installed')(crowi);
   const accessTokenParser = require('../middlewares/access-token-parser')(crowi);
   const accessTokenParser = require('../middlewares/access-token-parser')(crowi);
@@ -32,7 +33,7 @@ module.exports = function(crowi, app) {
 
 
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
 
 
-  app.get('/'                        , applicationInstalled, loginRequired , page.showTopPage);
+  app.get('/'                        , applicationInstalled, loginRequired , autoReconnectToSearch, page.showTopPage);
 
 
   // API v3
   // API v3
   app.use('/api-docs', require('./apiv3/docs')(crowi));
   app.use('/api-docs', require('./apiv3/docs')(crowi));
@@ -175,6 +176,6 @@ module.exports = function(crowi, app) {
   app.get('/share/:linkId', page.showSharedPage);
   app.get('/share/:linkId', page.showSharedPage);
 
 
   app.get('/*/$'                   , loginRequired , page.showPageWithEndOfSlash, page.notFound);
   app.get('/*/$'                   , loginRequired , page.showPageWithEndOfSlash, page.notFound);
-  app.get('/*'                     , loginRequired , page.showPage, page.notFound);
+  app.get('/*'                     , loginRequired , autoReconnectToSearch, page.showPage, page.notFound);
 
 
 };
 };

+ 16 - 0
src/server/routes/page.js

@@ -147,6 +147,17 @@ module.exports = function(crowi, app) {
   const interceptorManager = crowi.getInterceptorManager();
   const interceptorManager = crowi.getInterceptorManager();
   const globalNotificationService = crowi.getGlobalNotificationService();
   const globalNotificationService = crowi.getGlobalNotificationService();
 
 
+  const XssOption = require('../../lib/service/xss/xssOption');
+  const Xss = require('../../lib/service/xss/index');
+  const initializedConfig = {
+    isEnabledXssPrevention: crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    attrWhiteList: crowi.xssService.getAttrWhiteList(),
+  };
+  const xssOption = new XssOption(initializedConfig);
+  const xss = new Xss(xssOption);
+
+
   const actions = {};
   const actions = {};
 
 
   function getPathFromRequest(req) {
   function getPathFromRequest(req) {
@@ -230,6 +241,11 @@ module.exports = function(crowi, app) {
   }
   }
 
 
   function addRenderVarsForPresentation(renderVars, page) {
   function addRenderVarsForPresentation(renderVars, page) {
+    // sanitize page.revision.body
+    if (crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention')) {
+      const preventXssRevision = xss.process(page.revision.body);
+      page.revision.body = preventXssRevision;
+    }
     renderVars.page = page;
     renderVars.page = page;
     renderVars.revision = page.revision;
     renderVars.revision = page.revision;
   }
   }

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

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

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

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

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

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