Преглед изворни кода

Merge branch 'master' into reactify-admin/markDownSettings

# Conflicts:
#	resource/locales/en-US/translation.json
itizawa пре 6 година
родитељ
комит
46acc78e3d
58 измењених фајлова са 2048 додато и 1381 уклоњено
  1. 1 1
      .eslintignore
  2. 2 1
      .stylelintrc.json
  3. 7 2
      .vscode/launch.json
  4. 41 4
      CHANGES.md
  5. 5 5
      app.json
  6. 3 0
      bin/heroku/install-packages.sh
  7. 0 7
      bin/heroku/install-plugins.sh
  8. 10 8
      config/jest.config.js
  9. 14 8
      config/migrate.js
  10. 15 16
      package.json
  11. 6 5
      resource/locales/en-US/translation.json
  12. 4 2
      resource/locales/ja/translation.json
  13. 15 2
      src/client/js/app.jsx
  14. 80 0
      src/client/js/components/Admin/ManageExternalAccount.jsx
  15. 132 0
      src/client/js/components/Admin/Users/ExternalAccountTable.jsx
  16. 0 78
      src/client/js/components/Admin/Users/ManageExternalAccount.jsx
  17. 2 1
      src/client/js/components/Admin/Users/UserRemoveButton.jsx
  18. 16 12
      src/client/js/components/Page/RevisionLoader.jsx
  19. 73 0
      src/client/js/services/AdminExternalAccountsContainer.js
  20. 11 5
      src/client/js/services/AdminUsersContainer.js
  21. 21 0
      src/linter-checker/test.js
  22. 23 0
      src/linter-checker/test.scss
  23. 28 0
      src/migrations/20191102223900-drop-configs-indices.js
  24. 29 0
      src/migrations/20191102223901-drop-pages-indices.js
  25. 5 8
      src/server/crowi/index.js
  26. 3 1
      src/server/models/attachment.js
  27. 7 4
      src/server/models/bookmark.js
  28. 8 7
      src/server/models/config.js
  29. 1 4
      src/server/models/external-account.js
  30. 5 0
      src/server/models/page-tag-relation.js
  31. 3 3
      src/server/models/page.js
  32. 2 0
      src/server/models/tag.js
  33. 3 0
      src/server/models/user-group-relation.js
  34. 4 4
      src/server/models/user.js
  35. 1 11
      src/server/routes/admin.js
  36. 74 2
      src/server/routes/apiv3/users.js
  37. 22 10
      src/server/routes/attachment.js
  38. 7 4
      src/server/routes/comment.js
  39. 0 2
      src/server/routes/installer.js
  40. 12 6
      src/server/service/config-loader.js
  41. 922 0
      src/server/service/search-delegator/elasticsearch.js
  42. 47 0
      src/server/service/search-delegator/searchbox.js
  43. 81 0
      src/server/service/search.js
  44. 0 919
      src/server/util/search.js
  45. 1 86
      src/server/views/admin/external-accounts.html
  46. 1 1
      src/server/views/admin/widget/passport/ldap.html
  47. 7 10
      src/test/crowi/crowi.test.js
  48. 12 0
      src/test/global-setup.js
  49. 19 27
      src/test/middleware/login-required.test.js
  50. 10 18
      src/test/models/page.test.js
  51. 3 8
      src/test/models/updatePost.test.js
  52. 4 6
      src/test/models/user.test.js
  53. 12 13
      src/test/service/acl.test.js
  54. 36 0
      src/test/service/search-delegator/searchbox.test.js
  55. 0 4
      src/test/setup-crowi.js
  56. 10 5
      src/test/util/slack.test.js
  57. 1 1
      wercker.yml
  58. 187 60
      yarn.lock

+ 1 - 1
.eslintignore

@@ -4,5 +4,5 @@
 /public/**
 /src/client/js/legacy/thirdparty-js/**
 /src/client/js/util/reveal/plugins/markdown.js
-/test/**
+/src/linter-checker/**
 /tmp/**

+ 2 - 1
.stylelintrc.json

@@ -4,7 +4,8 @@
     "./node_modules/prettier-stylelint/config.js"
   ],
   "ignoreFiles": [
-    "src/client/styles/scss/_override-bootstrap-variables.scss"
+    "src/client/styles/scss/_override-bootstrap-variables.scss",
+    "src/linter-checker/test.scss"
   ],
   "rules": {
     "indentation": 2,

+ 7 - 2
.vscode/launch.json

@@ -4,6 +4,12 @@
     // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387
     "version": "0.2.0",
     "configurations": [
+      {
+        "type": "node",
+        "request": "attach",
+        "name": "Debug: Attach Debugger to Server",
+        "port": 9229
+      },
       {
         "type": "node",
         "request": "launch",
@@ -11,10 +17,9 @@
         "runtimeExecutable": "npm",
         "runtimeArgs": [
           "run",
-          "server:debug"
+          "server:nolazy"
         ],
         "port": 9229,
-        "timeout": 30000,
         "restart": true,
         "console": "integratedTerminal",
         "internalConsoleOptions": "neverOpen"

+ 41 - 4
CHANGES.md

@@ -1,8 +1,45 @@
 # CHANGES
 
-## 3.5.19-RC
+## 3.6.0-RC
 
-* 
+### BREAKING CHANGES
+
+* GROWI v3.6.0 no longer support Node.js v8.x
+* The name of database that is storing migrations meta data has been changed
+    * This affects **only when `MONGO_URI` has parameters**
+    * v3.5.x or above has a bug ([#1361](https://github.com/weseek/growi/issues/1361))
+
+Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/36x.html
+
+### Updates
+
+* Improvement: Drop unnecessary MongoDB collection indexes
+* Support: Support Node.js v12
+* Support: Upgrade libs
+    * growi-commons
+
+## 3.5.21
+
+* Improvement: Cache control when retrieving attachment data
+* Fix: Inviting user doesn't work
+    * Introduced by 3.5.20
+
+## 3.5.20
+
+* Improvement: Organize MongoDB collection indexes uniqueness
+* Improvement: Reactify admin pages (External Account Management)
+* Fix: Search result or Timeline shows loading icon eternally when retrieving not accessible page
+* Support: Use SearchBox Elasticsearch Addon on Heroku
+* Support: Upgrade libs
+    * cross-env
+    * eslint-plugin-jest
+    * i18next
+    * i18next-browser-languagedetector
+    * migrate-mongo
+    * react-i18next
+    * validator
+
+## 3.5.19 (Missing number)
 
 ## 3.5.18
 
@@ -170,7 +207,7 @@
 * The restriction mode of the root page (`/`) will be set 'Public'
 * The restriction mode of the root page (`/`) can not be changed after v 3.5.1
 
-Upgrading Guide: https://docs.growi.org/guide/upgrading/35x.html
+Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/35x.html
 
 ### Updates
 
@@ -287,7 +324,7 @@ Upgrading Guide: https://docs.growi.org/guide/upgrading/35x.html
 
 None.
 
-Upgrading Guide: https://docs.growi.org/guide/upgrading/34x.html
+Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/34x.html
 
 ### Updates
 

+ 5 - 5
app.json

@@ -25,18 +25,18 @@
       "description": "A password seed is used by password hash generator. ",
       "generator": "secret"
     },
-    "INSTALL_PLUGINS": {
-      "description": "Comma-separated list of plugin package names to install.",
-      "value": "growi-plugin-lsx,growi-plugin-pukiwiki-like-linker",
+    "ADDITIONAL_PACKAGES": {
+      "description": "Space-separated list of npm package names to install.",
+      "value": "growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs react-images react-motion",
       "required": false
     }
   },
   "addons": [
     "mongolab",
     {
-      "plan": "bonsai:sandbox-6",
+      "plan": "searchbox:starter",
       "options": {
-        "version": "6.5.4"
+        "es_version": "6"
       }
     }
   ]

+ 3 - 0
bin/heroku/install-packages.sh

@@ -0,0 +1,3 @@
+#!/bin/sh
+
+yarn add -D $ADDITIONAL_PACKAGES

+ 0 - 7
bin/heroku/install-plugins.sh

@@ -1,7 +0,0 @@
-#!/bin/sh
-
-export IFS=","
-
-for plugin in $INSTALL_PLUGINS; do
-  yarn add $plugin
-done

+ 10 - 8
config/jest.config.js

@@ -1,6 +1,15 @@
 // For a detailed explanation regarding each configuration property, visit:
 // https://jestjs.io/docs/en/configuration.html
 
+const MODULE_NAME_MAPPING = {
+  '@root/(.+)': '<rootDir>/$1',
+  '@commons/(.+)': '<rootDir>/src/lib/$1',
+  '@server/(.+)': '<rootDir>/src/server/$1',
+  '@alias/logger': '<rootDir>/src/lib/service/logger',
+  // -- doesn't work with unknown error -- 2019.06.19 Yuki Takei
+  // debug: '<rootDir>/src/lib/service/logger/alias-for-debug',
+};
+
 module.exports = {
   // Indicates whether each individual test should be reported during the run
   verbose: true,
@@ -19,14 +28,7 @@ module.exports = {
       // Automatically clear mock calls and instances between every test
       clearMocks: true,
       // A map from regular expressions to module names that allow to stub out resources with a single module
-      moduleNameMapper: {
-        '@root/(.+)': '<rootDir>/$1',
-        '@commons/(.+)': '<rootDir>/src/lib/$1',
-        '@server/(.+)': '<rootDir>/src/server/$1',
-        '@alias/logger': '<rootDir>/src/lib/service/logger',
-        // -- doesn't work with unknown error -- 2019.06.19 Yuki Takei
-        // debug: '<rootDir>/src/lib/service/logger/alias-for-debug',
-      },
+      moduleNameMapper: MODULE_NAME_MAPPING,
     },
     // {
     //   displayName: 'client',

+ 14 - 8
config/migrate.js

@@ -7,20 +7,26 @@
 
 require('module-alias/register');
 
+const { URL } = require('url');
+
 const { getMongoUri } = require('@commons/util/mongoose-utils');
 
 const mongoUri = getMongoUri();
-const match = mongoUri.match(/^(.+)\/([^/]+)$/);
+
+// parse url
+const url = new URL(mongoUri);
+
+const mongodb = {
+  url: `${url.protocol}//${url.host}`,
+  databaseName: url.pathname.substring(1), // omit heading slash
+  options: {
+    useNewUrlParser: true, // removes a deprecation warning when connecting
+  },
+};
 
 module.exports = {
   mongoUri,
-  mongodb: {
-    url: match[0],
-    databaseName: match[2],
-    options: {
-      useNewUrlParser: true, // removes a deprecation warning when connecting
-    },
-  },
+  mongodb,
   migrationsDir: 'src/migrations/',
   changelogCollectionName: 'migrations',
 };

+ 15 - 16
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.5.19-RC",
+  "version": "3.6.0-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -33,7 +33,7 @@
     "clean:report": "rimraf -- report",
     "clean": "npm-run-all -p clean:*",
     "console": "env-cmd -f config/env.dev.js node --experimental-repl-await src/server/console.js",
-    "heroku-postbuild": "sh bin/heroku/install-plugins.sh && npm run build:prod",
+    "heroku-postbuild": "sh bin/heroku/install-packages.sh && npm run build:prod",
     "lint:js:fix": "eslint \"**/*.{js,jsx}\" --fix",
     "lint:js": "eslint \"**/*.{js,jsx}\"",
     "lint:styles:fix": "prettier-stylelint --quiet --write src/client/styles/scss/**/*.scss",
@@ -53,8 +53,8 @@
     "preserver:prod": "npm run migrate",
     "prestart": "npm run build:prod",
     "resource": "node bin/download-cdn-resources.js",
-    "server:debug": "env-cmd -f config/env.dev.js node-dev --inspect src/server/app.js",
-    "server:dev": "env-cmd -f config/env.dev.js node-dev --respawn src/server/app.js",
+    "server:nolazy": "env-cmd -f config/env.dev.js node-dev --nolazy --inspect src/server/app.js",
+    "server:dev": "env-cmd -f config/env.dev.js node-dev --inspect src/server/app.js",
     "server:prod:ci": "npm run server:prod -- --ci",
     "server:prod": "env-cmd -f config/env.prod.js node src/server/app.js",
     "server": "npm run server:dev",
@@ -84,7 +84,7 @@
     "connect-mongo": "^3.0.0",
     "connect-redis": "^3.3.0",
     "cookie-parser": "^1.4.3",
-    "cross-env": "^5.0.5",
+    "cross-env": "^6.0.3",
     "csrf": "^3.1.0",
     "date-fns": "^2.0.0",
     "diff": "^4.0.1",
@@ -101,15 +101,15 @@
     "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
     "graceful-fs": "^4.1.11",
-    "growi-commons": "^4.0.7",
+    "growi-commons": "^4.0.8",
     "helmet": "^3.13.0",
-    "i18next": "^17.0.3",
+    "i18next": "^19.0.0",
     "i18next-express-middleware": "^1.4.1",
     "i18next-node-fs-backend": "^2.1.0",
     "i18next-sprintf-postprocessor": "^0.2.2",
     "md5": "^2.2.1",
     "method-override": "^3.0.0",
-    "migrate-mongo": "^6.0.0",
+    "migrate-mongo": "^7.0.1",
     "mkdirp": "~0.5.1",
     "module-alias": "^2.0.6",
     "mongoose": "5.4.4",
@@ -139,7 +139,7 @@
     "uglifycss": "^0.0.29",
     "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
-    "validator": "^11.1.0",
+    "validator": "^12.0.0",
     "xss": "^1.0.6"
   },
   "devDependencies": {
@@ -174,14 +174,13 @@
     "eslint": "^6.0.1",
     "eslint-config-weseek": "^1.0.3",
     "eslint-plugin-import": "^2.18.0",
-    "eslint-plugin-jest": "^22.7.1",
+    "eslint-plugin-jest": "^23.0.3",
     "eslint-plugin-react": "^7.14.2",
     "file-loader": "^4.0.0",
     "handsontable": "=6.2.2",
-    "i18next-browser-languagedetector": "^3.0.1",
+    "i18next-browser-languagedetector": "^4.0.1",
     "imports-loader": "^0.8.0",
     "jest": "^24.8.0",
-    "jest-each": "^24.8.0",
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
@@ -219,7 +218,7 @@
     "react-dropzone": "^10.1.3",
     "react-frame-component": "^4.0.0",
     "react-hotkeys": "^2.0.0",
-    "react-i18next": "^10.6.1",
+    "react-i18next": "^11.1.0",
     "react-waypoint": "^9.0.0",
     "replacestream": "^4.0.3",
     "reveal.js": "^3.5.0",
@@ -248,8 +247,8 @@
     "debug": "src/lib/service/logger/alias-for-debug"
   },
   "engines": {
-    "node": ">=8.11.1 <11",
-    "npm": ">=5.6.0 <7",
-    "yarn": ">=1.5.1 <2"
+    "node": ">=10.17.0 <13",
+    "npm": ">=6.11.3 <7",
+    "yarn": ">=1.19.1 <2"
   }
 }

+ 6 - 5
resource/locales/en-US/translation.json

@@ -708,7 +708,7 @@
     "send_new_password": "Please send the new password to the user.",
     "password_never_seen": "The temporary password can never be retrieved after this screen is closed.",
     "reset_password": "Reset Password",
-    "related_username": "Related user's <code>%s</code>",
+    "related_username": "Related user's ",
     "accept": "Accept",
     "deactivate_account":"Deactivate Account",
     "your_own":"You cannot deactivate your own account",
@@ -719,10 +719,11 @@
     "valid_email": "Valid email address is required",
     "existing_email": "The following emails already exist",
     "give_user_admin": "Succeeded to give {{username}} admin",
-    "remove_user_admin": "Succeeded to remove {{username}} admin",
-    "activate_user_success": "Succeeded to activate {{username}} admin",
-    "deactivate_user_success": "Succeeded to deactive {{username}} admin",
-    "remove_user_success": "Succeeded to remove {{username}}"
+    "remove_user_admin": "Succeeded to remove {{username}} admin ",
+    "activate_user_success": "Succeeded to activating {{username}}",
+    "deactivate_user_success": "Succeeded to deactivate {{username}}",
+    "remove_user_success": "Succeeded to removing {{username}} ",
+    "remove_external_user_success": "Succeeded to remove {{accountId}} "
   },
 
   "user_group_management": {

+ 4 - 2
resource/locales/ja/translation.json

@@ -692,7 +692,7 @@
     "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。",
     "password_never_seen": "表示されたパスワードはこの画面を閉じると二度と表示できませんのでご注意ください。",
     "reset_password": "パスワードの再発行",
-    "related_username": "関連付けられているユーザーの <code>%s</code>",
+    "related_username": "関連付けられているユーザーの ",
     "accept": "承認する",
     "deactivate_account": "アカウント停止",
     "your_own": "自分自身のアカウントを停止することはできません",
@@ -706,7 +706,9 @@
     "remove_user_admin": "{{username}}を管理者から外しました",
     "activate_user_success": "{{username}}を有効化しました",
     "deactivate_user_success": "{{username}}を無効化しました",
-    "remove_user_success": "{{username}}を削除しました"
+    "remove_user_success": "{{username}}を削除しました",
+    "remove_external_user_success": "{{accountId}}を削除しました "
+
   },
 
   "user_group_management": {

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

@@ -40,7 +40,7 @@ import CustomScriptEditor from './components/Admin/CustomScriptEditor';
 import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
 import MarkdownSetting from './components/Admin/MarkdownSetting/MarkDownSetting';
 import UserManagement from './components/Admin/UserManagement';
-import ManageExternalAccount from './components/Admin/Users/ManageExternalAccount';
+import ManageExternalAccount from './components/Admin/ManageExternalAccount';
 import UserGroupPage from './components/Admin/UserGroup/UserGroupPage';
 import Customize from './components/Admin/Customize/Customize';
 import ImportDataPage from './components/Admin/ImportDataPage';
@@ -56,6 +56,7 @@ import UserGroupDetailContainer from './services/UserGroupDetailContainer';
 import AdminUsersContainer from './services/AdminUsersContainer';
 import WebsocketContainer from './services/WebsocketContainer';
 import MarkDownSettingContainer from './services/MarkDownSettingContainer';
+import AdminExternalAccountsContainer from './services/AdminExternalAccountsContainer';
 
 const logger = loggerFactory('growi:app');
 
@@ -110,7 +111,6 @@ let componentMappings = {
 
   'admin-full-text-search-management': <FullTextSearchManagement />,
   'admin-customize': <Customize />,
-  'admin-external-account-setting': <ManageExternalAccount />,
 
   'staff-credit': <StaffCredit />,
   'admin-importer': <ImportDataPage />,
@@ -171,6 +171,19 @@ if (adminUsersElem != null) {
   );
 }
 
+const adminExternalAccountsElem = document.getElementById('admin-external-account-setting');
+if (adminExternalAccountsElem != null) {
+  const adminExternalAccountsContainer = new AdminExternalAccountsContainer(appContainer);
+  ReactDOM.render(
+    <Provider inject={[injectableContainers, adminExternalAccountsContainer]}>
+      <I18nextProvider i18n={i18n}>
+        <ManageExternalAccount />
+      </I18nextProvider>
+    </Provider>,
+    adminExternalAccountsElem,
+  );
+}
+
 const adminUserGroupDetailElem = document.getElementById('admin-user-group-detail');
 if (adminUserGroupDetailElem != null) {
   const userGroupDetailContainer = new UserGroupDetailContainer(appContainer);

+ 80 - 0
src/client/js/components/Admin/ManageExternalAccount.jsx

@@ -0,0 +1,80 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import PaginationWrapper from '../PaginationWrapper';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import AdminExternalAccountsContainer from '../../services/AdminExternalAccountsContainer';
+import ExternalAccountTable from './Users/ExternalAccountTable';
+import { toastError } from '../../util/apiNotification';
+
+
+class ManageExternalAccount extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.xss = window.xss;
+    this.handleExternalAccountPage = this.handleExternalAccountPage.bind(this);
+  }
+
+  componentWillMount() {
+    this.handleExternalAccountPage(1);
+  }
+
+  async handleExternalAccountPage(selectedPage) {
+    try {
+      await this.props.adminExternalAccountsContainer.retrieveExternalAccountsByPagingNum(selectedPage);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t, adminExternalAccountsContainer } = this.props;
+
+    const pager = (
+      <div className="pull-right">
+        <PaginationWrapper
+          activePage={adminExternalAccountsContainer.state.activePage}
+          changePage={this.handleExternalAccountPage}
+          totalItemsCount={adminExternalAccountsContainer.state.totalAccounts}
+          pagingLimit={adminExternalAccountsContainer.state.pagingLimit}
+        />
+      </div>
+    );
+    return (
+      <Fragment>
+        <p>
+          <a className="btn btn-default" href="/admin/users">
+            <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
+            { t('user_management.back_to_user_management') }
+          </a>
+        </p>
+
+        <h2>{ t('user_management.external_account_list') }</h2>
+
+        { pager }
+        <ExternalAccountTable />
+        { pager }
+
+      </Fragment>
+    );
+  }
+
+}
+
+ManageExternalAccount.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminExternalAccountsContainer: PropTypes.instanceOf(AdminExternalAccountsContainer).isRequired,
+};
+
+const ManageExternalAccountWrapper = (props) => {
+  return createSubscribedElement(ManageExternalAccount, props, [AppContainer, AdminExternalAccountsContainer]);
+};
+
+
+export default withTranslation()(ManageExternalAccountWrapper);

+ 132 - 0
src/client/js/components/Admin/Users/ExternalAccountTable.jsx

@@ -0,0 +1,132 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import AdminExternalAccountsContainer from '../../../services/AdminExternalAccountsContainer';
+
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class ExternalAccountTable extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+
+    };
+    this.removeExtenalAccount = this.removeExtenalAccount.bind(this);
+  }
+
+  // remove external-account
+  async removeExtenalAccount(externalAccountId) {
+    const { t } = this.props;
+
+    try {
+      const accountId = await this.props.adminExternalAccountsContainer.removeExternalAccountById(externalAccountId);
+      toastSuccess(t('user_management.remove_external_user_success', { accountId }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+
+  render() {
+    const { t, adminExternalAccountsContainer } = this.props;
+    return (
+      <Fragment>
+        <table className="table table-bordered table-user-list">
+          <thead>
+            <tr>
+              <th width="120px">{ t('user_management.authentication_provider') }</th>
+              <th><code>accountId</code></th>
+              <th>{ t('user_management.related_username') }<code>username</code></th>
+              <th>
+                { t('user_management.password_setting') }
+                <div
+                  className="text-muted"
+                  data-toggle="popover"
+                  data-placement="top"
+                  data-trigger="hover focus"
+                  tabIndex="0"
+                  role="button"
+                  data-animation="false"
+                  data-html="true"
+                  data-content={t('user_management.password_setting_help')}
+                >
+                  <small>
+                    <i className="icon-question" aria-hidden="true"></i>
+                  </small>
+                </div>
+              </th>
+              <th width="100px">{ t('Created') }</th>
+              <th width="70px"></th>
+            </tr>
+          </thead>
+          <tbody>
+            {adminExternalAccountsContainer.state.externalAccounts.map((ea) => {
+              return (
+                <tr key={ea._id}>
+                  <td>{ea.providerType}</td>
+                  <td>
+                    <strong>{ea.accountId}</strong>
+                  </td>
+                  <td>
+                    <strong>{ ea.user.username }</strong>
+                  </td>
+                  <td>
+                    { ea.user.password
+                      ? (
+                        <span className="label label-info">
+                          { t('user_management.set') }
+                        </span>
+                      )
+                      : (
+                        <span className="label label-warning">
+                          { t('user_management.unset') }
+                        </span>
+                      )
+                    }
+                  </td>
+                  <td>{dateFnsFormat(new Date(ea.createdAt), 'yyyy-MM-dd')}</td>
+                  <td>
+                    <div className="btn-group admin-user-menu">
+                      <button type="button" className="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
+                        <i className="icon-settings"></i> <span className="caret"></span>
+                      </button>
+                      <ul className="dropdown-menu" role="menu">
+                        <li className="dropdown-header">{ t('user_management.edit_menu') }</li>
+                        <li>
+                          <a onClick={() => { return this.removeExtenalAccount(ea._id) }}>
+                            <i className="icon-fw icon-fire text-danger"></i> { t('Delete') }
+                          </a>
+                        </li>
+                      </ul>
+                    </div>
+                  </td>
+                </tr>
+              );
+            })}
+          </tbody>
+        </table>
+      </Fragment>
+    );
+  }
+
+}
+
+ExternalAccountTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminExternalAccountsContainer: PropTypes.instanceOf(AdminExternalAccountsContainer).isRequired,
+};
+
+const ExternalAccountTableWrapper = (props) => {
+  return createSubscribedElement(ExternalAccountTable, props, [AppContainer, AdminExternalAccountsContainer]);
+};
+
+
+export default withTranslation()(ExternalAccountTableWrapper);

+ 0 - 78
src/client/js/components/Admin/Users/ManageExternalAccount.jsx

@@ -1,78 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { createSubscribedElement } from '../../UnstatedUtils';
-import AppContainer from '../../../services/AppContainer';
-
-
-class ManageExternalAccount extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-    };
-  }
-
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <Fragment>
-        <p>
-          <a className="btn btn-default" href="/admin/users">
-            <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
-            { t('user_management.back_to_user_management') }
-          </a>
-        </p>
-
-        <h2>{ t('user_management.external_account_list') }</h2>
-
-        <table className="table table-bordered table-user-list">
-          <thead>
-            <tr>
-              <th width="120px">{ t('user_management.authentication_provider') }</th>
-              <th><code>accountId</code></th>
-              <th>{ t('user_management.related_username', 'username') }</th>
-              <th>
-                { t('user_management.password_setting') }
-                <div
-                  className="text-muted"
-                  data-toggle="popover"
-                  data-placement="top"
-                  data-trigger="hover focus"
-                  tabIndex="0"
-                  role="button"
-                  data-animation="false"
-                  data-html="true"
-                  data-content="<small>{{ t('user_management.password_setting_help') }}</small>"
-                >
-                  <small>
-                    <i className="icon-question" aria-hidden="true"></i>
-                  </small>
-                </div>
-              </th>
-              <th width="100px">{ t('Created') }</th>
-              <th width="70px"></th>
-            </tr>
-          </thead>
-          {/* TODO GW-328 */}
-        </table>
-      </Fragment>
-    );
-  }
-
-}
-
-const ManageExternalAccountWrapper = (props) => {
-  return createSubscribedElement(ManageExternalAccount, props, [AppContainer]);
-};
-
-ManageExternalAccount.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-export default withTranslation()(ManageExternalAccountWrapper);

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

@@ -19,7 +19,8 @@ class UserRemoveButton extends React.Component {
     const { t } = this.props;
 
     try {
-      const username = await this.props.adminUsersContainer.removeUser(this.props.user._id);
+      await this.props.adminUsersContainer.removeUser(this.props.user._id);
+      const { username } = this.props.user;
       toastSuccess(t('user_management.remove_user_success', { username }));
     }
     catch (err) {

+ 16 - 12
src/client/js/components/Page/RevisionLoader.jsx

@@ -46,21 +46,25 @@ class RevisionLoader extends React.Component {
     };
 
     // load data with REST API
-    const res = await this.props.appContainer.apiGet('/revisions.get', requestData);
-    this.setState({ isLoaded: true, isLoading: false });
+    try {
+      const res = await this.props.appContainer.apiGet('/revisions.get', requestData);
 
-    if (res != null && !res.ok) {
-      throw new Error(res.error);
-    }
-
-    this.setState({
-      markdown: res.revision.body,
-      error: null,
-    });
+      this.setState({
+        markdown: res.revision.body,
+        error: null,
+      });
 
-    if (this.props.onRevisionLoaded != null) {
-      this.props.onRevisionLoaded(res.revision);
+      if (this.props.onRevisionLoaded != null) {
+        this.props.onRevisionLoaded(res.revision);
+      }
     }
+    catch (error) {
+      this.setState({ error });
+    }
+    finally {
+      this.setState({ isLoaded: true, isLoading: false });
+    }
+
   }
 
   onWaypointChange(event) {

+ 73 - 0
src/client/js/services/AdminExternalAccountsContainer.js

@@ -0,0 +1,73 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:services:AdminexternalaccountsContainer');
+
+/**
+ * Service container for admin external-accounts page (ManageExternalAccountsContainer.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminExternalAccountsContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      externalAccounts: [],
+      totalAccounts: 0,
+      activePage: 1,
+      pagingLimit: Infinity,
+    };
+
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminExternalAccountsContainer';
+  }
+
+
+  /**
+   * syncExternalAccounts of selectedPage
+   * @memberOf AdminExternalAccountsContainer
+   * @param {number} selectedPage
+   */
+  async retrieveExternalAccountsByPagingNum(selectedPage) {
+
+    const params = { page: selectedPage };
+    const { data } = await this.appContainer.apiv3.get('/users/external-accounts', params);
+
+    if (data.paginateResult == null) {
+      throw new Error('data must conclude \'paginateResult\' property.');
+    }
+    const { docs: externalAccounts, totalDocs: totalAccounts, limit: pagingLimit } = data.paginateResult;
+    this.setState({
+      externalAccounts,
+      totalAccounts,
+      pagingLimit,
+      activePage: selectedPage,
+    });
+
+  }
+
+  /**
+   * remove external account
+   *
+   * @memberOf AdminExternalAccountsContainer
+   * @param {string} externalAccountId id of the External Account to be removed
+   */
+  async removeExternalAccountById(externalAccountId) {
+    const res = await this.appContainer.apiv3.delete(`/users/external-accounts/${externalAccountId}/remove`);
+    const deletedUserData = res.data.externalAccount;
+    await this.retrieveExternalAccountsByPagingNum(this.state.activePage);
+    return deletedUserData.accountId;
+  }
+
+}

+ 11 - 5
src/client/js/services/AdminUsersContainer.js

@@ -74,8 +74,9 @@ export default class AdminUsersContainer extends Container {
       shapedEmailList,
       sendEmail,
     });
-    const { emailList } = response.data;
-    return emailList;
+    await this.retrieveUsersByPagingNum(this.state.activePage);
+    const { invitedUserList } = response.data;
+    return invitedUserList;
   }
 
   /**
@@ -115,6 +116,7 @@ export default class AdminUsersContainer extends Container {
   async giveUserAdmin(userId) {
     const response = await this.appContainer.apiv3.put(`/users/${userId}/giveAdmin`);
     const { username } = response.data.userData;
+    await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;
   }
 
@@ -127,6 +129,7 @@ export default class AdminUsersContainer extends Container {
   async removeUserAdmin(userId) {
     const response = await this.appContainer.apiv3.put(`/users/${userId}/removeAdmin`);
     const { username } = response.data.userData;
+    await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;
   }
 
@@ -139,6 +142,7 @@ export default class AdminUsersContainer extends Container {
   async activateUser(userId) {
     const response = await this.appContainer.apiv3.put(`/users/${userId}/activate`);
     const { username } = response.data.userData;
+    await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;
   }
 
@@ -151,6 +155,7 @@ export default class AdminUsersContainer extends Container {
   async deactivateUser(userId) {
     const response = await this.appContainer.apiv3.put(`/users/${userId}/deactivate`);
     const { username } = response.data.userData;
+    await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;
   }
 
@@ -158,12 +163,13 @@ export default class AdminUsersContainer extends Container {
    * remove user
    * @memberOf AdminUsersContainer
    * @param {string} userId
-   * @return {string} username
+   * @return {object} removedUserData
    */
   async removeUser(userId) {
     const response = await this.appContainer.apiv3.delete(`/users/${userId}/remove`);
-    const { username } = response.data.userData;
-    return username;
+    const removedUserData = response.data.userData;
+    await this.retrieveUsersByPagingNum(this.state.activePage);
+    return removedUserData;
   }
 
 }

+ 21 - 0
src/linter-checker/test.js

@@ -0,0 +1,21 @@
+/*
+ * VSCode の Eslint 設定チェック方法
+ *
+ * 1. VSCode で以下のエラーが表示されていることを確認
+ *   - constructor で eslint(space-before-blocks)
+ *   - ファイル末尾の ";" で eslint(eol-last)
+ *
+ * 2. VSCode で上書き保存
+ *
+ * 3. 以下のように整形され、全てのエラーが消えていることを確認
+ *   - "constructor() {" のように間にスペースが入る
+ *   - ファイル末尾に空行が入る
+ *
+ */
+class EslintTest {
+  constructor(){
+    this.i = 0;
+  }
+}
+
+module.exports = EslintTest;

+ 23 - 0
src/linter-checker/test.scss

@@ -0,0 +1,23 @@
+/*
+ * VSCode の Stylelint 設定チェック方法
+ *
+ * 1. VSCode で以下のエラーが表示されていることを確認
+ *   - color で stylelint(order/properties-order)
+ *   - ul で stylelint(selector-combinator-space-after)
+ *
+ * 2. VSCode で上書き保存
+ *
+ * 3. 以下のように整形され、全てのエラーが消えていることを確認
+ *   - color が background の上の行にくる
+ *   - ul と li の間にスペースが入る
+ *
+ */
+
+.test {
+  background: #ccc;
+  color: #333;
+
+  ul>li {
+    margin-left: 0;
+  }
+}

+ 28 - 0
src/migrations/20191102223900-drop-configs-indices.js

@@ -0,0 +1,28 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:drop-configs-indices');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+async function dropIndexIfExists(collection, indexName) {
+  if (await collection.indexExists(indexName)) {
+    await collection.dropIndex(indexName);
+  }
+}
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const collection = db.collection('configs');
+    await dropIndexIfExists(collection, 'ns_1');
+    await dropIndexIfExists(collection, 'key_1');
+
+    logger.info('Migration has successfully applied');
+  },
+
+  down(db) {
+    // do not rollback
+  },
+};

+ 29 - 0
src/migrations/20191102223901-drop-pages-indices.js

@@ -0,0 +1,29 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:drop-pages-indices');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+async function dropIndexIfExists(collection, indexName) {
+  if (await collection.indexExists(indexName)) {
+    await collection.dropIndex(indexName);
+  }
+}
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const collection = db.collection('pages');
+    await dropIndexIfExists(collection, 'lastUpdateUser_1');
+    await dropIndexIfExists(collection, 'liker_1');
+    await dropIndexIfExists(collection, 'seenUsers_1');
+
+    logger.info('Migration has successfully applied');
+  },
+
+  down(db) {
+    // do not rollback
+  },
+};

+ 5 - 8
src/server/crowi/index.js

@@ -330,19 +330,16 @@ Crowi.prototype.setupPassport = async function() {
 };
 
 Crowi.prototype.setupSearcher = async function() {
-  const self = this;
-  const searcherUri = this.env.ELASTICSEARCH_URI
-    || this.env.BONSAI_URL
-    || null;
+  const SearchService = require('@server/service/search');
+  const searchService = new SearchService(this);
 
-  if (searcherUri) {
+  if (searchService.isAvailable) {
     try {
-      self.searcher = new (require(path.join(self.libDir, 'util', 'search')))(self, searcherUri);
-      self.searcher.initIndices();
+      this.searcher = searchService;
     }
     catch (e) {
       logger.error('Error on setup searcher', e);
-      self.searcher = null;
+      this.searcher = null;
     }
   }
 };

+ 3 - 1
src/server/models/attachment.js

@@ -6,6 +6,7 @@ const logger = require('@alias/logger')('growi:models:attachment');
 const path = require('path');
 
 const mongoose = require('mongoose');
+const uniqueValidator = require('mongoose-unique-validator');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
@@ -21,12 +22,13 @@ module.exports = function(crowi) {
     page: { type: ObjectId, ref: 'Page', index: true },
     creator: { type: ObjectId, ref: 'User', index: true },
     filePath: { type: String }, // DEPRECATED: remains for backward compatibility for v3.3.x or below
-    fileName: { type: String, required: true },
+    fileName: { type: String, required: true, unique: true },
     originalName: { type: String },
     fileFormat: { type: String, required: true },
     fileSize: { type: Number, default: 0 },
     createdAt: { type: Date, default: Date.now },
   });
+  attachmentSchema.plugin(uniqueValidator);
 
   attachmentSchema.virtual('filePathProxied').get(function() {
     return `/attachment/${this._id}`;

+ 7 - 4
src/server/models/bookmark.js

@@ -1,10 +1,12 @@
-// disable no-return-await for model functions
 /* eslint-disable no-return-await */
 
+const debug = require('debug')('growi:models:bookmark');
+const mongoose = require('mongoose');
+const uniqueValidator = require('mongoose-unique-validator');
+
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
 module.exports = function(crowi) {
-  const debug = require('debug')('growi:models:bookmark');
-  const mongoose = require('mongoose');
-  const ObjectId = mongoose.Schema.Types.ObjectId;
   const bookmarkEvent = crowi.event('bookmark');
 
   let bookmarkSchema = null;
@@ -16,6 +18,7 @@ module.exports = function(crowi) {
     createdAt: { type: Date, default: Date.now },
   });
   bookmarkSchema.index({ page: 1, user: 1 }, { unique: true });
+  bookmarkSchema.plugin(uniqueValidator);
 
   bookmarkSchema.statics.countByPageId = async function(pageId) {
     return await this.count({ page: pageId });

+ 8 - 7
src/server/models/config.js

@@ -1,21 +1,22 @@
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-
-/* eslint-disable no-use-before-define */
+const mongoose = require('mongoose');
+const uniqueValidator = require('mongoose-unique-validator');
 
 module.exports = function(crowi) {
-  const mongoose = require('mongoose');
 
   const configSchema = new mongoose.Schema({
-    ns: { type: String, required: true, index: true },
-    key: { type: String, required: true, index: true },
+    ns: { type: String, required: true },
+    key: { type: String, required: true },
     value: { type: String, required: true },
   });
+  // define unique compound index
+  configSchema.index({ ns: 1, key: 1 }, { unique: true });
+  configSchema.plugin(uniqueValidator);
 
   /**
    * default values when GROWI is cleanly installed
    */
   function getConfigsForInstalling() {
+    // eslint-disable-next-line no-use-before-define
     const config = getDefaultCrowiConfigs();
 
     // overwrite

+ 1 - 4
src/server/models/external-account.js

@@ -169,10 +169,7 @@ class ExternalAccount {
       options.limit = ExternalAccount.DEFAULT_LIMIT;
     }
 
-    return this.paginate(query, options)
-      .catch((err) => {
-        debug('Error on pagination:', err);
-      });
+    return this.paginate(query, options);
   }
 
 }

+ 5 - 0
src/server/models/page-tag-relation.js

@@ -5,6 +5,7 @@ const flatMap = require('array.prototype.flatmap');
 
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
+const uniqueValidator = require('mongoose-unique-validator');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
@@ -17,6 +18,7 @@ const schema = new mongoose.Schema({
     type: ObjectId,
     ref: 'Page',
     required: true,
+    index: true,
   },
   relatedTag: {
     type: ObjectId,
@@ -24,7 +26,10 @@ const schema = new mongoose.Schema({
     required: true,
   },
 });
+// define unique compound index
+schema.index({ page: 1, user: 1 }, { unique: true });
 schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
 
 /**
  * PageTagRelation Class

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

@@ -39,9 +39,9 @@ const pageSchema = new mongoose.Schema({
   grantedUsers: [{ type: ObjectId, ref: 'User' }],
   grantedGroup: { type: ObjectId, ref: 'UserGroup', index: true },
   creator: { type: ObjectId, ref: 'User', index: true },
-  lastUpdateUser: { type: ObjectId, ref: 'User', index: true },
-  liker: [{ type: ObjectId, ref: 'User', index: true }],
-  seenUsers: [{ type: ObjectId, ref: 'User', index: true }],
+  lastUpdateUser: { type: ObjectId, ref: 'User' },
+  liker: [{ type: ObjectId, ref: 'User' }],
+  seenUsers: [{ type: ObjectId, ref: 'User' }],
   commentCount: { type: Number, default: 0 },
   extended: {
     type: String,

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

@@ -3,6 +3,7 @@
 
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
+const uniqueValidator = require('mongoose-unique-validator');
 
 /*
  * define schema
@@ -15,6 +16,7 @@ const schema = new mongoose.Schema({
   },
 });
 schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
 
 /**
  * Tag Class

+ 3 - 0
src/server/models/user-group-relation.js

@@ -1,6 +1,7 @@
 const debug = require('debug')('growi:models:userGroupRelation');
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
+const uniqueValidator = require('mongoose-unique-validator');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
@@ -14,6 +15,8 @@ const schema = new mongoose.Schema({
   createdAt: { type: Date, default: Date.now, required: true },
 });
 schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
+
 
 /**
  * UserGroupRelation Class

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

@@ -44,14 +44,14 @@ module.exports = function(crowi) {
     name: { type: String },
     username: { type: String, required: true, unique: true },
     email: { type: String, unique: true, sparse: true },
-    // === The official settings
+    // === Crowi settings
     // username: { type: String, index: true },
     // email: { type: String, required: true, index: true },
     // === crowi-plus (>= 2.1.0, <2.3.0) settings
     // email: { type: String, required: true, unique: true },
-    introduction: { type: String },
+    introduction: String,
     password: String,
-    apiToken: String,
+    apiToken: { type: String, index: true },
     lang: {
       type: String,
       // eslint-disable-next-line no-eval
@@ -627,7 +627,7 @@ module.exports = function(crowi) {
   userSchema.statics.createUsersByEmailList = async function(emailList) {
     const User = this;
 
-    // check exists and get list of tyr to create
+    // check exists and get list of try to create
     const existingUserList = await User.find({ email: { $in: emailList }, userStatus: { $ne: STATUS_DELETED } });
     const existingEmailList = existingUserList.map((user) => { return user.email });
     const creationEmailList = emailList.filter((email) => { return existingEmailList.indexOf(email) === -1 });

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

@@ -447,17 +447,7 @@ module.exports = function(crowi, app) {
 
   actions.externalAccount = {};
   actions.externalAccount.index = function(req, res) {
-    const page = parseInt(req.query.page) || 1;
-
-    ExternalAccount.findAllWithPagination({ page })
-      .then((result) => {
-        const pager = createPager(result.total, result.limit, result.page, result.pages, MAX_PAGE_LIST);
-
-        return res.render('admin/external-accounts', {
-          accounts: result.docs,
-          pager,
-        });
-      });
+    return res.render('admin/external-accounts');
   };
 
   actions.externalAccount.remove = async function(req, res) {

+ 74 - 2
src/server/routes/apiv3/users.js

@@ -119,8 +119,8 @@ module.exports = (crowi) => {
    */
   router.post('/invite', loginRequiredStrictly, adminRequired, csrf, validator.inviteEmail, ApiV3FormValidator, async(req, res) => {
     try {
-      const emailList = await User.createUsersByInvitation(req.body.shapedEmailList, req.body.sendEmail);
-      return res.apiv3({ emailList });
+      const invitedUserList = await User.createUsersByInvitation(req.body.shapedEmailList, req.body.sendEmail);
+      return res.apiv3({ invitedUserList });
     }
     catch (err) {
       logger.error('Error', err);
@@ -334,5 +334,77 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/users:
+   *      get:
+   *        tags: [Users]
+   *        description: Get external-account
+   *        responses:
+   *          200:
+   *            description: external-account are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    paginateResult:
+   *                      $ref: '#/components/schemas/PaginateResult'
+   */
+  router.get('/external-accounts/', loginRequiredStrictly, adminRequired, async(req, res) => {
+    const page = parseInt(req.query.page) || 1;
+    try {
+      const paginateResult = await ExternalAccount.findAllWithPagination({ page });
+      return res.apiv3({ paginateResult });
+    }
+    catch (err) {
+      const msg = 'Error occurred in fetching external-account list  ';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg + err.message, 'external-account-list-fetch-failed'), 500);
+    }
+  });
+
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/users/external-accounts/{id}/remove:
+   *      delete:
+   *        tags: [Users]
+   *        description: Delete ExternalAccount
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of ExternalAccount
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description:  External Account is removed
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    externalAccount:
+   *                      type: object
+   *                      description: A result of `ExtenralAccount.findByIdAndRemove`
+   */
+  router.delete('/external-accounts/:id/remove', loginRequiredStrictly, adminRequired, ApiV3FormValidator, async(req, res) => {
+    const { id } = req.params;
+
+    try {
+      const externalAccount = await ExternalAccount.findByIdAndRemove(id);
+
+      return res.apiv3({ externalAccount });
+    }
+    catch (err) {
+      const msg = 'Error occurred in deleting a external account  ';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg + err.message, 'extenral-account-delete-failed'));
+    }
+  });
   return router;
 };

+ 22 - 10
src/server/routes/attachment.js

@@ -47,21 +47,32 @@ module.exports = function(crowi, app) {
   /**
    * Common method to response
    *
+   * @param {Request} req
    * @param {Response} res
    * @param {User} user
    * @param {Attachment} attachment
    * @param {boolean} forceDownload
    */
-  async function responseForAttachment(res, user, attachment, forceDownload) {
+  async function responseForAttachment(req, res, attachment, forceDownload) {
     if (attachment == null) {
       return res.json(ApiResponse.error('attachment not found'));
     }
 
+    const user = req.user;
     const isAccessible = await isAccessibleByViewer(user, attachment);
     if (!isAccessible) {
       return res.json(ApiResponse.error(`Forbidden to access to the attachment '${attachment.id}'`));
     }
 
+    // add headers before evaluating 'req.fresh'
+    setHeaderToRes(res, attachment, forceDownload);
+
+    // return 304 if request is "fresh"
+    // see: http://expressjs.com/en/5x/api.html#req.fresh
+    if (req.fresh) {
+      return res.sendStatus(304);
+    }
+
     let fileStream;
     try {
       fileStream = await fileUploader.findDeliveryFile(attachment);
@@ -71,7 +82,6 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error(e.message));
     }
 
-    setHeaderToRes(res, attachment, forceDownload);
     return fileStream.pipe(res);
   }
 
@@ -83,14 +93,16 @@ module.exports = function(crowi, app) {
    * @param {boolean} forceDownload
    */
   function setHeaderToRes(res, attachment, forceDownload) {
+    res.set({
+      ETag: `Attachment-${attachment._id}`,
+      'Last-Modified': attachment.createdAt,
+    });
+
     // download
     if (forceDownload) {
-      const headers = {
-        'Content-Type': 'application/force-download',
-        'Content-Disposition': `inline;filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
-      };
-
-      res.writeHead(200, headers);
+      res.set({
+        'Content-Disposition': `attachment;filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
+      });
     }
     // reference
     else {
@@ -134,7 +146,7 @@ module.exports = function(crowi, app) {
 
     const attachment = await Attachment.findById(id);
 
-    return responseForAttachment(res, req.user, attachment, true);
+    return responseForAttachment(req, res, attachment, true);
   };
 
   /**
@@ -149,7 +161,7 @@ module.exports = function(crowi, app) {
 
     const attachment = await Attachment.findById(id);
 
-    return responseForAttachment(res, req.user, attachment);
+    return responseForAttachment(req, res, attachment);
   };
 
   /**

+ 7 - 4
src/server/routes/comment.js

@@ -121,10 +121,13 @@ module.exports = function(crowi, app) {
     }
 
     // update page
-    const page = await Page.findOneAndUpdate({ _id: pageId }, {
-      lastUpdateUser: req.user,
-      updatedAt: new Date(),
-    });
+    const page = await Page.findOneAndUpdate(
+      { _id: pageId },
+      {
+        lastUpdateUser: req.user,
+        updatedAt: new Date(),
+      },
+    );
 
     res.json(ApiResponse.success({ comment: createdComment }));
 

+ 0 - 2
src/server/routes/installer.js

@@ -17,9 +17,7 @@ module.exports = function(crowi, app) {
       return;
     }
 
-    await search.deleteIndex();
     await search.buildIndex();
-    await search.addAllPages();
   }
 
   async function createInitialPages(owner, lang) {

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

@@ -22,12 +22,6 @@ const TYPES = {
  *  So, parameters of these are under consideration.
  */
 const ENV_VAR_NAME_TO_CONFIG_INFO = {
-  // ELASTICSEARCH_URI: {
-  //   ns:      ,
-  //   key:     ,
-  //   type:    ,
-  //   default:
-  // },
   // FILE_UPLOAD: {
   //   ns:      ,
   //   key:     ,
@@ -136,6 +130,18 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.NUMBER,
     default: Infinity,
   },
+  ELASTICSEARCH_URI: {
+    ns:      'crowi',
+    key:     'app:elasticsearchUri',
+    type:    TYPES.STRING,
+    default: null,
+  },
+  SEARCHBOX_SSL_URL: {
+    ns:      'crowi',
+    key:     'app:searchboxSslUrl',
+    type:    TYPES.STRING,
+    default: null,
+  },
   MONGO_GRIDFS_TOTAL_LIMIT: {
     ns:      'crowi',
     key:     'gridfs:totalLimit',

+ 922 - 0
src/server/service/search-delegator/elasticsearch.js

@@ -0,0 +1,922 @@
+const logger = require('@alias/logger')('growi:service:search-delegator:elasticsearch');
+const elasticsearch = require('elasticsearch');
+const mongoose = require('mongoose');
+
+const { URL } = require('url');
+
+const {
+  Writable, Transform,
+} = require('stream');
+const streamToPromise = require('stream-to-promise');
+
+const { createBatchStream } = require('@server/util/batch-stream');
+
+const DEFAULT_OFFSET = 0;
+const DEFAULT_LIMIT = 50;
+const BULK_REINDEX_SIZE = 100;
+
+class ElasticsearchDelegator {
+
+  constructor(configManager, searchEvent) {
+    this.configManager = configManager;
+    this.searchEvent = searchEvent;
+
+    this.esNodeName = '-';
+    this.esNodeNames = [];
+    this.esVersion = 'unknown';
+    this.esVersions = [];
+    this.esPlugin = [];
+    this.esPlugins = [];
+
+    this.client = null;
+
+    // In Elasticsearch RegExp, we don't need to used ^ and $.
+    // Ref: https://www.elastic.co/guide/en/elasticsearch/reference/5.6/query-dsl-regexp-query.html#_standard_operators
+    this.queries = {
+      PORTAL: {
+        regexp: {
+          'path.raw': '.*/',
+        },
+      },
+      PUBLIC: {
+        regexp: {
+          'path.raw': '.*[^/]',
+        },
+      },
+      USER: {
+        prefix: {
+          'path.raw': '/user/',
+        },
+      },
+    };
+
+    this.initClient();
+  }
+
+  get aliasName() {
+    return `${this.indexName}-alias`;
+  }
+
+  shouldIndexed(page) {
+    return page.creator != null && page.revision != null && page.redirectTo == null;
+  }
+
+  initClient() {
+    const { host, httpAuth, indexName } = this.getConnectionInfo();
+    this.client = new elasticsearch.Client({
+      host,
+      httpAuth,
+      requestTimeout: 5000,
+      // log: 'debug',
+    });
+    this.indexName = indexName;
+  }
+
+  /**
+   * return information object to connect to ES
+   * @return {object} { host, httpAuth, indexName}
+   */
+  getConnectionInfo() {
+    let indexName = 'crowi';
+    let host = this.esUri;
+    let httpAuth = '';
+
+    const elasticsearchUri = this.configManager.getConfig('crowi', 'app:elasticsearchUri');
+
+    const url = new URL(elasticsearchUri);
+    if (url.pathname !== '/') {
+      host = `${url.protocol}//${url.host}`;
+      indexName = url.pathname.substring(1); // omit heading slash
+
+      if (url.auth != null) {
+        httpAuth = url.auth;
+      }
+    }
+
+    return {
+      host,
+      httpAuth,
+      indexName,
+    };
+  }
+
+  async init() {
+    return this.initIndices();
+  }
+
+  /**
+   * build index
+   */
+  async buildIndex() {
+    const { client, indexName, aliasName } = this;
+
+    const tmpIndexName = `${indexName}-tmp`;
+
+    // reindex to tmp index
+    await this.createIndex(tmpIndexName);
+    await client.reindex({
+      waitForCompletion: false,
+      body: {
+        source: { index: indexName },
+        dest: { index: tmpIndexName },
+      },
+    });
+
+    // update alias
+    await client.indices.updateAliases({
+      body: {
+        actions: [
+          { add: { alias: aliasName, index: tmpIndexName } },
+          { remove: { alias: aliasName, index: indexName } },
+        ],
+      },
+    });
+
+    // flush index
+    await client.indices.delete({
+      index: indexName,
+    });
+    await this.createIndex(indexName);
+    await this.addAllPages();
+
+    // update alias
+    await client.indices.updateAliases({
+      body: {
+        actions: [
+          { add: { alias: aliasName, index: indexName } },
+          { remove: { alias: aliasName, index: tmpIndexName } },
+        ],
+      },
+    });
+
+    // remove tmp index
+    await client.indices.delete({ index: tmpIndexName });
+  }
+
+  /**
+   * retrieve elasticsearch node information
+   */
+  async checkESVersion() {
+    try {
+      const nodes = await this.client.nodes.info();
+      if (!nodes._nodes || !nodes.nodes) {
+        throw new Error('no nodes info');
+      }
+
+      for (const [nodeName, nodeInfo] of Object.entries(nodes.nodes)) {
+        this.esNodeName = nodeName;
+        this.esNodeNames.push(nodeName);
+        this.esVersion = nodeInfo.version;
+        this.esVersions.push(nodeInfo.version);
+        this.esPlugin = nodeInfo.plugins;
+        this.esPlugins.push(nodeInfo.plugins);
+      }
+    }
+    catch (error) {
+      logger.error('Couldn\'t check ES version:', error);
+    }
+  }
+
+  async initIndices() {
+    await this.checkESVersion();
+
+    const { client, indexName, aliasName } = this;
+
+    const tmpIndexName = `${indexName}-tmp`;
+
+    // remove tmp index
+    const isExistsTmpIndex = await client.indices.exists({ index: tmpIndexName });
+    if (isExistsTmpIndex) {
+      await client.indices.delete({ index: tmpIndexName });
+    }
+
+    // create index
+    const isExistsIndex = await client.indices.exists({ index: indexName });
+    if (!isExistsIndex) {
+      await this.createIndex(indexName);
+    }
+
+    // create alias
+    const isExistsAlias = await client.indices.existsAlias({ name: aliasName, index: indexName });
+    if (!isExistsAlias) {
+      await client.indices.putAlias({
+        name: aliasName,
+        index: indexName,
+      });
+    }
+  }
+
+  async createIndex(index) {
+    const body = require('@root/resource/search/mappings.json');
+    return this.client.indices.create({ index, body });
+  }
+
+  /**
+   * generate object that is related to page.grant*
+   */
+  generateDocContentsRelatedToRestriction(page) {
+    let grantedUserIds = null;
+    if (page.grantedUsers != null && page.grantedUsers.length > 0) {
+      grantedUserIds = page.grantedUsers.map((user) => {
+        const userId = (user._id == null) ? user : user._id;
+        return userId.toString();
+      });
+    }
+
+    let grantedGroupId = null;
+    if (page.grantedGroup != null) {
+      const groupId = (page.grantedGroup._id == null) ? page.grantedGroup : page.grantedGroup._id;
+      grantedGroupId = groupId.toString();
+    }
+
+    return {
+      grant: page.grant,
+      granted_users: grantedUserIds,
+      granted_group: grantedGroupId,
+    };
+  }
+
+  prepareBodyForCreate(body, page) {
+    if (!Array.isArray(body)) {
+      throw new Error('Body must be an array.');
+    }
+
+    const command = {
+      index: {
+        _index: this.indexName,
+        _type: 'pages',
+        _id: page._id.toString(),
+      },
+    };
+
+    const bookmarkCount = page.bookmarkCount || 0;
+    let document = {
+      path: page.path,
+      body: page.revision.body,
+      username: page.creator.username,
+      comment_count: page.commentCount,
+      bookmark_count: bookmarkCount,
+      like_count: page.liker.length || 0,
+      created_at: page.createdAt,
+      updated_at: page.updatedAt,
+      tag_names: page.tagNames,
+    };
+
+    document = Object.assign(document, this.generateDocContentsRelatedToRestriction(page));
+
+    body.push(command);
+    body.push(document);
+  }
+
+  prepareBodyForDelete(body, page) {
+    if (!Array.isArray(body)) {
+      throw new Error('Body must be an array.');
+    }
+
+    const command = {
+      delete: {
+        _index: this.indexName,
+        _type: 'pages',
+        _id: page._id.toString(),
+      },
+    };
+
+    body.push(command);
+  }
+
+  addAllPages() {
+    const Page = mongoose.model('Page');
+    return this.updateOrInsertPages(() => Page.find(), true);
+  }
+
+  updateOrInsertPageById(pageId) {
+    const Page = mongoose.model('Page');
+    return this.updateOrInsertPages(() => Page.findById(pageId));
+  }
+
+  /**
+   * @param {function} queryFactory factory method to generate a Mongoose Query instance
+   */
+  async updateOrInsertPages(queryFactory, isEmittingProgressEvent = false) {
+    const Page = mongoose.model('Page');
+    const { PageQueryBuilder } = Page;
+    const Bookmark = mongoose.model('Bookmark');
+    const PageTagRelation = mongoose.model('PageTagRelation');
+
+    const { searchEvent } = this;
+
+    // prepare functions invoked from custom streams
+    const prepareBodyForCreate = this.prepareBodyForCreate.bind(this);
+    const shouldIndexed = this.shouldIndexed.bind(this);
+    const bulkWrite = this.client.bulk.bind(this.client);
+
+    const findQuery = new PageQueryBuilder(queryFactory()).addConditionToExcludeRedirect().query;
+    const countQuery = new PageQueryBuilder(queryFactory()).addConditionToExcludeRedirect().query;
+
+    const totalCount = await countQuery.count();
+
+    const readStream = findQuery
+      // populate data which will be referenced by prepareBodyForCreate()
+      .populate([
+        { path: 'creator', model: 'User', select: 'username' },
+        { path: 'revision', model: 'Revision', select: 'body' },
+      ])
+      .snapshot()
+      .lean()
+      .cursor();
+
+    let skipped = 0;
+    const thinOutStream = new Transform({
+      objectMode: true,
+      async transform(doc, encoding, callback) {
+        if (shouldIndexed(doc)) {
+          this.push(doc);
+        }
+        else {
+          skipped++;
+        }
+        callback();
+      },
+    });
+
+    const batchStream = createBatchStream(BULK_REINDEX_SIZE);
+
+    const appendBookmarkCountStream = new Transform({
+      objectMode: true,
+      async transform(chunk, encoding, callback) {
+        const pageIds = chunk.map(doc => doc._id);
+
+        const idToCountMap = await Bookmark.getPageIdToCountMap(pageIds);
+        const idsHavingCount = Object.keys(idToCountMap);
+
+        // append count
+        chunk
+          .filter(doc => idsHavingCount.includes(doc._id.toString()))
+          .forEach((doc) => {
+            // append count from idToCountMap
+            doc.bookmarkCount = idToCountMap[doc._id.toString()];
+          });
+
+        this.push(chunk);
+        callback();
+      },
+    });
+
+    const appendTagNamesStream = new Transform({
+      objectMode: true,
+      async transform(chunk, encoding, callback) {
+        const pageIds = chunk.map(doc => doc._id);
+
+        const idToTagNamesMap = await PageTagRelation.getIdToTagNamesMap(pageIds);
+        const idsHavingTagNames = Object.keys(idToTagNamesMap);
+
+        // append tagNames
+        chunk
+          .filter(doc => idsHavingTagNames.includes(doc._id.toString()))
+          .forEach((doc) => {
+            // append tagName from idToTagNamesMap
+            doc.tagNames = idToTagNamesMap[doc._id.toString()];
+          });
+
+        this.push(chunk);
+        callback();
+      },
+    });
+
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        const body = [];
+        batch.forEach(doc => prepareBodyForCreate(body, doc));
+
+        try {
+          const res = await bulkWrite({
+            body,
+            requestTimeout: Infinity,
+          });
+
+          count += (res.items || []).length;
+
+          logger.info(`Adding pages progressing: (count=${count}, errors=${res.errors}, took=${res.took}ms)`);
+
+          if (isEmittingProgressEvent) {
+            searchEvent.emit('addPageProgress', totalCount, count, skipped);
+          }
+        }
+        catch (err) {
+          logger.error('addAllPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.info(`Adding pages has terminated: (totalCount=${totalCount}, skipped=${skipped})`);
+
+        if (isEmittingProgressEvent) {
+          searchEvent.emit('finishAddPage', totalCount, count, skipped);
+        }
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(thinOutStream)
+      .pipe(batchStream)
+      .pipe(appendBookmarkCountStream)
+      .pipe(appendTagNamesStream)
+      .pipe(writeStream);
+
+    return streamToPromise(writeStream);
+
+  }
+
+  deletePages(pages) {
+    const self = this;
+    const body = [];
+
+    pages.map((page) => {
+      self.prepareBodyForDelete(body, page);
+      return;
+    });
+
+    logger.debug('deletePages(): Sending Request to ES', body);
+    return this.client.bulk({
+      body,
+    });
+  }
+
+  /**
+   * search returning type:
+   * {
+   *   meta: { total: Integer, results: Integer},
+   *   data: [ pages ...],
+   * }
+   */
+  async search(query) {
+    // for debug
+    if (process.env.NODE_ENV === 'development') {
+      const result = await this.client.indices.validateQuery({
+        explain: true,
+        body: {
+          query: query.body.query,
+        },
+      });
+      logger.debug('ES returns explanations: ', result.explanations);
+    }
+
+    const result = await this.client.search(query);
+
+    // for debug
+    logger.debug('ES result: ', result);
+
+    return {
+      meta: {
+        took: result.took,
+        total: result.hits.total,
+        results: result.hits.hits.length,
+      },
+      data: result.hits.hits.map((elm) => {
+        return { _id: elm._id, _score: elm._score, _source: elm._source };
+      }),
+    };
+  }
+
+  createSearchQuerySortedByUpdatedAt(option) {
+    // getting path by default is almost for debug
+    let fields = ['path', 'bookmark_count', 'tag_names'];
+    if (option) {
+      fields = option.fields || fields;
+    }
+
+    // default is only id field, sorted by updated_at
+    const query = {
+      index: this.aliasName,
+      type: 'pages',
+      body: {
+        sort: [{ updated_at: { order: 'desc' } }],
+        query: {}, // query
+        _source: fields,
+      },
+    };
+    this.appendResultSize(query);
+
+    return query;
+  }
+
+  createSearchQuerySortedByScore(option) {
+    let fields = ['path', 'bookmark_count', 'tag_names'];
+    if (option) {
+      fields = option.fields || fields;
+    }
+
+    // sort by score
+    const query = {
+      index: this.aliasName,
+      type: 'pages',
+      body: {
+        sort: [{ _score: { order: 'desc' } }],
+        query: {}, // query
+        _source: fields,
+      },
+    };
+    this.appendResultSize(query);
+
+    return query;
+  }
+
+  appendResultSize(query, from, size) {
+    query.from = from || DEFAULT_OFFSET;
+    query.size = size || DEFAULT_LIMIT;
+  }
+
+  initializeBoolQuery(query) {
+    // query is created by createSearchQuerySortedByScore() or createSearchQuerySortedByUpdatedAt()
+    if (!query.body.query.bool) {
+      query.body.query.bool = {};
+    }
+
+    const isInitialized = (query) => { return !!query && Array.isArray(query) };
+
+    if (!isInitialized(query.body.query.bool.filter)) {
+      query.body.query.bool.filter = [];
+    }
+    if (!isInitialized(query.body.query.bool.must)) {
+      query.body.query.bool.must = [];
+    }
+    if (!isInitialized(query.body.query.bool.must_not)) {
+      query.body.query.bool.must_not = [];
+    }
+    return query;
+  }
+
+  appendCriteriaForQueryString(query, queryString) {
+    query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
+
+    // parse
+    const parsedKeywords = this.parseQueryString(queryString);
+
+    if (parsedKeywords.match.length > 0) {
+      const q = {
+        multi_match: {
+          query: parsedKeywords.match.join(' '),
+          type: 'most_fields',
+          fields: ['path.ja^2', 'path.en^2', 'body.ja', 'body.en'],
+        },
+      };
+      query.body.query.bool.must.push(q);
+    }
+
+    if (parsedKeywords.not_match.length > 0) {
+      const q = {
+        multi_match: {
+          query: parsedKeywords.not_match.join(' '),
+          fields: ['path.ja', 'path.en', 'body.ja', 'body.en'],
+          operator: 'or',
+        },
+      };
+      query.body.query.bool.must_not.push(q);
+    }
+
+    if (parsedKeywords.phrase.length > 0) {
+      const phraseQueries = [];
+      parsedKeywords.phrase.forEach((phrase) => {
+        phraseQueries.push({
+          multi_match: {
+            query: phrase, // each phrase is quoteted words
+            type: 'phrase',
+            fields: [
+              // Not use "*.ja" fields here, because we want to analyze (parse) search words
+              'path.raw^2',
+              'body',
+            ],
+          },
+        });
+      });
+
+      query.body.query.bool.must.push(phraseQueries);
+    }
+
+    if (parsedKeywords.not_phrase.length > 0) {
+      const notPhraseQueries = [];
+      parsedKeywords.not_phrase.forEach((phrase) => {
+        notPhraseQueries.push({
+          multi_match: {
+            query: phrase, // each phrase is quoteted words
+            type: 'phrase',
+            fields: [
+              // Not use "*.ja" fields here, because we want to analyze (parse) search words
+              'path.raw^2',
+              'body',
+            ],
+          },
+        });
+      });
+
+      query.body.query.bool.must_not.push(notPhraseQueries);
+    }
+
+    if (parsedKeywords.prefix.length > 0) {
+      const queries = parsedKeywords.prefix.map((path) => {
+        return { prefix: { 'path.raw': path } };
+      });
+      query.body.query.bool.filter.push({ bool: { should: queries } });
+    }
+
+    if (parsedKeywords.not_prefix.length > 0) {
+      const queries = parsedKeywords.not_prefix.map((path) => {
+        return { prefix: { 'path.raw': path } };
+      });
+      query.body.query.bool.filter.push({ bool: { must_not: queries } });
+    }
+
+    if (parsedKeywords.tag.length > 0) {
+      const queries = parsedKeywords.tag.map((tag) => {
+        return { term: { tag_names: tag } };
+      });
+      query.body.query.bool.filter.push({ bool: { must: queries } });
+    }
+
+    if (parsedKeywords.not_tag.length > 0) {
+      const queries = parsedKeywords.not_tag.map((tag) => {
+        return { term: { tag_names: tag } };
+      });
+      query.body.query.bool.filter.push({ bool: { must_not: queries } });
+    }
+  }
+
+  async filterPagesByViewer(query, user, userGroups) {
+    const showPagesRestrictedByOwner = !this.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner');
+    const showPagesRestrictedByGroup = !this.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup');
+
+    query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
+
+    const Page = mongoose.model('Page');
+    const {
+      GRANT_PUBLIC, GRANT_RESTRICTED, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP,
+    } = Page;
+
+    const grantConditions = [
+      { term: { grant: GRANT_PUBLIC } },
+    ];
+
+    // ensure to hit to GRANT_RESTRICTED pages that the user specified at own
+    if (user != null) {
+      grantConditions.push(
+        {
+          bool: {
+            must: [
+              { term: { grant: GRANT_RESTRICTED } },
+              { term: { granted_users: user._id.toString() } },
+            ],
+          },
+        },
+      );
+    }
+
+    if (showPagesRestrictedByOwner) {
+      grantConditions.push(
+        { term: { grant: GRANT_SPECIFIED } },
+        { term: { grant: GRANT_OWNER } },
+      );
+    }
+    else if (user != null) {
+      grantConditions.push(
+        {
+          bool: {
+            must: [
+              { term: { grant: GRANT_SPECIFIED } },
+              { term: { granted_users: user._id.toString() } },
+            ],
+          },
+        },
+        {
+          bool: {
+            must: [
+              { term: { grant: GRANT_OWNER } },
+              { term: { granted_users: user._id.toString() } },
+            ],
+          },
+        },
+      );
+    }
+
+    if (showPagesRestrictedByGroup) {
+      grantConditions.push(
+        { term: { grant: GRANT_USER_GROUP } },
+      );
+    }
+    else if (userGroups != null && userGroups.length > 0) {
+      const userGroupIds = userGroups.map((group) => { return group._id.toString() });
+      grantConditions.push(
+        {
+          bool: {
+            must: [
+              { term: { grant: GRANT_USER_GROUP } },
+              { terms: { granted_group: userGroupIds } },
+            ],
+          },
+        },
+      );
+    }
+
+    query.body.query.bool.filter.push({ bool: { should: grantConditions } });
+  }
+
+  filterPortalPages(query) {
+    query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
+
+    query.body.query.bool.must_not.push(this.queries.USER);
+    query.body.query.bool.filter.push(this.queries.PORTAL);
+  }
+
+  filterPublicPages(query) {
+    query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
+
+    query.body.query.bool.must_not.push(this.queries.USER);
+    query.body.query.bool.filter.push(this.queries.PUBLIC);
+  }
+
+  filterUserPages(query) {
+    query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
+
+    query.body.query.bool.filter.push(this.queries.USER);
+  }
+
+  filterPagesByType(query, type) {
+    const Page = mongoose.model('Page');
+
+    switch (type) {
+      case Page.TYPE_PORTAL:
+        return this.filterPortalPages(query);
+      case Page.TYPE_PUBLIC:
+        return this.filterPublicPages(query);
+      case Page.TYPE_USER:
+        return this.filterUserPages(query);
+      default:
+        return query;
+    }
+  }
+
+  appendFunctionScore(query, queryString) {
+    const User = mongoose.model('User');
+    const count = User.count({}) || 1;
+
+    const minScore = queryString.length * 0.1 - 1; // increase with length
+    logger.debug('min_score: ', minScore);
+
+    query.body.query = {
+      function_score: {
+        query: { ...query.body.query },
+        // // disable min_score -- 2019.02.28 Yuki Takei
+        // // more precise adjustment is needed...
+        // min_score: minScore,
+        field_value_factor: {
+          field: 'bookmark_count',
+          modifier: 'log1p',
+          factor: 10000 / count,
+          missing: 0,
+        },
+        boost_mode: 'sum',
+      },
+    };
+  }
+
+  async searchKeyword(queryString, user, userGroups, option) {
+    const from = option.offset || null;
+    const size = option.limit || null;
+    const type = option.type || null;
+    const query = this.createSearchQuerySortedByScore();
+    this.appendCriteriaForQueryString(query, queryString);
+
+    this.filterPagesByType(query, type);
+    await this.filterPagesByViewer(query, user, userGroups);
+
+    this.appendResultSize(query, from, size);
+
+    this.appendFunctionScore(query, queryString);
+
+    return this.search(query);
+  }
+
+  parseQueryString(queryString) {
+    const matchWords = [];
+    const notMatchWords = [];
+    const phraseWords = [];
+    const notPhraseWords = [];
+    const prefixPaths = [];
+    const notPrefixPaths = [];
+    const tags = [];
+    const notTags = [];
+
+    queryString.trim();
+    queryString = queryString.replace(/\s+/g, ' '); // eslint-disable-line no-param-reassign
+
+    // First: Parse phrase keywords
+    const phraseRegExp = new RegExp(/(-?"[^"]+")/g);
+    const phrases = queryString.match(phraseRegExp);
+
+    if (phrases !== null) {
+      queryString = queryString.replace(phraseRegExp, ''); // eslint-disable-line no-param-reassign
+
+      phrases.forEach((phrase) => {
+        phrase.trim();
+        if (phrase.match(/^-/)) {
+          notPhraseWords.push(phrase.replace(/^-/, ''));
+        }
+        else {
+          phraseWords.push(phrase);
+        }
+      });
+    }
+
+    // Second: Parse other keywords (include minus keywords)
+    queryString.split(' ').forEach((word) => {
+      if (word === '') {
+        return;
+      }
+
+      // https://regex101.com/r/pN9XfK/1
+      const matchNegative = word.match(/^-(prefix:|tag:)?(.+)$/);
+      // https://regex101.com/r/3qw9FQ/1
+      const matchPositive = word.match(/^(prefix:|tag:)?(.+)$/);
+
+      if (matchNegative != null) {
+        if (matchNegative[1] === 'prefix:') {
+          notPrefixPaths.push(matchNegative[2]);
+        }
+        else if (matchNegative[1] === 'tag:') {
+          notTags.push(matchNegative[2]);
+        }
+        else {
+          notMatchWords.push(matchNegative[2]);
+        }
+      }
+      else if (matchPositive != null) {
+        if (matchPositive[1] === 'prefix:') {
+          prefixPaths.push(matchPositive[2]);
+        }
+        else if (matchPositive[1] === 'tag:') {
+          tags.push(matchPositive[2]);
+        }
+        else {
+          matchWords.push(matchPositive[2]);
+        }
+      }
+    });
+
+    return {
+      match: matchWords,
+      not_match: notMatchWords,
+      phrase: phraseWords,
+      not_phrase: notPhraseWords,
+      prefix: prefixPaths,
+      not_prefix: notPrefixPaths,
+      tag: tags,
+      not_tag: notTags,
+    };
+  }
+
+  async syncPageUpdated(page, user) {
+    logger.debug('SearchClient.syncPageUpdated', page.path);
+
+    // delete if page should not indexed
+    if (!this.shouldIndexed(page)) {
+      try {
+        await this.deletePages([page]);
+      }
+      catch (err) {
+        logger.error('deletePages:ES Error', err);
+      }
+      return;
+    }
+
+    return this.updateOrInsertPageById(page._id);
+  }
+
+  async syncPageDeleted(page, user) {
+    logger.debug('SearchClient.syncPageDeleted', page.path);
+
+    try {
+      return await this.deletePages([page]);
+    }
+    catch (err) {
+      logger.error('deletePages:ES Error', err);
+    }
+  }
+
+  async syncBookmarkChanged(pageId) {
+    logger.debug('SearchClient.syncBookmarkChanged', pageId);
+
+    return this.updateOrInsertPageById(pageId);
+  }
+
+  async syncTagChanged(page) {
+    logger.debug('SearchClient.syncTagChanged', page.path);
+
+    return this.updateOrInsertPageById(page._id);
+  }
+
+}
+
+module.exports = ElasticsearchDelegator;

+ 47 - 0
src/server/service/search-delegator/searchbox.js

@@ -0,0 +1,47 @@
+// eslint-disable-next-line no-unused-vars
+const logger = require('@alias/logger')('growi:service:search-delegator:searchbox');
+
+const ElasticsearchDelegator = require('./elasticsearch');
+
+class SearchboxDelegator extends ElasticsearchDelegator {
+
+  /**
+   * @inheritdoc
+   */
+  getConnectionInfo() {
+    const searchboxSslUrl = this.configManager.getConfig('crowi', 'app:searchboxSslUrl');
+    const url = new URL(searchboxSslUrl);
+
+    const indexName = 'crowi';
+    const host = `${url.protocol}//${url.username}:${url.password}@${url.host}:443`;
+
+    return {
+      host,
+      httpAuth: '',
+      indexName,
+    };
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async buildIndex() {
+    const { client, indexName, aliasName } = this;
+
+    // flush index
+    await client.indices.delete({
+      index: indexName,
+    });
+    await this.createIndex(indexName);
+    await this.addAllPages();
+
+    // put alias
+    await client.indices.putAlias({
+      name: aliasName,
+      index: indexName,
+    });
+  }
+
+}
+
+module.exports = SearchboxDelegator;

+ 81 - 0
src/server/service/search.js

@@ -0,0 +1,81 @@
+// eslint-disable-next-line no-unused-vars
+const logger = require('@alias/logger')('growi:service:search');
+
+class SearchService {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.configManager = crowi.configManager;
+
+    try {
+      this.delegator = this.initDelegator();
+    }
+    catch (err) {
+      logger.error(err);
+    }
+
+    if (this.isAvailable) {
+      this.delegator.init();
+      this.registerUpdateEvent();
+    }
+  }
+
+  get isAvailable() {
+    return this.delegator != null;
+  }
+
+  get isSearchboxEnabled() {
+    return this.configManager.getConfig('crowi', 'app:searchboxSslUrl') != null;
+  }
+
+  get isElasticsearchEnabled() {
+    return this.configManager.getConfig('crowi', 'app:elasticsearchUri') != null;
+  }
+
+  initDelegator() {
+    logger.info('Initializing search delegator');
+
+    const searchEvent = this.crowi.event('search');
+
+    if (this.isSearchboxEnabled) {
+      logger.info('Searchbox is enabled');
+      const SearchboxDelegator = require('./search-delegator/searchbox.js');
+      return new SearchboxDelegator(this.configManager, searchEvent);
+    }
+    if (this.isElasticsearchEnabled) {
+      logger.info('Elasticsearch (not Searchbox) is enabled');
+      const ElasticsearchDelegator = require('./search-delegator/elasticsearch.js');
+      return new ElasticsearchDelegator(this.configManager, searchEvent);
+    }
+
+  }
+
+  registerUpdateEvent() {
+    const pageEvent = this.crowi.event('page');
+    pageEvent.on('create', this.delegator.syncPageUpdated.bind(this.delegator));
+    pageEvent.on('update', this.delegator.syncPageUpdated.bind(this.delegator));
+    pageEvent.on('delete', this.delegator.syncPageDeleted.bind(this.delegator));
+
+    const bookmarkEvent = this.crowi.event('bookmark');
+    bookmarkEvent.on('create', this.delegator.syncBookmarkChanged.bind(this.delegator));
+    bookmarkEvent.on('delete', this.delegator.syncBookmarkChanged.bind(this.delegator));
+
+    const tagEvent = this.crowi.event('tag');
+    tagEvent.on('update', this.delegator.syncTagChanged.bind(this.delegator));
+  }
+
+  getInfo() {
+    return this.delegator.info({});
+  }
+
+  async buildIndex() {
+    return this.delegator.buildIndex();
+  }
+
+  async searchKeyword(keyword, user, userGroups, searchOpts) {
+    return this.delegator.searchKeyword(keyword, user, userGroups, searchOpts);
+  }
+
+}
+
+module.exports = SearchService;

+ 0 - 919
src/server/util/search.js

@@ -1,919 +0,0 @@
-/**
- * Search
- */
-
-const elasticsearch = require('elasticsearch');
-const debug = require('debug')('growi:lib:search');
-const logger = require('@alias/logger')('growi:lib:search');
-
-const {
-  Writable, Transform,
-} = require('stream');
-const streamToPromise = require('stream-to-promise');
-
-const { createBatchStream } = require('./batch-stream');
-
-const BULK_REINDEX_SIZE = 100;
-
-function SearchClient(crowi, esUri) {
-  this.DEFAULT_OFFSET = 0;
-  this.DEFAULT_LIMIT = 50;
-
-  this.esNodeName = '-';
-  this.esNodeNames = [];
-  this.esVersion = 'unknown';
-  this.esVersions = [];
-  this.esPlugin = [];
-  this.esPlugins = [];
-  this.esUri = esUri;
-  this.crowi = crowi;
-  this.searchEvent = crowi.event('search');
-  this.configManager = this.crowi.configManager;
-
-  // In Elasticsearch RegExp, we don't need to used ^ and $.
-  // Ref: https://www.elastic.co/guide/en/elasticsearch/reference/5.6/query-dsl-regexp-query.html#_standard_operators
-  this.queries = {
-    PORTAL: {
-      regexp: {
-        'path.raw': '.*/',
-      },
-    },
-    PUBLIC: {
-      regexp: {
-        'path.raw': '.*[^/]',
-      },
-    },
-    USER: {
-      prefix: {
-        'path.raw': '/user/',
-      },
-    },
-  };
-
-  const uri = this.parseUri(this.esUri);
-  this.host = uri.host;
-  this.indexName = uri.indexName;
-  this.aliasName = `${this.indexName}-alias`;
-
-  this.client = new elasticsearch.Client({
-    host: this.host,
-    requestTimeout: 5000,
-    // log: 'debug',
-  });
-
-  this.registerUpdateEvent();
-
-  this.mappingFile = `${crowi.resourceDir}search/mappings.json`;
-}
-
-SearchClient.prototype.getInfo = function() {
-  return this.client.info({});
-};
-
-SearchClient.prototype.checkESVersion = async function() {
-  try {
-    const nodes = await this.client.nodes.info();
-    if (!nodes._nodes || !nodes.nodes) {
-      throw new Error('no nodes info');
-    }
-
-    for (const [nodeName, nodeInfo] of Object.entries(nodes.nodes)) {
-      this.esNodeName = nodeName;
-      this.esNodeNames.push(nodeName);
-      this.esVersion = nodeInfo.version;
-      this.esVersions.push(nodeInfo.version);
-      this.esPlugin = nodeInfo.plugins;
-      this.esPlugins.push(nodeInfo.plugins);
-    }
-  }
-  catch (error) {
-    logger.error('es check version error:', error);
-  }
-};
-
-SearchClient.prototype.registerUpdateEvent = function() {
-  const pageEvent = this.crowi.event('page');
-  pageEvent.on('create', this.syncPageUpdated.bind(this));
-  pageEvent.on('update', this.syncPageUpdated.bind(this));
-  pageEvent.on('delete', this.syncPageDeleted.bind(this));
-
-  const bookmarkEvent = this.crowi.event('bookmark');
-  bookmarkEvent.on('create', this.syncBookmarkChanged.bind(this));
-  bookmarkEvent.on('delete', this.syncBookmarkChanged.bind(this));
-
-  const tagEvent = this.crowi.event('tag');
-  tagEvent.on('update', this.syncTagChanged.bind(this));
-};
-
-SearchClient.prototype.shouldIndexed = function(page) {
-  return page.creator != null && page.revision != null && page.redirectTo == null;
-};
-
-// BONSAI_URL is following format:
-// => https://{ID}:{PASSWORD}@{HOST}
-SearchClient.prototype.parseUri = function(uri) {
-  let indexName = 'crowi';
-  let host = uri;
-  const match = uri.match(/^(https?:\/\/[^/]+)\/(.+)$/);
-  if (match) {
-    host = match[1];
-    indexName = match[2];
-  }
-
-  return {
-    host,
-    indexName,
-  };
-};
-
-SearchClient.prototype.initIndices = async function() {
-  await this.checkESVersion();
-
-  const { client, indexName, aliasName } = this;
-
-  const tmpIndexName = `${indexName}-tmp`;
-
-  // remove tmp index
-  const isExistsTmpIndex = await client.indices.exists({ index: tmpIndexName });
-  if (isExistsTmpIndex) {
-    await client.indices.delete({ index: tmpIndexName });
-  }
-
-  // create index
-  const isExistsIndex = await client.indices.exists({ index: indexName });
-  if (!isExistsIndex) {
-    await this.createIndex(indexName);
-  }
-
-  // create alias
-  const isExistsAlias = await client.indices.existsAlias({ name: aliasName, index: indexName });
-  if (!isExistsAlias) {
-    await client.indices.putAlias({
-      name: aliasName,
-      index: indexName,
-    });
-  }
-};
-
-SearchClient.prototype.createIndex = async function(index) {
-  const body = require(this.mappingFile);
-  return this.client.indices.create({ index, body });
-};
-
-SearchClient.prototype.buildIndex = async function(uri) {
-  await this.initIndices();
-
-  const { client, indexName } = this;
-
-  const aliasName = `${indexName}-alias`;
-  const tmpIndexName = `${indexName}-tmp`;
-
-  // reindex to tmp index
-  await this.createIndex(tmpIndexName);
-  await client.reindex({
-    waitForCompletion: false,
-    body: {
-      source: { index: indexName },
-      dest: { index: tmpIndexName },
-    },
-  });
-
-  // update alias
-  await client.indices.updateAliases({
-    body: {
-      actions: [
-        { add: { alias: aliasName, index: tmpIndexName } },
-        { remove: { alias: aliasName, index: indexName } },
-      ],
-    },
-  });
-
-  // flush index
-  await client.indices.delete({
-    index: indexName,
-  });
-  await this.createIndex(indexName);
-  await this.addAllPages();
-
-  // update alias
-  await client.indices.updateAliases({
-    body: {
-      actions: [
-        { add: { alias: aliasName, index: indexName } },
-        { remove: { alias: aliasName, index: tmpIndexName } },
-      ],
-    },
-  });
-
-  // remove tmp index
-  await client.indices.delete({ index: tmpIndexName });
-};
-
-/**
- * generate object that is related to page.grant*
- */
-function generateDocContentsRelatedToRestriction(page) {
-  let grantedUserIds = null;
-  if (page.grantedUsers != null && page.grantedUsers.length > 0) {
-    grantedUserIds = page.grantedUsers.map((user) => {
-      const userId = (user._id == null) ? user : user._id;
-      return userId.toString();
-    });
-  }
-
-  let grantedGroupId = null;
-  if (page.grantedGroup != null) {
-    const groupId = (page.grantedGroup._id == null) ? page.grantedGroup : page.grantedGroup._id;
-    grantedGroupId = groupId.toString();
-  }
-
-  return {
-    grant: page.grant,
-    granted_users: grantedUserIds,
-    granted_group: grantedGroupId,
-  };
-}
-
-SearchClient.prototype.prepareBodyForCreate = function(body, page) {
-  if (!Array.isArray(body)) {
-    throw new Error('Body must be an array.');
-  }
-
-  const command = {
-    index: {
-      _index: this.indexName,
-      _type: 'pages',
-      _id: page._id.toString(),
-    },
-  };
-
-  const bookmarkCount = page.bookmarkCount || 0;
-  let document = {
-    path: page.path,
-    body: page.revision.body,
-    username: page.creator.username,
-    comment_count: page.commentCount,
-    bookmark_count: bookmarkCount,
-    like_count: page.liker.length || 0,
-    created_at: page.createdAt,
-    updated_at: page.updatedAt,
-    tag_names: page.tagNames,
-  };
-
-  document = Object.assign(document, generateDocContentsRelatedToRestriction(page));
-
-  body.push(command);
-  body.push(document);
-};
-
-SearchClient.prototype.prepareBodyForDelete = function(body, page) {
-  if (!Array.isArray(body)) {
-    throw new Error('Body must be an array.');
-  }
-
-  const command = {
-    delete: {
-      _index: this.indexName,
-      _type: 'pages',
-      _id: page._id.toString(),
-    },
-  };
-
-  body.push(command);
-};
-
-SearchClient.prototype.addAllPages = async function() {
-  const Page = this.crowi.model('Page');
-  return this.updateOrInsertPages(() => Page.find(), true);
-};
-
-SearchClient.prototype.updateOrInsertPageById = async function(pageId) {
-  const Page = this.crowi.model('Page');
-  return this.updateOrInsertPages(() => Page.findById(pageId));
-};
-
-/**
- * @param {function} queryFactory factory method to generate a Mongoose Query instance
- */
-SearchClient.prototype.updateOrInsertPages = async function(queryFactory, isEmittingProgressEvent = false) {
-  const Page = this.crowi.model('Page');
-  const { PageQueryBuilder } = Page;
-  const Bookmark = this.crowi.model('Bookmark');
-  const PageTagRelation = this.crowi.model('PageTagRelation');
-
-  const searchEvent = this.searchEvent;
-
-  // prepare functions invoked from custom streams
-  const prepareBodyForCreate = this.prepareBodyForCreate.bind(this);
-  const shouldIndexed = this.shouldIndexed.bind(this);
-  const bulkWrite = this.client.bulk.bind(this.client);
-
-  const findQuery = new PageQueryBuilder(queryFactory()).addConditionToExcludeRedirect().query;
-  const countQuery = new PageQueryBuilder(queryFactory()).addConditionToExcludeRedirect().query;
-
-  const totalCount = await countQuery.count();
-
-  const readStream = findQuery
-    // populate data which will be referenced by prepareBodyForCreate()
-    .populate([
-      { path: 'creator', model: 'User', select: 'username' },
-      { path: 'revision', model: 'Revision', select: 'body' },
-    ])
-    .snapshot()
-    .lean()
-    .cursor();
-
-  let skipped = 0;
-  const thinOutStream = new Transform({
-    objectMode: true,
-    async transform(doc, encoding, callback) {
-      if (shouldIndexed(doc)) {
-        this.push(doc);
-      }
-      else {
-        skipped++;
-      }
-      callback();
-    },
-  });
-
-  const batchStream = createBatchStream(BULK_REINDEX_SIZE);
-
-  const appendBookmarkCountStream = new Transform({
-    objectMode: true,
-    async transform(chunk, encoding, callback) {
-      const pageIds = chunk.map(doc => doc._id);
-
-      const idToCountMap = await Bookmark.getPageIdToCountMap(pageIds);
-      const idsHavingCount = Object.keys(idToCountMap);
-
-      // append count
-      chunk
-        .filter(doc => idsHavingCount.includes(doc._id.toString()))
-        .forEach((doc) => {
-          // append count from idToCountMap
-          doc.bookmarkCount = idToCountMap[doc._id.toString()];
-        });
-
-      this.push(chunk);
-      callback();
-    },
-  });
-
-  const appendTagNamesStream = new Transform({
-    objectMode: true,
-    async transform(chunk, encoding, callback) {
-      const pageIds = chunk.map(doc => doc._id);
-
-      const idToTagNamesMap = await PageTagRelation.getIdToTagNamesMap(pageIds);
-      const idsHavingTagNames = Object.keys(idToTagNamesMap);
-
-      // append tagNames
-      chunk
-        .filter(doc => idsHavingTagNames.includes(doc._id.toString()))
-        .forEach((doc) => {
-          // append tagName from idToTagNamesMap
-          doc.tagNames = idToTagNamesMap[doc._id.toString()];
-        });
-
-      this.push(chunk);
-      callback();
-    },
-  });
-
-  let count = 0;
-  const writeStream = new Writable({
-    objectMode: true,
-    async write(batch, encoding, callback) {
-      const body = [];
-      batch.forEach(doc => prepareBodyForCreate(body, doc));
-
-      try {
-        const res = await bulkWrite({
-          body,
-          requestTimeout: Infinity,
-        });
-
-        count += (res.items || []).length;
-
-        logger.info(`Adding pages progressing: (count=${count}, errors=${res.errors}, took=${res.took}ms)`);
-
-        if (isEmittingProgressEvent) {
-          searchEvent.emit('addPageProgress', totalCount, count, skipped);
-        }
-      }
-      catch (err) {
-        logger.error('addAllPages error on add anyway: ', err);
-      }
-
-      callback();
-    },
-    final(callback) {
-      logger.info(`Adding pages has terminated: (totalCount=${totalCount}, skipped=${skipped})`);
-
-      if (isEmittingProgressEvent) {
-        searchEvent.emit('finishAddPage', totalCount, count, skipped);
-      }
-      callback();
-    },
-  });
-
-  readStream
-    .pipe(thinOutStream)
-    .pipe(batchStream)
-    .pipe(appendBookmarkCountStream)
-    .pipe(appendTagNamesStream)
-    .pipe(writeStream);
-
-  return streamToPromise(writeStream);
-
-};
-
-SearchClient.prototype.deletePages = function(pages) {
-  const self = this;
-  const body = [];
-
-  pages.map((page) => {
-    self.prepareBodyForDelete(body, page);
-    return;
-  });
-
-  logger.debug('deletePages(): Sending Request to ES', body);
-  return this.client.bulk({
-    body,
-  });
-};
-
-/**
- * search returning type:
- * {
- *   meta: { total: Integer, results: Integer},
- *   data: [ pages ...],
- * }
- */
-SearchClient.prototype.search = async function(query) {
-  // for debug
-  if (process.env.NODE_ENV === 'development') {
-    const result = await this.client.indices.validateQuery({
-      explain: true,
-      body: {
-        query: query.body.query,
-      },
-    });
-    logger.debug('ES returns explanations: ', result.explanations);
-  }
-
-  const result = await this.client.search(query);
-
-  // for debug
-  logger.debug('ES result: ', result);
-
-  return {
-    meta: {
-      took: result.took,
-      total: result.hits.total,
-      results: result.hits.hits.length,
-    },
-    data: result.hits.hits.map((elm) => {
-      return { _id: elm._id, _score: elm._score, _source: elm._source };
-    }),
-  };
-};
-
-SearchClient.prototype.createSearchQuerySortedByUpdatedAt = function(option) {
-  // getting path by default is almost for debug
-  let fields = ['path', 'bookmark_count', 'tag_names'];
-  if (option) {
-    fields = option.fields || fields;
-  }
-
-  // default is only id field, sorted by updated_at
-  const query = {
-    index: this.aliasName,
-    type: 'pages',
-    body: {
-      sort: [{ updated_at: { order: 'desc' } }],
-      query: {}, // query
-      _source: fields,
-    },
-  };
-  this.appendResultSize(query);
-
-  return query;
-};
-
-SearchClient.prototype.createSearchQuerySortedByScore = function(option) {
-  let fields = ['path', 'bookmark_count', 'tag_names'];
-  if (option) {
-    fields = option.fields || fields;
-  }
-
-  // sort by score
-  const query = {
-    index: this.aliasName,
-    type: 'pages',
-    body: {
-      sort: [{ _score: { order: 'desc' } }],
-      query: {}, // query
-      _source: fields,
-    },
-  };
-  this.appendResultSize(query);
-
-  return query;
-};
-
-SearchClient.prototype.appendResultSize = function(query, from, size) {
-  query.from = from || this.DEFAULT_OFFSET;
-  query.size = size || this.DEFAULT_LIMIT;
-};
-
-SearchClient.prototype.initializeBoolQuery = function(query) {
-  // query is created by createSearchQuerySortedByScore() or createSearchQuerySortedByUpdatedAt()
-  if (!query.body.query.bool) {
-    query.body.query.bool = {};
-  }
-
-  const isInitialized = (query) => { return !!query && Array.isArray(query) };
-
-  if (!isInitialized(query.body.query.bool.filter)) {
-    query.body.query.bool.filter = [];
-  }
-  if (!isInitialized(query.body.query.bool.must)) {
-    query.body.query.bool.must = [];
-  }
-  if (!isInitialized(query.body.query.bool.must_not)) {
-    query.body.query.bool.must_not = [];
-  }
-  return query;
-};
-
-SearchClient.prototype.appendCriteriaForQueryString = function(query, queryString) {
-  query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
-
-  // parse
-  const parsedKeywords = this.parseQueryString(queryString);
-
-  if (parsedKeywords.match.length > 0) {
-    const q = {
-      multi_match: {
-        query: parsedKeywords.match.join(' '),
-        type: 'most_fields',
-        fields: ['path.ja^2', 'path.en^2', 'body.ja', 'body.en'],
-      },
-    };
-    query.body.query.bool.must.push(q);
-  }
-
-  if (parsedKeywords.not_match.length > 0) {
-    const q = {
-      multi_match: {
-        query: parsedKeywords.not_match.join(' '),
-        fields: ['path.ja', 'path.en', 'body.ja', 'body.en'],
-        operator: 'or',
-      },
-    };
-    query.body.query.bool.must_not.push(q);
-  }
-
-  if (parsedKeywords.phrase.length > 0) {
-    const phraseQueries = [];
-    parsedKeywords.phrase.forEach((phrase) => {
-      phraseQueries.push({
-        multi_match: {
-          query: phrase, // each phrase is quoteted words
-          type: 'phrase',
-          fields: [
-            // Not use "*.ja" fields here, because we want to analyze (parse) search words
-            'path.raw^2',
-            'body',
-          ],
-        },
-      });
-    });
-
-    query.body.query.bool.must.push(phraseQueries);
-  }
-
-  if (parsedKeywords.not_phrase.length > 0) {
-    const notPhraseQueries = [];
-    parsedKeywords.not_phrase.forEach((phrase) => {
-      notPhraseQueries.push({
-        multi_match: {
-          query: phrase, // each phrase is quoteted words
-          type: 'phrase',
-          fields: [
-            // Not use "*.ja" fields here, because we want to analyze (parse) search words
-            'path.raw^2',
-            'body',
-          ],
-        },
-      });
-    });
-
-    query.body.query.bool.must_not.push(notPhraseQueries);
-  }
-
-  if (parsedKeywords.prefix.length > 0) {
-    const queries = parsedKeywords.prefix.map((path) => {
-      return { prefix: { 'path.raw': path } };
-    });
-    query.body.query.bool.filter.push({ bool: { should: queries } });
-  }
-
-  if (parsedKeywords.not_prefix.length > 0) {
-    const queries = parsedKeywords.not_prefix.map((path) => {
-      return { prefix: { 'path.raw': path } };
-    });
-    query.body.query.bool.filter.push({ bool: { must_not: queries } });
-  }
-
-  if (parsedKeywords.tag.length > 0) {
-    const queries = parsedKeywords.tag.map((tag) => {
-      return { term: { tag_names: tag } };
-    });
-    query.body.query.bool.filter.push({ bool: { must: queries } });
-  }
-
-  if (parsedKeywords.not_tag.length > 0) {
-    const queries = parsedKeywords.not_tag.map((tag) => {
-      return { term: { tag_names: tag } };
-    });
-    query.body.query.bool.filter.push({ bool: { must_not: queries } });
-  }
-};
-
-SearchClient.prototype.filterPagesByViewer = async function(query, user, userGroups) {
-  const showPagesRestrictedByOwner = !this.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner');
-  const showPagesRestrictedByGroup = !this.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup');
-
-  query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
-
-  const Page = this.crowi.model('Page');
-  const {
-    GRANT_PUBLIC, GRANT_RESTRICTED, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP,
-  } = Page;
-
-  const grantConditions = [
-    { term: { grant: GRANT_PUBLIC } },
-  ];
-
-  // ensure to hit to GRANT_RESTRICTED pages that the user specified at own
-  if (user != null) {
-    grantConditions.push(
-      {
-        bool: {
-          must: [
-            { term: { grant: GRANT_RESTRICTED } },
-            { term: { granted_users: user._id.toString() } },
-          ],
-        },
-      },
-    );
-  }
-
-  if (showPagesRestrictedByOwner) {
-    grantConditions.push(
-      { term: { grant: GRANT_SPECIFIED } },
-      { term: { grant: GRANT_OWNER } },
-    );
-  }
-  else if (user != null) {
-    grantConditions.push(
-      {
-        bool: {
-          must: [
-            { term: { grant: GRANT_SPECIFIED } },
-            { term: { granted_users: user._id.toString() } },
-          ],
-        },
-      },
-      {
-        bool: {
-          must: [
-            { term: { grant: GRANT_OWNER } },
-            { term: { granted_users: user._id.toString() } },
-          ],
-        },
-      },
-    );
-  }
-
-  if (showPagesRestrictedByGroup) {
-    grantConditions.push(
-      { term: { grant: GRANT_USER_GROUP } },
-    );
-  }
-  else if (userGroups != null && userGroups.length > 0) {
-    const userGroupIds = userGroups.map((group) => { return group._id.toString() });
-    grantConditions.push(
-      {
-        bool: {
-          must: [
-            { term: { grant: GRANT_USER_GROUP } },
-            { terms: { granted_group: userGroupIds } },
-          ],
-        },
-      },
-    );
-  }
-
-  query.body.query.bool.filter.push({ bool: { should: grantConditions } });
-};
-
-SearchClient.prototype.filterPortalPages = function(query) {
-  query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
-
-  query.body.query.bool.must_not.push(this.queries.USER);
-  query.body.query.bool.filter.push(this.queries.PORTAL);
-};
-
-SearchClient.prototype.filterPublicPages = function(query) {
-  query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
-
-  query.body.query.bool.must_not.push(this.queries.USER);
-  query.body.query.bool.filter.push(this.queries.PUBLIC);
-};
-
-SearchClient.prototype.filterUserPages = function(query) {
-  query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
-
-  query.body.query.bool.filter.push(this.queries.USER);
-};
-
-SearchClient.prototype.filterPagesByType = function(query, type) {
-  const Page = this.crowi.model('Page');
-
-  switch (type) {
-    case Page.TYPE_PORTAL:
-      return this.filterPortalPages(query);
-    case Page.TYPE_PUBLIC:
-      return this.filterPublicPages(query);
-    case Page.TYPE_USER:
-      return this.filterUserPages(query);
-    default:
-      return query;
-  }
-};
-
-SearchClient.prototype.appendFunctionScore = function(query, queryString) {
-  const User = this.crowi.model('User');
-  const count = User.count({}) || 1;
-
-  const minScore = queryString.length * 0.1 - 1; // increase with length
-  logger.debug('min_score: ', minScore);
-
-  query.body.query = {
-    function_score: {
-      query: { ...query.body.query },
-      // // disable min_score -- 2019.02.28 Yuki Takei
-      // // more precise adjustment is needed...
-      // min_score: minScore,
-      field_value_factor: {
-        field: 'bookmark_count',
-        modifier: 'log1p',
-        factor: 10000 / count,
-        missing: 0,
-      },
-      boost_mode: 'sum',
-    },
-  };
-};
-
-SearchClient.prototype.searchKeyword = async function(queryString, user, userGroups, option) {
-  const from = option.offset || null;
-  const size = option.limit || null;
-  const type = option.type || null;
-  const query = this.createSearchQuerySortedByScore();
-  this.appendCriteriaForQueryString(query, queryString);
-
-  this.filterPagesByType(query, type);
-  await this.filterPagesByViewer(query, user, userGroups);
-
-  this.appendResultSize(query, from, size);
-
-  this.appendFunctionScore(query, queryString);
-
-  return this.search(query);
-};
-
-SearchClient.prototype.parseQueryString = function(queryString) {
-  const matchWords = [];
-  const notMatchWords = [];
-  const phraseWords = [];
-  const notPhraseWords = [];
-  const prefixPaths = [];
-  const notPrefixPaths = [];
-  const tags = [];
-  const notTags = [];
-
-  queryString.trim();
-  queryString = queryString.replace(/\s+/g, ' '); // eslint-disable-line no-param-reassign
-
-  // First: Parse phrase keywords
-  const phraseRegExp = new RegExp(/(-?"[^"]+")/g);
-  const phrases = queryString.match(phraseRegExp);
-
-  if (phrases !== null) {
-    queryString = queryString.replace(phraseRegExp, ''); // eslint-disable-line no-param-reassign
-
-    phrases.forEach((phrase) => {
-      phrase.trim();
-      if (phrase.match(/^-/)) {
-        notPhraseWords.push(phrase.replace(/^-/, ''));
-      }
-      else {
-        phraseWords.push(phrase);
-      }
-    });
-  }
-
-  // Second: Parse other keywords (include minus keywords)
-  queryString.split(' ').forEach((word) => {
-    if (word === '') {
-      return;
-    }
-
-    // https://regex101.com/r/pN9XfK/1
-    const matchNegative = word.match(/^-(prefix:|tag:)?(.+)$/);
-    // https://regex101.com/r/3qw9FQ/1
-    const matchPositive = word.match(/^(prefix:|tag:)?(.+)$/);
-
-    if (matchNegative != null) {
-      if (matchNegative[1] === 'prefix:') {
-        notPrefixPaths.push(matchNegative[2]);
-      }
-      else if (matchNegative[1] === 'tag:') {
-        notTags.push(matchNegative[2]);
-      }
-      else {
-        notMatchWords.push(matchNegative[2]);
-      }
-    }
-    else if (matchPositive != null) {
-      if (matchPositive[1] === 'prefix:') {
-        prefixPaths.push(matchPositive[2]);
-      }
-      else if (matchPositive[1] === 'tag:') {
-        tags.push(matchPositive[2]);
-      }
-      else {
-        matchWords.push(matchPositive[2]);
-      }
-    }
-  });
-
-  return {
-    match: matchWords,
-    not_match: notMatchWords,
-    phrase: phraseWords,
-    not_phrase: notPhraseWords,
-    prefix: prefixPaths,
-    not_prefix: notPrefixPaths,
-    tag: tags,
-    not_tag: notTags,
-  };
-};
-
-SearchClient.prototype.syncPageUpdated = async function(page, user) {
-  logger.debug('SearchClient.syncPageUpdated', page.path);
-
-  // delete if page should not indexed
-  if (!this.shouldIndexed(page)) {
-    try {
-      await this.deletePages([page]);
-    }
-    catch (err) {
-      logger.error('deletePages:ES Error', err);
-    }
-    return;
-  }
-
-  return this.updateOrInsertPageById(page._id);
-};
-
-SearchClient.prototype.syncPageDeleted = async function(page, user) {
-  debug('SearchClient.syncPageDeleted', page.path);
-
-  try {
-    return await this.deletePages([page]);
-  }
-  catch (err) {
-    logger.error('deletePages:ES Error', err);
-  }
-};
-
-SearchClient.prototype.syncBookmarkChanged = async function(pageId) {
-  logger.debug('SearchClient.syncBookmarkChanged', pageId);
-
-  return this.updateOrInsertPageById(pageId);
-};
-
-SearchClient.prototype.syncTagChanged = async function(page) {
-  logger.debug('SearchClient.syncTagChanged', page.path);
-
-  return this.updateOrInsertPageById(page._id);
-};
-
-
-module.exports = SearchClient;

+ 1 - 86
src/server/views/admin/external-accounts.html

@@ -38,92 +38,7 @@
       {% include './widget/menu.html' with {current: 'external-account'} %}
     </div>
 
-    <!-- TODO reactify admin -->
-    <div class="col-md-9">
-      <p>
-        <a class="btn btn-default" href="/admin/users">
-          <i class="icon-fw ti-arrow-left" aria-hidden="true"></i>
-          {{ t('user_management.back_to_user_management') }}
-        </a>
-      </p>
-
-      <h2>{{ t('user_management.external_account_list') }}</h2>
-
-      <table class="table table-bordered table-user-list">
-        <thead>
-          <tr>
-            <th width="120px">{{ t('user_management.authentication_provider') }}</th>
-            <th><code>accountId</code></th>
-            <th>{{ t('user_management.related_username', 'username') }}</th>
-
-            <th>
-              {{ t('user_management.password_setting') }}
-              <a class="text-muted"
-                  data-toggle="popover" data-placement="top"
-                  data-trigger="hover focus" tabindex="0" role="button" {# dismiss settings #}
-                  data-animation="false" data-html="true"
-                  data-content="<small>{{ t('user_management.password_setting_help') }}</small>">
-                <small>
-                  <i class="icon-question" aria-hidden="true"></i>
-                </small>
-              </a>
-            </th>
-            <th width="100px">{{ t('Created') }}</th>
-            <th width="70px"></th>
-          </tr>
-        </thead>
-        <tbody>
-          {% for account in accounts %}
-          <tr>
-            <td>{{ account.providerType }}</td>
-            <td>
-              <strong>{{ account.accountId }}</strong>
-            </td>
-            <td>
-              <strong>{{ account.user.username }}</strong>
-            </td>
-            <td>
-              {% if account.user.password != null %}
-              <span class="label label-info">
-                {{ t('user_management.set') }}
-              </span>
-              {% else %}
-              <span class="label label-warning">
-                {{ t('user_management.unset') }}
-              </span>
-              {% endif %}
-            </td>
-            <td>{{ account.createdAt|datetz('Y-m-d') }}</td>
-            <td>
-              <div class="btn-group admin-user-menu">
-
-                <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
-                  <i class="icon-settings"></i> <span class="caret"></span>
-                </button>
-                <ul class="dropdown-menu" role="menu">
-                  <li class="dropdown-header">{{ t('user_management.edit_menu') }}</li>
-                  <form id="form_remove_{{ loop.index }}" action="/admin/users/external-accounts/{{ account._id.toString() }}/remove" method="post">
-                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                  </form>
-                  <li>
-                    <a href="javascript:form_remove_{{ loop.index }}.submit()">
-                      <i class="icon-fw icon-fire text-danger"></i>
-                      {{ t('Delete') }}
-                    </a>
-                  </li>
-                </ul>{# end of .dropdown-menu #}
-
-
-
-              </div>{# end of .btn-group #}
-            </td>
-          </tr>
-          {% endfor %}
-        </tbody>
-      </table>
-
-      {% include '../widget/pager.html' with {path: "/admin/users/external-accounts", pager: pager} %}
-
+    <div class="col-md-9" id="admin-external-account-setting">
     </div>
   </div>
 </div>

+ 1 - 1
src/server/views/admin/widget/passport/ldap.html

@@ -81,7 +81,7 @@
       <div class="form-group">
         <label for="settingForm[security:passport-ldap:bindDNPassword]" class="col-xs-3 control-label">{{ t("security_setting.ldap.bind_DN_password") }}</label>
         <div class="col-xs-6">
-          <input class="form-control passport-ldap-managerbind" type="text" {% if isUserBind %}style="display: none;"{% endif %}
+          <input class="form-control passport-ldap-managerbind" type="password" {% if isUserBind %}style="display: none;"{% endif %}
               name="settingForm[security:passport-ldap:bindDNPassword]" value="{{ getConfig('crowi', 'security:passport-ldap:bindDNPassword') | default('') }}">
           <p class="help-block passport-ldap-managerbind">
             <small>

+ 7 - 10
src/test/crowi/crowi.test.js

@@ -1,26 +1,23 @@
-const helpers = require('@commons/util/helpers');
+const { getInstance } = require('../setup-crowi');
 
 describe('Test for Crowi application context', () => {
 
-  const Crowi = require('@server/crowi');
-
   describe('construction', () => {
-    test('initialize crowi context', () => {
-      const crowi = new Crowi(helpers.root());
-      expect(crowi).toBeInstanceOf(Crowi);
+    test('initialize crowi context', async() => {
+      const crowi = await getInstance();
       expect(crowi.version).toBe(require('@root/package.json').version);
       expect(typeof crowi.env).toBe('object');
     });
 
-    test('config getter, setter', () => {
-      const crowi = new Crowi(helpers.root());
+    test('config getter, setter', async() => {
+      const crowi = await getInstance();
       expect(crowi.getConfig()).toEqual({});
       crowi.setConfig({ test: 1 });
       expect(crowi.getConfig()).toEqual({ test: 1 });
     });
 
-    test('model getter, setter', () => {
-      const crowi = new Crowi(helpers.root());
+    test('model getter, setter', async() => {
+      const crowi = await getInstance();
       // set
       crowi.model('hoge', { fuga: 1 });
       expect(crowi.model('hoge')).toEqual({ fuga: 1 });

+ 12 - 0
src/test/global-setup.js

@@ -1,3 +1,5 @@
+require('module-alias/register');
+
 // check env
 if (process.env.NODE_ENV !== 'test') {
   throw new Error('\'process.env.NODE_ENV\' must be \'test\'');
@@ -7,8 +9,18 @@ const mongoose = require('mongoose');
 
 const { getMongoUri } = require('../lib/util/mongoose-utils');
 
+const { getInstance } = require('./setup-crowi');
+
 module.exports = async() => {
   await mongoose.connect(getMongoUri(), { useNewUrlParser: true });
+
+  // drop database
   await mongoose.connection.dropDatabase();
+
+  // init DB
+  const crowi = await getInstance();
+  const appService = crowi.appService;
+  await appService.initDB();
+
   await mongoose.disconnect();
 };

+ 19 - 27
src/test/middleware/login-required.test.js

@@ -1,7 +1,5 @@
 /* eslint-disable arrow-body-style */
 
-import each from 'jest-each';
-
 const { getInstance } = require('../setup-crowi');
 
 describe('loginRequired', () => {
@@ -16,11 +14,6 @@ describe('loginRequired', () => {
     done();
   });
 
-  // test('returns strict middlware when args is undefined', () => {
-  //   const func = middlewares.loginRequired();
-  //   expect(func).toBe(loginRequiredStrict);
-  // });
-
   describe('not strict mode', () => {
     // setup req/res/next
     const req = {
@@ -129,30 +122,29 @@ describe('loginRequired', () => {
       expect(req.session.jumpTo).toBe(undefined);
     });
 
-    each`
+    /* eslint-disable indent */
+    test.each`
       userStatus  | expectedPath
       ${1}        | ${'/login/error/registered'}
       ${3}        | ${'/login/error/suspended'}
       ${5}        | ${'/login/invited'}
-    `
-      .test('redirect to \'$expectedPath\''
-        + ' when user.status is \'$userStatus\' ', ({ userStatus, expectedPath }) => {
-
-        req.user = {
-          _id: 'user id',
-          status: userStatus,
-        };
-
-        const result = loginRequiredStrictly(req, res, next);
-
-        expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
-        expect(next).not.toHaveBeenCalled();
-        expect(res.sendStatus).not.toHaveBeenCalled();
-        expect(res.redirect).toHaveBeenCalledTimes(1);
-        expect(res.redirect).toHaveBeenCalledWith(expectedPath);
-        expect(result).toBe('redirect');
-        expect(req.session.jumpTo).toBe(undefined);
-      });
+    `('redirect to \'$expectedPath\' when user.status is \'$userStatus\'', ({ userStatus, expectedPath }) => {
+      req.user = {
+        _id: 'user id',
+        status: userStatus,
+      };
+
+      const result = loginRequiredStrictly(req, res, next);
+
+      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(next).not.toHaveBeenCalled();
+      expect(res.sendStatus).not.toHaveBeenCalled();
+      expect(res.redirect).toHaveBeenCalledTimes(1);
+      expect(res.redirect).toHaveBeenCalledWith(expectedPath);
+      expect(result).toBe('redirect');
+      expect(req.session.jumpTo).toBe(undefined);
+    });
+    /* eslint-disable indent */
 
     test('redirect to \'/login\' when user.status is \'STATUS_DELETED\'', () => {
       const User = crowi.model('User');

+ 10 - 18
src/test/models/page.test.js

@@ -117,23 +117,17 @@ describe('Page', () => {
 
   describe('.isPublic', () => {
     describe('with a public page', () => {
-      test('should return true', (done) => {
-        Page.findOne({ path: '/grant/public' }, (err, page) => {
-          expect(err).toBeNull();
-          expect(page.isPublic()).toEqual(true);
-          done();
-        });
+      test('should return true', async() => {
+        const page = await Page.findOne({ path: '/grant/public' });
+        expect(page.isPublic()).toEqual(true);
       });
     });
 
     ['restricted', 'specified', 'owner'].forEach((grant) => {
       describe(`with a ${grant} page`, () => {
-        test('should return false', (done) => {
-          Page.findOne({ path: `/grant/${grant}` }, (err, page) => {
-            expect(err).toBeNull();
-            expect(page.isPublic()).toEqual(false);
-            done();
-          });
+        test('should return false', async() => {
+          const page = await Page.findOne({ path: `/grant/${grant}` });
+          expect(page.isPublic()).toEqual(false);
         });
       });
     });
@@ -241,12 +235,10 @@ describe('Page', () => {
 
   describe('Extended field', () => {
     describe('Slack Channel.', () => {
-      test('should be empty', (done) => {
-        Page.findOne({ path: '/page/for/extended' }, (err, page) => {
-          expect(page.extended.hoge).toEqual(1);
-          expect(page.getSlackChannel()).toEqual('');
-          done();
-        });
+      test('should be empty', async() => {
+        const page = await Page.findOne({ path: '/page/for/extended' });
+        expect(page.extended.hoge).toEqual(1);
+        expect(page.getSlackChannel()).toEqual('');
       });
 
       test('set slack channel and should get it and should keep hoge ', async() => {

+ 3 - 8
src/test/models/updatePost.test.js

@@ -19,40 +19,35 @@ describe('UpdatePost', () => {
 
   describe('.createPrefixesByPathPattern', () => {
     describe('with a path', () => {
-      test('should return right patternPrfixes', (done) => {
+      test('should return right patternPrfixes', () => {
         expect(UpdatePost.createPrefixesByPathPattern('/*')).toEqual(['*', '*']);
         expect(UpdatePost.createPrefixesByPathPattern('/user/*/日報*')).toEqual(['user', '*']);
         expect(UpdatePost.createPrefixesByPathPattern('/project/hoge/*')).toEqual(['project', 'hoge']);
         expect(UpdatePost.createPrefixesByPathPattern('/*/MTG/*')).toEqual(['*', 'MTG']);
         expect(UpdatePost.createPrefixesByPathPattern('自己紹介')).toEqual(['*', '*']);
         expect(UpdatePost.createPrefixesByPathPattern('/user/aoi/メモ/2016/02/10/xxx')).toEqual(['user', 'aoi']);
-
-        done();
       });
     });
   });
 
   describe('.getRegExpByPattern', () => {
     describe('with a pattern', () => {
-      test('should return right regexp', (done) => {
+      test('should return right regexp', () => {
         expect(UpdatePost.getRegExpByPattern('/*')).toEqual(/^\/.*/);
         expect(UpdatePost.getRegExpByPattern('/user/*/日報*')).toEqual(/^\/user\/.*\/日報.*/);
         expect(UpdatePost.getRegExpByPattern('/project/hoge/*')).toEqual(/^\/project\/hoge\/.*/);
         expect(UpdatePost.getRegExpByPattern('/*/MTG/*')).toEqual(/^\/.*\/MTG\/.*/);
         expect(UpdatePost.getRegExpByPattern('自己紹介')).toEqual(/^\/.*自己紹介.*/);
         expect(UpdatePost.getRegExpByPattern('/user/aoi/メモ/2016/02/10/xxx')).toEqual(/^\/user\/aoi\/メモ\/2016\/02\/10\/xxx/);
-        done();
       });
     });
   });
 
   describe('.normalizeChannelName', () => {
     describe('with a channel name', () => {
-      test('should return true', (done) => {
+      test('should return true', () => {
         expect(UpdatePost.normalizeChannelName('#pj-hoge')).toEqual('pj-hoge');
         expect(UpdatePost.normalizeChannelName('pj-hoge')).toEqual('pj-hoge');
-
-        done();
       });
     });
   });

+ 4 - 6
src/test/models/user.test.js

@@ -25,6 +25,7 @@ describe('User', () => {
 
   describe('Create and Find.', () => {
     describe('The user', () => {
+      /* eslint-disable jest/no-test-callback */
       test('should created with createUserByEmailAndPassword', (done) => {
         User.createUserByEmailAndPassword('Example2 for User Test', 'usertest2', 'usertest2@example.com', 'usertest2pass', 'en', (err, userData) => {
           expect(err).toBeNull();
@@ -33,6 +34,7 @@ describe('User', () => {
           done();
         });
       });
+      /* eslint-enable jest/no-test-callback */
 
       test('should be found by findUserByUsername', async() => {
         const user = await User.findUserByUsername('usertest');
@@ -52,23 +54,19 @@ describe('User', () => {
 
   describe('User Utilities', () => {
     describe('Get username from path', () => {
-      test('found', (done) => {
+      test('found', () => {
         let username = null;
         username = User.getUsernameByPath('/user/sotarok');
         expect(username).toEqual('sotarok');
 
         username = User.getUsernameByPath('/user/some.user.name12/'); // with slash
         expect(username).toEqual('some.user.name12');
-
-        done();
       });
 
-      test('not found', (done) => {
+      test('not found', () => {
         let username = null;
         username = User.getUsernameByPath('/the/page/is/not/related/to/user/page');
         expect(username).toBeNull();
-
-        done();
       });
     });
   });

+ 12 - 13
src/test/service/acl.test.js

@@ -1,5 +1,3 @@
-import each from 'jest-each';
-
 const { getInstance } = require('../setup-crowi');
 
 describe('AclService test', () => {
@@ -165,17 +163,17 @@ describe('AclService test', () => {
       expect(result).toBe(true);
     });
 
-    each`
-    restrictGuestMode   | expected
-      ${undefined}      | ${false}
-      ${'Deny'}         | ${false}
-      ${'Readonly'}     | ${true}
-      ${'Open'}         | ${false}
-      ${'Restricted'}   | ${false}
-      ${'closed'}       | ${false}
-    `
-      .test('to be $expected when FORCE_WIKI_MODE is undefined'
-          + ' and `security:restrictGuestMode` is \'$restrictGuestMode\'', async({ restrictGuestMode, expected }) => {
+    /* eslint-disable indent */
+    describe.each`
+      restrictGuestMode   | expected
+      ${undefined}        | ${false}
+      ${'Deny'}           | ${false}
+      ${'Readonly'}       | ${true}
+      ${'Open'}           | ${false}
+      ${'Restricted'}     | ${false}
+      ${'closed'}         | ${false}
+    `('to be $expected', ({ restrictGuestMode, expected }) => {
+      test(`when FORCE_WIKI_MODE is undefined and 'security:restrictGuestMode' is '${restrictGuestMode}`, async() => {
 
         // reload
         await crowi.configManager.loadConfigs();
@@ -198,6 +196,7 @@ describe('AclService test', () => {
         expect(getConfigSpy).toHaveBeenCalledWith('crowi', 'security:restrictGuestMode');
         expect(result).toBe(expected);
       });
+    });
 
   });
 

+ 36 - 0
src/test/service/search-delegator/searchbox.test.js

@@ -0,0 +1,36 @@
+const SearchboxDelegator = require('@server/service/search-delegator/searchbox');
+
+describe('SearchboxDelegator test', () => {
+
+  let delegator;
+
+  describe('getConnectionInfo()', () => {
+
+    let configManagerMock;
+    let searchEventMock;
+
+    beforeEach(() => {
+      configManagerMock = {};
+      searchEventMock = {};
+
+      // setup mock
+      configManagerMock.getConfig = jest.fn()
+        .mockReturnValue('https://paas:7e530aafad58c892a8778827ae80c879@thorin-us-east-1.searchly.com');
+
+      delegator = new SearchboxDelegator(configManagerMock, searchEventMock);
+    });
+
+    test('returns expected object', async() => {
+
+      const { host, httpAuth, indexName } = delegator.getConnectionInfo();
+
+      expect(configManagerMock.getConfig).toHaveBeenCalledWith('crowi', 'app:searchboxSslUrl');
+      expect(host).toBe('https://paas:7e530aafad58c892a8778827ae80c879@thorin-us-east-1.searchly.com:443');
+      expect(httpAuth).toBe('');
+      expect(indexName).toBe('crowi');
+    });
+
+  });
+
+
+});

+ 0 - 4
src/test/setup-crowi.js

@@ -8,10 +8,6 @@ async function createInstance() {
   const crowi = new Crowi(helpers.root());
   await crowi.initForTest();
 
-  // init DB
-  const appService = crowi.appService;
-  await appService.initDB();
-
   return crowi;
 }
 

+ 10 - 5
src/test/util/slack.test.js

@@ -1,10 +1,15 @@
-const helpers = require('@commons/util/helpers');
-
-const Crowi = require('@server/crowi');
+const { getInstance } = require('../setup-crowi');
 
 describe('Slack Util', () => {
-  const crowi = new Crowi(helpers.root());
-  const slack = require(`${crowi.libDir}/util/slack`)(crowi);
+
+  let crowi;
+  let slack;
+
+  beforeEach(async(done) => {
+    crowi = await getInstance();
+    slack = require(`${crowi.libDir}/util/slack`)(crowi);
+    done();
+  });
 
   test('post comment method exists', () => {
     expect(slack.postComment).toBeInstanceOf(Function);

+ 1 - 1
wercker.yml

@@ -1,4 +1,4 @@
-box: node:10
+box: node:12-slim
 
 services:
   - mongo:3.6

+ 187 - 60
yarn.lock

@@ -695,6 +695,13 @@
   dependencies:
     regenerator-runtime "^0.13.2"
 
+"@babel/runtime@^7.5.5":
+  version "7.7.2"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.7.2.tgz#111a78002a5c25fc8e3361bedc9529c696b85a6a"
+  integrity sha512-JONRbXbTXc9WQE2mAZd1p0Z3DZ/6vaQIkgYMSTP3KjRCyd7rCZCcfhCyX+YjwcKxcZ82UrxbRD358bpExNgrjw==
+  dependencies:
+    regenerator-runtime "^0.13.2"
+
 "@babel/template@^7.1.0":
   version "7.4.0"
   resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.4.0.tgz#12474e9c077bae585c5d835a95c0b0b790c25c8b"
@@ -1128,6 +1135,11 @@
     "@types/istanbul-lib-coverage" "*"
     "@types/istanbul-lib-report" "*"
 
+"@types/json-schema@^7.0.3":
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.3.tgz#bdfd69d61e464dcc81b25159c270d75a73c1a636"
+  integrity sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A==
+
 "@types/ldapjs@^1.0.0":
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/@types/ldapjs/-/ldapjs-1.0.2.tgz#1152cb17564a1a5445af9956b95fc18d1a811ba6"
@@ -1169,6 +1181,27 @@
   resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-12.0.12.tgz#45dd1d0638e8c8f153e87d296907659296873916"
   integrity sha512-SOhuU4wNBxhhTHxYaiG5NY4HBhDIDnJF60GU+2LqHAdKKer86//e4yg69aENCtQ04n0ovz+tq2YPME5t5yp4pw==
 
+"@typescript-eslint/experimental-utils@^2.5.0":
+  version "2.7.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.7.0.tgz#58d790a3884df3041b5a5e08f9e5e6b7c41864b5"
+  integrity sha512-9/L/OJh2a5G2ltgBWJpHRfGnt61AgDeH6rsdg59BH0naQseSwR7abwHq3D5/op0KYD/zFT4LS5gGvWcMmegTEg==
+  dependencies:
+    "@types/json-schema" "^7.0.3"
+    "@typescript-eslint/typescript-estree" "2.7.0"
+    eslint-scope "^5.0.0"
+
+"@typescript-eslint/typescript-estree@2.7.0":
+  version "2.7.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-2.7.0.tgz#34fd98c77a07b40d04d5b4203eddd3abeab909f4"
+  integrity sha512-vVCE/DY72N4RiJ/2f10PTyYekX2OLaltuSIBqeHYI44GQ940VCYioInIb8jKMrK9u855OEJdFC+HmWAZTnC+Ag==
+  dependencies:
+    debug "^4.1.1"
+    glob "^7.1.4"
+    is-glob "^4.0.1"
+    lodash.unescape "4.0.1"
+    semver "^6.3.0"
+    tsutils "^3.17.1"
+
 "@webassemblyjs/ast@1.8.5":
   version "1.8.5"
   resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359"
@@ -2829,11 +2862,15 @@ cli-cursor@^2.1.0:
   dependencies:
     restore-cursor "^2.0.0"
 
-cli-table@0.3.1:
-  version "0.3.1"
-  resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.1.tgz#f53b05266a8b1a0b934b3d0821e6e2dc5914ae23"
+cli-table3@0.5.1:
+  version "0.5.1"
+  resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.5.1.tgz#0252372d94dfc40dbd8df06005f48f31f656f202"
+  integrity sha512-7Qg2Jrep1S/+Q3EceiZtQcDPWxhAvBw+ERf1162v4sikJrvojMHFqXt8QIVha8UlH9rgU0BeWPytZ9/TzYqlUw==
   dependencies:
-    colors "1.0.3"
+    object-assign "^4.1.0"
+    string-width "^2.1.1"
+  optionalDependencies:
+    colors "^1.1.2"
 
 cli-width@^2.0.0:
   version "2.2.0"
@@ -2975,9 +3012,10 @@ color@^3.0.0:
     color-convert "^1.9.1"
     color-string "^1.5.2"
 
-colors@1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b"
+colors@^1.1.2:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
+  integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==
 
 colors@^1.2.5:
   version "1.2.5"
@@ -3004,6 +3042,11 @@ commander@2.20.0, commander@^2.20.0, commander@^2.7.1, commander@~2.20.0:
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422"
   integrity sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==
 
+commander@4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-4.0.0.tgz#e782b6afe6a0f1b1408be59429919e1305160e3f"
+  integrity sha512-SEa2abMBTZuEjLVYpNrAFoRgxPwG4rXP3+SGY6CM/HZGeDzIA7Pzp+7H3AHDukKEpyy2SoSGGPShKqqfH9T9AQ==
+
 commander@^2.18.0:
   version "2.18.0"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.18.0.tgz#2bf063ddee7c7891176981a2cc798e5754bc6970"
@@ -3376,12 +3419,12 @@ create-react-context@^0.2.3:
     fbjs "^0.8.0"
     gud "^1.0.0"
 
-cross-env@^5.0.5:
-  version "5.1.3"
-  resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-5.1.3.tgz#f8ae18faac87692b0a8b4d2f7000d4ec3a85dfd7"
+cross-env@^6.0.3:
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-6.0.3.tgz#4256b71e49b3a40637a0ce70768a6ef5c72ae941"
+  integrity sha512-+KqxF6LCvfhWvADcDPqo64yVIB31gv/jQulX2NGzKS/g3GEVz6/pt4wjHFtFWsHMddebWD/sDthJemzM4MaAag==
   dependencies:
-    cross-spawn "^5.1.0"
-    is-windows "^1.0.0"
+    cross-spawn "^7.0.0"
 
 cross-spawn@6.0.5, cross-spawn@^6.0.0, cross-spawn@^6.0.5:
   version "6.0.5"
@@ -3409,6 +3452,15 @@ cross-spawn@^5.0.1, cross-spawn@^5.1.0:
     shebang-command "^1.2.0"
     which "^1.2.9"
 
+cross-spawn@^7.0.0:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.1.tgz#0ab56286e0f7c24e153d04cc2aa027e43a9a5d14"
+  integrity sha512-u7v4o84SwFpD32Z8IIcPZ6z1/ie24O6RU3RbtL5Y316l3KuHVPx9ItBgWQ6VlfAFnRnTtMUrsQ9MUUTuEZjogg==
+  dependencies:
+    path-key "^3.1.0"
+    shebang-command "^2.0.0"
+    which "^2.0.1"
+
 crypt@~0.0.1:
   version "0.0.2"
   resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
@@ -3648,9 +3700,10 @@ date-and-time@^0.10.0:
   resolved "https://registry.yarnpkg.com/date-and-time/-/date-and-time-0.10.0.tgz#53825b774167b55fbdf0bbd0f17f19357df7bc70"
   integrity sha512-IbIzxtvK80JZOVsWF6+NOjunTaoFVYxkAQoyzmflJyuRCJAJebehy48mPiCAedcGp4P7/UO3QYRWa0fe6INftg==
 
-date-fns@1.30.1:
-  version "1.30.1"
-  resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c"
+date-fns@2.6.0:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.6.0.tgz#a5bc82e6a4c3995ae124b0ba1a71aec7b8cbd666"
+  integrity sha512-F55YxqRdEfP/eYQmQjLN798v0AwLjmZ8nMBjdQvNwEE3N/zWVrlkkqT+9seBlPlsbkybG4JmWg3Ee3dIV9BcGQ==
 
 date-fns@^2.0.0:
   version "2.0.0"
@@ -4483,10 +4536,12 @@ eslint-plugin-import@^2.18.0:
     read-pkg-up "^2.0.0"
     resolve "^1.11.0"
 
-eslint-plugin-jest@^22.7.1:
-  version "22.7.1"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-22.7.1.tgz#5dcdf8f7a285f98040378220d6beca581f0ab2a1"
-  integrity sha512-CrT3AzA738neimv8G8iK2HCkrCwHnAJeeo7k5TEHK86VMItKl6zdJT/tHBDImfnVVAYsVs4Y6BUdBZQCCgfiyw==
+eslint-plugin-jest@^23.0.3:
+  version "23.0.3"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-23.0.3.tgz#d3f157f7791f97713372c13259ba1dfc436eb4c1"
+  integrity sha512-9cNxr66zeOyz1S9AkQL4/ouilR6QHpYj8vKOQZ60fu9hAt5PJWS4KqWqfr1aqN5NFEZSPjFOla2Azn+KTWiGwg==
+  dependencies:
+    "@typescript-eslint/experimental-utils" "^2.5.0"
 
 eslint-plugin-react@^7.14.2:
   version "7.14.2"
@@ -4522,6 +4577,14 @@ eslint-scope@^4.0.3:
     esrecurse "^4.1.0"
     estraverse "^4.1.1"
 
+eslint-scope@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.0.0.tgz#e87c8887c73e8d1ec84f1ca591645c358bfc8fb9"
+  integrity sha512-oYrhJW7S0bxAFDvWqzvMPRm6pcgcnWc4QnofCAqRTRfQC0JcwenzGglTtsLyIuuWFfkqDG9vz67cnttSd53djw==
+  dependencies:
+    esrecurse "^4.1.0"
+    estraverse "^4.1.1"
+
 eslint-utils@^1.3.1:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.3.1.tgz#9a851ba89ee7c460346f97cf8939c7298827e512"
@@ -5269,12 +5332,12 @@ fs-extra@3.0.1:
     jsonfile "^3.0.0"
     universalify "^0.1.0"
 
-fs-extra@8.0.1:
-  version "8.0.1"
-  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.0.1.tgz#90294081f978b1f182f347a440a209154344285b"
-  integrity sha512-W+XLrggcDzlle47X/XnS7FXrXu9sDo+Ze9zpndeBxdgv88FHLm1HtmkhEwavruS6koanBjp098rUpHs65EmG7A==
+fs-extra@8.1.0:
+  version "8.1.0"
+  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
+  integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==
   dependencies:
-    graceful-fs "^4.1.2"
+    graceful-fs "^4.2.0"
     jsonfile "^4.0.0"
     universalify "^0.1.0"
 
@@ -5695,10 +5758,10 @@ grapheme-splitter@^1.0.4:
   resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e"
   integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==
 
-growi-commons@^4.0.7:
-  version "4.0.7"
-  resolved "https://registry.yarnpkg.com/growi-commons/-/growi-commons-4.0.7.tgz#f9ff9c2f6afe3a9982b689d368e8e7a000d137e8"
-  integrity sha512-Ipku+WlfXsSqS+2fuDRfOeCP5Q8dBo/8XYlcjsPFdCvKa9lBr2OsSN2ac9dAXaL9bH4wc+Kz7UR/OLjAMlx0Iw==
+growi-commons@^4.0.8:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/growi-commons/-/growi-commons-4.0.8.tgz#3040f2759f5eb13084b101e303c11028af2a8faf"
+  integrity sha512-AL/vm3R3LqiWwCbuEHPsDP8hapiz7ulKZXyW4399WHorx/7oEkSWb6sGdkvJiqSnRWxEcdPkfw6K0kgzilMpig==
 
 growly@^1.3.0:
   version "1.3.0"
@@ -6085,9 +6148,12 @@ humanize-ms@^1.2.1:
   dependencies:
     ms "^2.0.0"
 
-i18next-browser-languagedetector@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-3.0.1.tgz#a47c43176e8412c91e808afb7c6eb5367649aa8e"
+i18next-browser-languagedetector@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-4.0.1.tgz#6a0b44a93835146287130da36ce3d04a1836879f"
+  integrity sha512-RxSoX6mB8cab0CTIQ+klCS764vYRj+Jk621cnFVsINvcdlb/cdi3vQFyrPwmnowB7ReUadjHovgZX+RPIzHVQQ==
+  dependencies:
+    "@babel/runtime" "^7.5.5"
 
 i18next-express-middleware@^1.4.1:
   version "1.4.1"
@@ -6106,10 +6172,10 @@ i18next-sprintf-postprocessor@^0.2.2:
   version "0.2.2"
   resolved "https://registry.yarnpkg.com/i18next-sprintf-postprocessor/-/i18next-sprintf-postprocessor-0.2.2.tgz#2e409f1043579382698b6a2da70cdaa551d67ea4"
 
-i18next@^17.0.3:
-  version "17.0.3"
-  resolved "https://registry.yarnpkg.com/i18next/-/i18next-17.0.3.tgz#82d67826d13e8ca2cd2a3c87871533414e952d03"
-  integrity sha512-vQyW6a4ZLt3Dxnd6GXSnhbW5DwGYC4uLPKk1MFE5pfFbR9CEiNatdwwUZDQfrcNOh2x0eOGDFYeCEyLlkLvDQA==
+i18next@^19.0.0:
+  version "19.0.0"
+  resolved "https://registry.yarnpkg.com/i18next/-/i18next-19.0.0.tgz#5418207d7286128e6cfe558e659fa8c60d89794b"
+  integrity sha512-xxNKNOqLdGP/M+/fzzBOhcc9hCAqv6gDhHq0xbYz/Vlz5PlMfr9P1LbBvmk7RkZjYoh/kyM1tnfSl+sJ2VaD0Q==
   dependencies:
     "@babel/runtime" "^7.3.1"
 
@@ -6532,6 +6598,13 @@ is-glob@^4.0.0:
   dependencies:
     is-extglob "^2.1.1"
 
+is-glob@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc"
+  integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
+  dependencies:
+    is-extglob "^2.1.1"
+
 is-hexadecimal@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.2.tgz#b6e710d7d07bb66b98cb8cece5c9b4921deeb835"
@@ -6686,10 +6759,6 @@ is-whitespace-character@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/is-whitespace-character/-/is-whitespace-character-1.0.2.tgz#ede53b4c6f6fb3874533751ec9280d01928d03ed"
 
-is-windows@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.1.tgz#310db70f742d259a16a369202b51af84233310d9"
-
 is-windows@^1.0.1, is-windows@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
@@ -7753,6 +7822,11 @@ lodash.sortby@^4.7.0:
   resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
   integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
 
+lodash.unescape@4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/lodash.unescape/-/lodash.unescape-4.0.1.tgz#bf2249886ce514cda112fae9218cdc065211fc9c"
+  integrity sha1-vyJJiGzlFM2hEvrpIYzcBlIR/Jw=
+
 lodash.union@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88"
@@ -7762,7 +7836,12 @@ lodash.uniq@^4.5.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
 
-lodash@4.17.11, lodash@>=4.17.11, lodash@^4.17.11, lodash@~4.17.10:
+lodash@4.17.15, lodash@^4.17.14, lodash@^4.17.15:
+  version "4.17.15"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
+  integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
+
+lodash@>=4.17.11, lodash@^4.17.11, lodash@~4.17.10:
   version "4.17.11"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
 
@@ -7778,11 +7857,6 @@ lodash@^4.17.10, lodash@^4.17.5:
   version "4.17.10"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.10.tgz#1b7793cf7259ea38fb3661d4d38b3260af8ae4e7"
 
-lodash@^4.17.14, lodash@^4.17.15:
-  version "4.17.15"
-  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
-  integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
-
 log-symbols@^2.0.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a"
@@ -8153,18 +8227,18 @@ micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4:
     snapdragon "^0.8.1"
     to-regex "^3.0.2"
 
-migrate-mongo@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/migrate-mongo/-/migrate-mongo-6.0.0.tgz#78ca370cb7b22fa6b305da8e44afc5f9b8de6daa"
-  integrity sha512-shNyzAOzHd5mh3Xjs8KGOCWz+u7ea3x8b16oJhQUWazBCHFOMV7DEor+sfjl1a5dmkCq83iS36wHgx4tVp72Vw==
+migrate-mongo@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/migrate-mongo/-/migrate-mongo-7.0.1.tgz#3c3bc524862525ae644d7fc097e458bbbe05aa10"
+  integrity sha512-Z3uytjSw7STfWFn2iqjbQ37h/ghI0xTejWCcWwNyatxwjX2ekPhN283SSf2A50b0lFa2ZHidT8Ti2JkUw+nkhw==
   dependencies:
-    cli-table "0.3.1"
-    commander "2.20.0"
-    date-fns "1.30.1"
+    cli-table3 "0.5.1"
+    commander "4.0.0"
+    date-fns "2.6.0"
     fn-args "5.0.0"
-    fs-extra "8.0.1"
-    lodash "4.17.11"
-    mongodb "3.2.7"
+    fs-extra "8.1.0"
+    lodash "4.17.15"
+    mongodb "3.3.3"
     p-each-series "2.1.0"
 
 miller-rabin@^4.0.0:
@@ -8399,7 +8473,18 @@ mongodb@3.1.10:
     mongodb-core "3.1.9"
     safe-buffer "^5.1.2"
 
-mongodb@3.2.7, mongodb@^3.1.0:
+mongodb@3.3.3:
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.3.3.tgz#509cad2225a1c56c65a331ed73a0d5d4ed5cbe67"
+  integrity sha512-MdRnoOjstmnrKJsK8PY0PjP6fyF/SBS4R8coxmhsfEU7tQ46/J6j+aSHF2n4c2/H8B+Hc/Klbfp8vggZfI0mmA==
+  dependencies:
+    bson "^1.1.1"
+    require_optional "^1.0.1"
+    safe-buffer "^5.1.2"
+  optionalDependencies:
+    saslprep "^1.0.0"
+
+mongodb@^3.1.0:
   version "3.2.7"
   resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.2.7.tgz#8ba149e4be708257cad0dea72aebb2bbb311a7ac"
   integrity sha512-2YdWrdf1PJgxcCrT1tWoL6nHuk6hCxhddAAaEh8QJL231ci4+P9FLyqopbTm2Z2sAU6mhCri+wd9r1hOcHdoMw==
@@ -9640,6 +9725,11 @@ path-key@^2.0.0, path-key@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
 
+path-key@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.0.tgz#99a10d870a803bdd5ee6f0470e58dfcd2f9a54d3"
+  integrity sha512-8cChqz0RP6SHJkMt48FW0A7+qUOn+OsnOsVtzI59tZ8m+5bCSk7hzwET0pulwOM2YMn9J1efb07KB9l9f30SGg==
+
 path-parse@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.5.tgz#3c1adf871ea9cd6c9431b6ea2bd74a0ff055c4c1"
@@ -10613,9 +10703,10 @@ react-hotkeys@^2.0.0:
   dependencies:
     prop-types "^15.6.1"
 
-react-i18next@^10.6.1:
-  version "10.6.1"
-  resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-10.6.1.tgz#044c39fb463a8d96cc548509187a1bb316e660fa"
+react-i18next@^11.1.0:
+  version "11.1.0"
+  resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-11.1.0.tgz#94e298e1a115100bebbaa0245921aab69529b956"
+  integrity sha512-FxHNBA6ptW7cygTpbIz5GAN9lrsB89xqgwpD3AzwSB4CDQsd+9i9/Je3Hc3VF6joAb1khFmCUO3ETJFLSrmuwg==
   dependencies:
     "@babel/runtime" "^7.3.1"
     html-parse-stringify2 "2.0.1"
@@ -11658,10 +11749,22 @@ shebang-command@^1.2.0:
   dependencies:
     shebang-regex "^1.0.0"
 
+shebang-command@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
+  integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
+  dependencies:
+    shebang-regex "^3.0.0"
+
 shebang-regex@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
 
+shebang-regex@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
+  integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
+
 shell-quote@^1.6.1:
   version "1.6.1"
   resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.6.1.tgz#f4781949cce402697127430ea3b3c5476f481767"
@@ -12897,6 +13000,11 @@ tryer@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8"
 
+tslib@^1.8.1:
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
+  integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
+
 tslib@^1.9.0:
   version "1.9.2"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.2.tgz#8be0cc9a1f6dc7727c38deb16c2ebd1a2892988e"
@@ -12905,6 +13013,13 @@ tsscmp@1.0.6:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
 
+tsutils@^3.17.1:
+  version "3.17.1"
+  resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759"
+  integrity sha512-kzeQ5B8H3w60nFY2g8cJIuH7JDpsALXySGtwGJ0p2LSjLgay3NdIpqq5SoOBe46bKDW2iq25irHCr8wjomUS2g==
+  dependencies:
+    tslib "^1.8.1"
+
 tty-browserify@0.0.0:
   version "0.0.0"
   resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
@@ -13335,11 +13450,16 @@ validator@>=11.0.0:
   resolved "https://registry.yarnpkg.com/validator/-/validator-11.0.0.tgz#fb10128bfb1fd14ce4ed36b79fc94289eae70667"
   integrity sha512-+wnGLYqaKV2++nUv60uGzUJyJQwYVOin6pn1tgEiFCeCQO60yeu3Og9/yPccbBX574kxIcEJicogkzx6s6eyag==
 
-validator@^11.0.0, validator@^11.1.0:
+validator@^11.0.0:
   version "11.1.0"
   resolved "https://registry.yarnpkg.com/validator/-/validator-11.1.0.tgz#ac18cac42e0aa5902b603d7a5d9b7827e2346ac4"
   integrity sha512-qiQ5ktdO7CD6C/5/mYV4jku/7qnqzjrxb3C/Q5wR3vGGinHTgJZN/TdFT3ZX4vXhX2R1PXx42fB1cn5W+uJ4lg==
 
+validator@^12.0.0:
+  version "12.0.0"
+  resolved "https://registry.yarnpkg.com/validator/-/validator-12.0.0.tgz#fb33221f5320abe2422cda2f517dc3838064e813"
+  integrity sha512-r5zA1cQBEOgYlesRmSEwc9LkbfNLTtji+vWyaHzRZUxCTHdsX3bd+sdHfs5tGZ2W6ILGGsxWxCNwT/h3IY/3ng==
+
 validator@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/validator/-/validator-2.1.0.tgz#63276570def208adcf1c032c1f4e6a17d2bd8d8b"
@@ -13602,6 +13722,13 @@ which@^1.2.9:
   dependencies:
     isexe "^2.0.0"
 
+which@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/which/-/which-2.0.1.tgz#f1cf94d07a8e571b6ff006aeb91d0300c47ef0a4"
+  integrity sha512-N7GBZOTswtB9lkQBZA4+zAXrjEIWAUOB93AvzUiudRzRxhUdLURQ7D/gAIMY1gatT/LTbmbcv8SiYazy3eYB7w==
+  dependencies:
+    isexe "^2.0.0"
+
 wide-align@^1.1.0:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.2.tgz#571e0f1b0604636ebc0dfc21b0339bbe31341710"