Browse Source

Merge pull request #732 from weseek/imprv/refactor-acl-search

Imprv/refactor acl search
Yuki Takei 7 years ago
parent
commit
805677df9c

+ 34 - 44
resource/search/mappings.json

@@ -24,13 +24,6 @@
         }
       },
       "analyzer": {
-        "autocomplete": {
-          "tokenizer":  "keyword",
-          "filter": [
-            "lowercase",
-            "nGram"
-          ]
-        },
         "japanese": {
           "tokenizer": "kuromoji_tokenizer",
           "char_filter" : ["icu_normalizer"]
@@ -48,52 +41,40 @@
     }
   },
   "mappings": {
-    "users": {
-      "properties" : {
-        "name": {
-          "type": "text",
-          "analyzer": "autocomplete"
-        }
-      }
-    },
     "pages": {
       "properties" : {
         "path": {
           "type": "text",
-          "copy_to": ["path_raw", "path_ja", "path_en"],
-          "index": "false"
-        },
-        "path_raw": {
-          "type": "text",
-          "analyzer": "standard"
-        },
-        "path_ja": {
-          "type": "text",
-          "analyzer": "japanese"
-        },
-        "path_en": {
-          "type": "text",
-          "analyzer": "english"
+          "fields": {
+            "raw": {
+              "type": "text",
+              "analyzer": "keyword"
+            },
+            "ja": {
+              "type": "text",
+              "analyzer": "japanese"
+            },
+            "en": {
+              "type": "text",
+              "analyzer": "english"
+            }
+          }
         },
         "body": {
           "type": "text",
-          "copy_to": ["body_raw", "body_ja", "body_en"],
-          "index": "false"
-        },
-        "body_raw": {
-          "type": "text",
-          "analyzer": "standard"
-        },
-        "body_ja": {
-          "type": "text",
-          "analyzer": "japanese"
-        },
-        "body_en": {
-          "type": "text",
-          "analyzer": "english"
+          "fields": {
+            "ja": {
+              "type": "text",
+              "analyzer": "japanese"
+            },
+            "en": {
+              "type": "text",
+              "analyzer": "english"
+            }
+          }
         },
         "username": {
-          "type": "text"
+          "type": "keyword"
         },
         "comment_count": {
           "type": "integer"
@@ -104,6 +85,15 @@
         "like_count": {
           "type": "integer"
         },
+        "grant": {
+          "type": "integer"
+        },
+        "granted_users": {
+          "type": "keyword"
+        },
+        "granted_group": {
+          "type": "keyword"
+        },
         "created_at": {
           "type": "date",
           "format": "dateOptionalTime"

+ 9 - 4
src/client/js/app.js

@@ -3,8 +3,6 @@ import ReactDOM from 'react-dom';
 import { I18nextProvider } from 'react-i18next';
 import * as toastr from 'toastr';
 
-import io from 'socket.io-client';
-
 import i18nFactory from './i18n';
 
 import loggerFactory from '@alias/logger';
@@ -38,6 +36,7 @@ import RecentCreated from './components/RecentCreated/RecentCreated';
 import CustomCssEditor  from './components/Admin/CustomCssEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
 import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
+import AdminRebuildSearch from './components/Admin/AdminRebuildSearch';
 
 import * as entities from 'entities';
 
@@ -50,8 +49,6 @@ if (!window) {
 const userlang = $('body').data('userlang');
 const i18n = i18nFactory(userlang);
 
-const socket = io();
-
 // setup xss library
 const xss = new Xss();
 window.xss = xss;
@@ -95,6 +92,7 @@ crowi.setConfig(JSON.parse(document.getElementById('crowi-context-hydrate').text
 if (isLoggedin) {
   crowi.fetchUsers();
 }
+const socket = crowi.getWebSocket();
 const socketClientId = crowi.getSocketClientId();
 
 const crowiRenderer = new GrowiRenderer(crowi, null, {
@@ -475,6 +473,13 @@ if (customHeaderEditorElem != null) {
     customHeaderEditorElem
   );
 }
+const adminRebuildSearchElem = document.getElementById('admin-rebuild-search');
+if (adminRebuildSearchElem != null) {
+  ReactDOM.render(
+    <AdminRebuildSearch crowi={crowi} />,
+    adminRebuildSearchElem
+  );
+}
 
 // notification from websocket
 function updatePageStatusAlert(page, user) {

+ 66 - 0
src/client/js/components/Admin/AdminRebuildSearch.jsx

@@ -0,0 +1,66 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class AdminRebuildSearch extends React.Component {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isCompleted: false,
+      total: 0,
+      current: 0,
+      skip: 0,
+    };
+  }
+
+  componentDidMount() {
+    const socket = this.props.crowi.getWebSocket();
+
+    socket.on('admin:addPageProgress', data => {
+      const newStates = Object.assign(data, { isCompleted: false });
+      this.setState(newStates);
+    });
+
+    socket.on('admin:finishAddPage', data => {
+      const newStates = Object.assign(data, { isCompleted: true });
+      this.setState(newStates);
+    });
+  }
+
+  render() {
+    const { total, current, skip, isCompleted } = this.state;
+    if (total === 0) {
+      return null;
+    }
+
+    const progressBarLabel = isCompleted ? 'Completed' : `Processing.. ${current}/${total} (${skip} skips)`;
+    const progressBarWidth = isCompleted ? '100%' : `${(current / total) * 100}%`;
+    const progressBarClassNames = isCompleted
+      ? 'progress-bar progress-bar-success'
+      : 'progress-bar progress-bar-striped progress-bar-animated active';
+
+    return (
+      <div>
+        <h5>
+          {progressBarLabel}
+          <span className="pull-right">{progressBarWidth}</span>
+        </h5>
+        <div className="progress progress-sm">
+          <div
+            className={progressBarClassNames}
+            role="progressbar"
+            aria-valuemin="0"
+            aria-valuenow={current}
+            aria-valuemax={total}
+            style={{ width: progressBarWidth }}
+          >
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
+
+AdminRebuildSearch.propTypes = {
+  crowi: PropTypes.object.isRequired,
+};

+ 7 - 0
src/client/js/util/Crowi.js

@@ -3,6 +3,7 @@
  */
 
 import axios from 'axios';
+import io from 'socket.io-client';
 
 import InterceptorManager from '@commons/service/interceptor-manager';
 
@@ -50,6 +51,8 @@ export default class Crowi {
     this.editorOptions = {};
 
     this.recoverData();
+
+    this.socket = io();
   }
 
   /**
@@ -75,6 +78,10 @@ export default class Crowi {
     this.pageEditor = pageEditor;
   }
 
+  getWebSocket() {
+    return this.socket;
+  }
+
   getSocketClientId() {
     return this.socketClientId;
   }

+ 1 - 4
src/client/styles/agile-admin/inverse/widgets.scss

@@ -808,7 +808,6 @@ border-radius:$radius;
 */
 
 /*Progressbars*/
-/*
 .progress {
 -webkit-box-shadow: none !important;
 background-color: $border;
@@ -894,10 +893,9 @@ animation-duration: 5s;
 animation-name: myanimation;
 transition: 5s all;
 }
-*/
+
 
 /* Progressbar Animated */
-/*
 @-webkit-keyframes myanimation {
 from {
   width:0;
@@ -908,7 +906,6 @@ from {
   width:0;
 }
 }
-*/
 
 /* Progressbar Vertical */
 /*

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

@@ -54,6 +54,8 @@ function Crowi(rootdir) {
   this.events = {
     user: new (require(self.eventsDir + 'user'))(this),
     page: new (require(self.eventsDir + 'page'))(this),
+    search: new (require(self.eventsDir + 'search'))(this),
+    bookmark: new (require(self.eventsDir + 'bookmark'))(this),
   };
 
 }

+ 15 - 0
src/server/events/bookmark.js

@@ -0,0 +1,15 @@
+// var debug = require('debug')('crowi:events:page')
+const util = require('util');
+const events = require('events');
+
+function BookmarkEvent(crowi) {
+  this.crowi = crowi;
+
+  events.EventEmitter.call(this);
+}
+util.inherits(BookmarkEvent, events.EventEmitter);
+
+BookmarkEvent.prototype.onCreate = function(bookmark) {};
+BookmarkEvent.prototype.onDelete = function(bookmark) {};
+
+module.exports = BookmarkEvent;

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

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

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

@@ -13,6 +13,10 @@ module.exports = function(crowi) {
   });
   bookmarkSchema.index({page: 1, user: 1}, {unique: true});
 
+  bookmarkSchema.statics.countByPageId = async function(pageId) {
+    return await this.count({ page: pageId });
+  };
+
   bookmarkSchema.statics.populatePage = async function(bookmarks) {
     const Bookmark = this;
     const User = crowi.model('User');

+ 58 - 98
src/server/models/page.js

@@ -71,6 +71,21 @@ const addSlashOfEnd = (path) => {
   return returnPath;
 };
 
+/**
+ * populate page (Query or Document) to show revision
+ * @param {any} page Query or Document
+ * @param {string} userPublicFields string to set to select
+ */
+const populateDataToShowRevision = (page, userPublicFields) => {
+  return page
+    .populate({ path: 'lastUpdateUser', model: 'User', select: userPublicFields })
+    .populate({ path: 'creator', model: 'User', select: userPublicFields })
+    .populate({ path: 'grantedGroup', model: 'UserGroup' })
+    .populate({ path: 'revision', model: 'Revision', populate: {
+      path: 'author', model: 'User', select: userPublicFields
+    } });
+};
+
 
 class PageQueryBuilder {
   constructor(query) {
@@ -199,6 +214,12 @@ class PageQueryBuilder {
 
     return this;
   }
+
+  populateDataToShowRevision(userPublicFields) {
+    this.query = populateDataToShowRevision(this.query, userPublicFields);
+    return this;
+  }
+
 }
 
 module.exports = function(crowi) {
@@ -365,28 +386,18 @@ module.exports = function(crowi) {
     });
   };
 
-  pageSchema.methods.populateDataToShow = async function(revisionId) {
-    validateCrowi();
-
-    const User = crowi.model('User');
-
+  pageSchema.methods.initLatestRevisionField = async function(revisionId) {
     this.latestRevision = this.revision;
     if (revisionId != null) {
       this.revision = revisionId;
     }
-    this.likerCount = this.liker.length || 0;
-    this.seenUsersCount = this.seenUsers.length || 0;
+  };
 
-    return this
-      .populate([
-        {path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS},
-        {path: 'creator', model: 'User', select: User.USER_PUBLIC_FIELDS},
-        {path: 'revision', model: 'Revision', populate: {
-          path: 'author', model: 'User', select: User.USER_PUBLIC_FIELDS
-        }},
-        //{path: 'liker', options: { limit: 11 }},
-        //{path: 'seenUsers', options: { limit: 11 }},
-      ])
+  pageSchema.methods.populateDataToShowRevision = async function() {
+    validateCrowi();
+
+    const User = crowi.model('User');
+    return populateDataToShowRevision(this, User.USER_PUBLIC_FIELDS)
       .execPopulate();
   };
 
@@ -398,31 +409,6 @@ module.exports = function(crowi) {
     return this.populate('revision').execPopulate();
   };
 
-  // TODO abolish or migrate
-  // https://weseek.myjetbrains.com/youtrack/issue/GC-1185
-  pageSchema.statics.populatePageListToAnyObjects = function(pageIdObjectArray) {
-    var Page = this;
-    var pageIdMappings = {};
-    var pageIds = pageIdObjectArray.map(function(page, idx) {
-      if (!page._id) {
-        throw new Error('Pass the arg of populatePageListToAnyObjects() must have _id on each element.');
-      }
-
-      pageIdMappings[String(page._id)] = idx;
-      return page._id;
-    });
-
-    return new Promise(function(resolve, reject) {
-      Page.findListByPageIds(pageIds, {limit: 100}) // limit => if the pagIds is greater than 100, ignore
-      .then(function(pages) {
-        pages.forEach(function(page) {
-          Object.assign(pageIdObjectArray[pageIdMappings[String(page._id)]], page._doc);
-        });
-
-        resolve(pageIdObjectArray);
-      });
-    });
-  };
 
   pageSchema.statics.updateCommentCount = function(pageId) {
     validateCrowi();
@@ -638,6 +624,27 @@ module.exports = function(crowi) {
     return await findListFromBuilderAndViewer(builder, currentUser, opt);
   };
 
+  pageSchema.statics.findListByPageIds = async function(ids, user, option) {
+    const User = crowi.model('User');
+
+    const opt = Object.assign({}, option);
+    const builder = new PageQueryBuilder(this.find({ _id: { $in: ids } }));
+
+    builder.addConditionToExcludeRedirect();
+    builder.addConditionToPagenate(opt.offset, opt.limit);
+    builder.populateDataToShowRevision(User.USER_PUBLIC_FIELDS);  // TODO omit this line after fixing GC-1323
+                                                                  // https://weseek.myjetbrains.com/youtrack/issue/GC-1323
+
+    const totalCount = await builder.query.exec('count');
+    const q = builder.query;
+    const pages = await q.exec('find');
+
+    const result = { pages, totalCount, offset: opt.offset, limit: opt.limit };
+    return result;
+
+  };
+
+
   /**
    * find pages by PageQueryBuilder
    * @param {PageQueryBuilder} builder
@@ -669,11 +676,7 @@ module.exports = function(crowi) {
 
     const totalCount = await builder.query.exec('count');
     const q = builder.query
-      .populate({
-        path: 'lastUpdateUser',
-        model: 'User',
-        select: User.USER_PUBLIC_FIELDS
-      });
+      .populate({ path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS });
     const pages = await q.exec('find');
 
     const result = { pages, totalCount, offset: opt.offset, limit: opt.limit };
@@ -802,65 +805,18 @@ module.exports = function(crowi) {
     return templateBody;
   };
 
-  // TODO refactor
-  // https://weseek.myjetbrains.com/youtrack/issue/GC-1185
-  pageSchema.statics.findListByPageIds = function(ids, options) {
-    validateCrowi();
-
-    const Page = this;
-    const User = crowi.model('User');
-    const limit = options.limit || 50
-      , offset = options.skip || 0
-      ;
-    options = options || {};
-
-    return new Promise(function(resolve, reject) {
-      Page
-      .find({ _id: { $in: ids }, grant: GRANT_PUBLIC })
-      //.sort({createdAt: -1}) // TODO optionize
-      .skip(offset)
-      .limit(limit)
-      .populate([
-        {path: 'creator', model: 'User', select: User.USER_PUBLIC_FIELDS},
-        {path: 'revision', model: 'Revision'},
-      ])
-      .exec(function(err, pages) {
-        if (err) {
-          return reject(err);
-        }
-
-        Page.populate(pages, {path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS}, function(err, data) {
-          if (err) {
-            return reject(err);
-          }
-
-          return resolve(data);
-        });
-      });
-    });
-  };
-
-
   /**
    * Bulk get (for internal only)
    */
   pageSchema.statics.getStreamOfFindAll = function(options) {
-    var Page = this
-      , options = options || {}
-      , publicOnly = options.publicOnly || true
-      , criteria = {redirectTo: null, }
-      ;
-
-    if (publicOnly) {
-      criteria.grant = GRANT_PUBLIC;
-    }
+    const criteria = { redirectTo: null };
 
     return this.find(criteria)
       .populate([
-        {path: 'creator', model: 'User'},
-        {path: 'revision', model: 'Revision'},
+        { path: 'creator', model: 'User' },
+        { path: 'revision', model: 'Revision' },
       ])
-      .sort({updatedAt: -1})
+      .lean()
       .cursor();
   };
 
@@ -1263,6 +1219,10 @@ module.exports = function(crowi) {
     return addSlashOfEnd(path);
   };
 
+  pageSchema.statics.allPageCount = function() {
+    return this.count({ redirectTo: null });
+  };
+
   pageSchema.statics.GRANT_PUBLIC = GRANT_PUBLIC;
   pageSchema.statics.GRANT_RESTRICTED = GRANT_RESTRICTED;
   pageSchema.statics.GRANT_SPECIFIED = GRANT_SPECIFIED;

+ 74 - 65
src/server/routes/admin.js

@@ -1,28 +1,33 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  const debug = require('debug')('growi:routes:admin')
-    , logger = require('@alias/logger')('growi:routes:admin')
-    , fs = require('fs')
-    , models = crowi.models
-    , Page = models.Page
-    , PageGroupRelation = models.PageGroupRelation
-    , User = models.User
-    , ExternalAccount = models.ExternalAccount
-    , UserGroup = models.UserGroup
-    , UserGroupRelation = models.UserGroupRelation
-    , Config = models.Config
-    , GlobalNotificationSetting = models.GlobalNotificationSetting
-    , GlobalNotificationMailSetting = models.GlobalNotificationMailSetting
-    , GlobalNotificationSlackSetting = models.GlobalNotificationSlackSetting  // eslint-disable-line no-unused-vars
-    , PluginUtils = require('../plugins/plugin-utils')
-    , pluginUtils = new PluginUtils()
-    , ApiResponse = require('../util/apiResponse')
-    , recommendedXssWhiteList = require('@commons/service/xss/recommendedXssWhiteList')
-    , importer = require('../util/importer')(crowi)
-
-    , MAX_PAGE_LIST = 50
-    , actions = {};
+  const debug = require('debug')('growi:routes:admin');
+  const logger = require('@alias/logger')('growi:routes:admin');
+  const fs = require('fs');
+
+  const models = crowi.models;
+  const Page = models.Page;
+  const PageGroupRelation = models.PageGroupRelation;
+  const User = models.User;
+  const ExternalAccount = models.ExternalAccount;
+  const UserGroup = models.UserGroup;
+  const UserGroupRelation = models.UserGroupRelation;
+  const Config = models.Config;
+  const GlobalNotificationSetting = models.GlobalNotificationSetting;
+  const GlobalNotificationMailSetting = models.GlobalNotificationMailSetting;
+  const GlobalNotificationSlackSetting = models.GlobalNotificationSlackSetting; // eslint-disable-line no-unused-vars
+
+  const recommendedXssWhiteList = require('@commons/service/xss/recommendedXssWhiteList');
+  const PluginUtils = require('../plugins/plugin-utils');
+  const ApiResponse = require('../util/apiResponse');
+  const importer = require('../util/importer')(crowi);
+
+  const searchEvent = crowi.event('search');
+  const pluginUtils = new PluginUtils();
+
+  const MAX_PAGE_LIST = 50;
+  const actions = {};
+
 
   function createPager(total, limit, page, pagesCount, maxPageList) {
     const pager = {
@@ -294,12 +299,6 @@ module.exports = function(crowi, app) {
     });
   };
 
-  actions.search = {};
-  actions.search.index = function(req, res) {
-    return res.render('admin/search', {
-    });
-  };
-
   // app.post('/admin/notification/slackIwhSetting' , admin.notification.slackIwhSetting);
   actions.notification.slackIwhSetting = function(req, res) {
     var slackIwhSetting = req.form.slackIwhSetting;
@@ -425,47 +424,14 @@ module.exports = function(crowi, app) {
     return triggerEvents;
   };
 
-  actions.search.buildIndex = function(req, res) {
-    var search = crowi.getSearcher();
+  actions.search = {}
+  actions.search.index = function(req, res) {
+    const search = crowi.getSearcher();
     if (!search) {
       return res.redirect('/admin');
     }
 
-    return new Promise(function(resolve, reject) {
-      search.deleteIndex()
-        .then(function(data) {
-          debug('Index deleted.');
-          resolve();
-        }).catch(function(err) {
-          debug('Delete index Error, but if it is initialize, its ok.', err);
-          resolve();
-        });
-    })
-    .then(function() {
-      return search.buildIndex();
-    })
-    .then(function(data) {
-      if (!data.errors) {
-        debug('Index created.');
-      }
-      return search.addAllPages();
-    })
-    .then(function(data) {
-      if (!data.errors) {
-        debug('Data is successfully indexed.');
-        req.flash('successMessage', 'Data is successfully indexed.');
-      }
-      else {
-        debug('Data index error.', data.errors);
-        req.flash('errorMessage', `Data index error: ${data.errors}`);
-      }
-      return res.redirect('/admin/search');
-    })
-    .catch(function(err) {
-      debug('Error', err);
-      req.flash('errorMessage', `Error: ${err}`);
-      return res.redirect('/admin/search');
-    });
+    return res.render('admin/search', {});
   };
 
   actions.user = {};
@@ -1416,6 +1382,49 @@ module.exports = function(crowi, app) {
     }
   };
 
+
+  actions.api.searchBuildIndex = async function(req, res) {
+    const search = crowi.getSearcher();
+    if (!search) {
+      return res.json(ApiResponse.error('ElasticSearch Integration is not set up.'));
+    }
+
+    // first, delete index
+    try {
+      await search.deleteIndex();
+    }
+    catch (err) {
+      logger.warn('Delete index Error, but if it is initialize, its ok.', err);
+    }
+
+    // second, create index
+    try {
+      await search.buildIndex();
+    }
+    catch (err) {
+      logger.error('Error', err);
+      return res.json(ApiResponse.error(err));
+    }
+
+    searchEvent.on('addPageProgress', (total, current, skip) => {
+      crowi.getIo().sockets.emit('admin:addPageProgress', { total, current, skip });
+    });
+    searchEvent.on('finishAddPage', (total, current, skip) => {
+      crowi.getIo().sockets.emit('admin:finishAddPage', { total, current, skip });
+    });
+    // add all page
+    search
+      .addAllPages()
+      .then(() => {
+        debug('Data is successfully indexed. ------------------ ✧✧');
+      })
+      .catch(err => {
+        logger.error('Error', err);
+      });
+
+    return res.json(ApiResponse.success());
+  };
+
   /**
    * save settings, update config cache, and response json
    *

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

@@ -102,7 +102,7 @@ module.exports = function(crowi, app) {
 
   // search admin
   app.get('/admin/search'              , loginRequired(crowi, app) , middleware.adminRequired() , admin.search.index);
-  app.post('/admin/search/build'       , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.search.buildIndex);
+  app.post('/_api/admin/search/build'  , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.api.searchBuildIndex);
 
   // notification admin
   app.get('/admin/notification'              , loginRequired(crowi, app) , middleware.adminRequired() , admin.notification.index);

+ 12 - 34
src/server/routes/page.js

@@ -201,9 +201,12 @@ module.exports = function(crowi, app) {
       return next();
     }
     else if (portalPageStatus === PORTAL_STATUS_EXISTS) {
-      // populate
       let portalPage = await Page.findByPathAndViewer(path, req.user);
-      portalPage = await portalPage.populateDataToShow(revisionId);
+      portalPage.initLatestRevisionField(revisionId);
+
+      // populate
+      portalPage = await portalPage.populateDataToShowRevision();
+
       addRendarVarsForPage(renderVars, portalPage);
       await addRenderVarsForSlack(renderVars, portalPage);
     }
@@ -241,8 +244,10 @@ module.exports = function(crowi, app) {
 
     let view = 'customlayout-selector/page';
 
+    page.initLatestRevisionField(revisionId);
+
     // populate
-    page = await page.populateDataToShow(revisionId);
+    page = await page.populateDataToShowRevision();
     addRendarVarsForPage(renderVars, page);
 
     await addRenderVarsForSlack(renderVars, page);
@@ -434,36 +439,6 @@ module.exports = function(crowi, app) {
 
   };
 
-  actions.search = function(req, res) {
-    // spec: ?q=query&sort=sort_order&author=author_filter
-    const query = req.query.q;
-    const search = require('../util/search')(crowi);
-
-    search.searchPageByKeyword(query)
-    .then(function(pages) {
-      debug('pages', pages);
-
-      if (pages.hits.total <= 0) {
-        return Promise.resolve([]);
-      }
-
-      const ids = pages.hits.hits.map(function(page) {
-        return page._id;
-      });
-
-      return Page.findListByPageIds(ids);
-    }).then(function(pages) {
-
-      res.render('customlayout-selector/page_list', {
-        path: '/',
-        pages: pagePathUtils.encodePagesPath(pages),
-        pager: generatePager(0, 50)
-      });
-    }).catch(function(err) {
-      debug('search error', err);
-    });
-  };
-
   /**
    * redirector
    */
@@ -684,7 +659,10 @@ module.exports = function(crowi, app) {
       else if (pagePath) {
         page = await Page.findByPathAndViewer(pagePath, req.user);
       }
-      page.populateDataToShow();
+      page.initLatestRevisionField();
+
+      // populate
+      page = await page.populateDataToShowRevision();
     }
     catch (err) {
       return res.json(ApiResponse.error(err));

+ 52 - 36
src/server/routes/search.js

@@ -1,17 +1,16 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  var debug = require('debug')('growi:routes:search')
-    , Page = crowi.model('Page')
-    , User = crowi.model('User')
-    , ApiResponse = require('../util/apiResponse')
-
-    , actions = {};
-  var api = actions.api = {};
+  // var debug = require('debug')('growi:routes:search')
+  const Page = crowi.model('Page');
+  const ApiResponse = require('../util/apiResponse');
+  const ApiPaginate = require('../util/apiPaginate');
+  const actions = {};
+  const api = (actions.api = {});
 
   actions.searchPage = function(req, res) {
-    var keyword = req.query.q || null;
-    var search = crowi.getSearcher();
+    const keyword = req.query.q || null;
+    const search = crowi.getSearcher();
     if (!search) {
       return res.json(ApiResponse.error('Configuration of ELASTICSEARCH_URI is required.'));
     }
@@ -28,47 +27,64 @@ module.exports = function(crowi, app) {
    *
    * @apiParam {String} q keyword
    * @apiParam {String} path
+   * @apiParam {String} offset
+   * @apiParam {String} limit
    */
-  api.search = function(req, res) {
-    var keyword = req.query.q || null;
-    var tree = req.query.tree || null;
+  api.search = async function(req, res) {
+    const user = req.user;
+    const { q: keyword = null, tree = null, type = null } = req.query;
+    let paginateOpts;
+
+    try {
+      paginateOpts = ApiPaginate.parseOptionsForElasticSearch(req.query);
+    }
+    catch (e) {
+      res.json(ApiResponse.error(e));
+    }
+
     if (keyword === null || keyword === '') {
       return res.json(ApiResponse.error('keyword should not empty.'));
     }
 
-    var search = crowi.getSearcher();
+    const search = crowi.getSearcher();
     if (!search) {
       return res.json(ApiResponse.error('Configuration of ELASTICSEARCH_URI is required.'));
     }
 
+    let userGroups = [];
+    if (user != null) {
+      const UserGroupRelation = crowi.model('UserGroupRelation');
+      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+
+    const searchOpts = { ...paginateOpts, type };
+
+    const result = {};
+    try {
+      let esResult;
+      if (tree) {
+        esResult = await search.searchKeywordUnderPath(keyword, tree, user, userGroups, searchOpts);
+      }
+      else {
+        esResult = await search.searchKeyword(keyword, user, userGroups, searchOpts);
+      }
+
+      const findResult = await Page.findListByPageIds(esResult.data);
 
-    var doSearch;
-    if (tree) {
-      doSearch = search.searchKeywordUnderPath(keyword, tree, {});
+      result.meta = esResult.meta;
+      result.totalCount = findResult.totalCount;
+      result.data = findResult.pages
+        .map(page => {
+          page.bookmarkCount = (page._source && page._source.bookmark_count) || 0;
+          return page;
+        });
     }
-    else {
-      doSearch = search.searchKeyword(keyword, {});
+    catch (err) {
+      return res.json(ApiResponse.error(err));
     }
-    var result = {};
-    doSearch
-      .then(function(data) {
-        result.meta = data.meta;
 
-        return Page.populatePageListToAnyObjects(data.data);
-      }).then(function(pages) {
-        result.data = pages.filter(function(page) {
-          if (Object.keys(page).length < 12) { // FIXME: 12 is a number of columns.
-            return false;
-          }
-          return true;
-        });
-        return res.json(ApiResponse.success(result));
-      })
-      .catch(function(err) {
-        return res.json(ApiResponse.error(err));
-      });
+    return res.json(ApiResponse.success(result));
   };
 
-
   return actions;
 };

+ 445 - 225
src/server/util/search.js

@@ -2,24 +2,52 @@
  * Search
  */
 
-var elasticsearch = require('elasticsearch'),
-  debug = require('debug')('growi:lib:search');
+const elasticsearch = require('elasticsearch');
+const debug = require('debug')('growi:lib:search');
+const logger = require('@alias/logger')('growi:lib:search');
 
 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');
+
+  // 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/',
+      },
+    },
+  };
 
-  var uri = this.parseUri(this.esUri);
+  const uri = this.parseUri(this.esUri);
   this.host = uri.host;
-  this.index_name = uri.index_name;
+  this.indexName = uri.indexName;
 
   this.client = new elasticsearch.Client({
     host: this.host,
     requestTimeout: 5000,
-    //log: 'debug',
+    // log: 'debug',
   });
 
   this.registerUpdateEvent();
@@ -31,87 +59,126 @@ 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.syncPageCreated.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));
 };
 
 SearchClient.prototype.shouldIndexed = function(page) {
-  // FIXME: Magic Number
-  if (page.grant !== 1) {
-    return false;
-  }
-
-  if (page.redirectTo !== null) {
-    return false;
-  }
-
-  if (page.isDeleted()) {
-    return false;
-  }
-
-  return true;
+  return (page.redirectTo == null);
 };
 
-
 // BONSAI_URL is following format:
 // => https://{ID}:{PASSWORD}@{HOST}
 SearchClient.prototype.parseUri = function(uri) {
-  var index_name = 'crowi';
-  var host = uri;
-  if (m = uri.match(/^(https?:\/\/[^\/]+)\/(.+)$/)) {
+  let indexName = 'crowi';
+  let host = uri;
+  let m;
+  if ((m = uri.match(/^(https?:\/\/[^/]+)\/(.+)$/))) {
     host = m[1];
-    index_name = m[2];
+    indexName = m[2];
   }
 
   return {
     host,
-    index_name,
+    indexName,
   };
 };
 
 SearchClient.prototype.buildIndex = function(uri) {
   return this.client.indices.create({
-    index: this.index_name,
-    body: require(this.mappingFile)
+    index: this.indexName,
+    body: require(this.mappingFile),
   });
 };
 
 SearchClient.prototype.deleteIndex = function(uri) {
   return this.client.indices.delete({
-    index: this.index_name,
+    index: this.indexName,
   });
 };
 
+/**
+ * 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.prepareBodyForUpdate = function(body, page) {
   if (!Array.isArray(body)) {
     throw new Error('Body must be an array.');
   }
 
-  var command = {
+  let command = {
     update: {
-      _index: this.index_name,
+      _index: this.indexName,
       _type: 'pages',
       _id: page._id.toString(),
-    }
+    },
   };
 
-  var document = {
-    doc: {
-      path: page.path,
-      body: page.revision.body,
-      comment_count: page.commentCount,
-      bookmark_count: 0, // todo
-      like_count: page.liker.length || 0,
-      updated_at: page.updatedAt,
-    },
-    doc_as_upsert: true,
+  let document = {
+    path: page.path,
+    body: page.revision.body,
+    comment_count: page.commentCount,
+    bookmark_count: page.bookmarkCount || 0,
+    like_count: page.liker.length || 0,
+    updated_at: page.updatedAt,
   };
 
+  document = Object.assign(document, generateDocContentsRelatedToRestriction(page));
+
   body.push(command);
-  body.push(document);
+  body.push({
+    doc: document,
+    doc_as_upsert: true,
+  });
 };
 
 SearchClient.prototype.prepareBodyForCreate = function(body, page) {
@@ -119,25 +186,28 @@ SearchClient.prototype.prepareBodyForCreate = function(body, page) {
     throw new Error('Body must be an array.');
   }
 
-  var command = {
+  let command = {
     index: {
-      _index: this.index_name,
+      _index: this.indexName,
       _type: 'pages',
       _id: page._id.toString(),
-    }
+    },
   };
 
-  var document = {
+  const bookmarkCount = page.bookmarkCount || 0;
+  let document = {
     path: page.path,
     body: page.revision.body,
     username: page.creator.username,
     comment_count: page.commentCount,
-    bookmark_count: 0, // todo
+    bookmark_count: bookmarkCount,
     like_count: page.liker.length || 0,
     created_at: page.createdAt,
     updated_at: page.updatedAt,
   };
 
+  document = Object.assign(document, generateDocContentsRelatedToRestriction(page));
+
   body.push(command);
   body.push(document);
 };
@@ -147,117 +217,121 @@ SearchClient.prototype.prepareBodyForDelete = function(body, page) {
     throw new Error('Body must be an array.');
   }
 
-  var command = {
+  let command = {
     delete: {
-      _index: this.index_name,
+      _index: this.indexName,
       _type: 'pages',
       _id: page._id.toString(),
-    }
+    },
   };
 
   body.push(command);
 };
 
+SearchClient.prototype.addPages = async function(pages) {
+  const Bookmark = this.crowi.model('Bookmark');
+  const body = [];
 
-SearchClient.prototype.addPages = function(pages) {
-  var self = this;
-  var body = [];
-
-  pages.map(function(page) {
-    self.prepareBodyForCreate(body, page);
-  });
+  for (const page of pages) {
+    page.bookmarkCount = await Bookmark.countByPageId(page._id);
+    this.prepareBodyForCreate(body, page);
+  }
 
-  debug('addPages(): Sending Request to ES', body);
+  logger.debug('addPages(): Sending Request to ES', body);
   return this.client.bulk({
     body: body,
   });
 };
 
 SearchClient.prototype.updatePages = function(pages) {
-  var self = this;
-  var body = [];
+  let self = this;
+  let body = [];
 
   pages.map(function(page) {
     self.prepareBodyForUpdate(body, page);
   });
 
-  debug('updatePages(): Sending Request to ES', body);
+  logger.debug('updatePages(): Sending Request to ES', body);
   return this.client.bulk({
     body: body,
   });
 };
 
 SearchClient.prototype.deletePages = function(pages) {
-  var self = this;
-  var body = [];
+  let self = this;
+  let body = [];
 
   pages.map(function(page) {
     self.prepareBodyForDelete(body, page);
   });
 
-  debug('deletePages(): Sending Request to ES', body);
+  logger.debug('deletePages(): Sending Request to ES', body);
   return this.client.bulk({
     body: body,
   });
 };
 
-SearchClient.prototype.addAllPages = function() {
-  var self = this;
-  var Page = this.crowi.model('Page');
-  var cursor = Page.getStreamOfFindAll();
-  var body = [];
-  var sent = 0;
-  var skipped = 0;
-
-  return new Promise(function(resolve, reject) {
-    cursor.on('data', function(doc) {
-      if (!doc.creator || !doc.revision || !self.shouldIndexed(doc)) {
-        //debug('Skipped', doc.path);
-        skipped++;
-        return ;
-      }
-
-      self.prepareBodyForCreate(body, doc);
-      //debug(body.length);
-      if (body.length > 2000) {
-        sent++;
-        debug('Sending request (seq, skipped)', sent, skipped);
-        self.client.bulk({
+SearchClient.prototype.addAllPages = async function() {
+  const self = this;
+  const Page = this.crowi.model('Page');
+  const allPageCount = await Page.allPageCount();
+  const Bookmark = this.crowi.model('Bookmark');
+  const cursor = Page.getStreamOfFindAll();
+  let body = [];
+  let sent = 0;
+  let skipped = 0;
+  let total = 0;
+
+  return new Promise((resolve, reject) => {
+    const bulkSend = body => {
+      self.client
+        .bulk({
           body: body,
           requestTimeout: Infinity,
-        }).then(res => {
-          debug('addAllPages add anyway (items, errors, took): ', (res.items || []).length, res.errors, res.took);
-        }).catch(err => {
-          debug('addAllPages error on add anyway: ', err);
+        })
+        .then(res => {
+          logger.info('addAllPages add anyway (items, errors, took): ', (res.items || []).length, res.errors, res.took, 'ms');
+        })
+        .catch(err => {
+          logger.error('addAllPages error on add anyway: ', err);
         });
+    };
+
+    cursor
+      .eachAsync(async doc => {
+        if (!doc.creator || !doc.revision || !self.shouldIndexed(doc)) {
+          // debug('Skipped', doc.path);
+          skipped++;
+          return;
+        }
+        total++;
 
-        body = [];
-      }
-    }).on('error', function(err) {
-      // TODO: handle err
-      debug('Error cursor:', err);
-    }).on('close', function() {
-      // all done
-
-      // return if body is empty
-      // see: https://github.com/weseek/growi/issues/228
-      if (body.length == 0) {
-        return resolve();
-      }
+        const bookmarkCount = await Bookmark.countByPageId(doc._id);
+        const page = { ...doc, bookmarkCount };
+        self.prepareBodyForCreate(body, page);
 
-      // 最後にすべてを送信
-      self.client.bulk({
-        body: body,
-        requestTimeout: Infinity,
+        if (body.length >= 4000) {
+          // send each 2000 docs. (body has 2 elements for each data)
+          sent++;
+          logger.debug('Sending request (seq, total, skipped)', sent, total, skipped);
+          bulkSend(body);
+          this.searchEvent.emit('addPageProgress', allPageCount, total, skipped);
+
+          body = [];
+        }
       })
-      .then(function(res) {
-        debug('Reponse from es (item length, errros, took):', (res.items || []).length, res.errors, res.took);
-        return resolve(res);
-      }).catch(function(err) {
-        debug('Err from es:', err);
-        return reject(err);
+      .then(() => {
+        // send all remaining data on body[]
+        logger.debug('Sending last body of bulk operation:', body.length);
+        bulkSend(body);
+        this.searchEvent.emit('finishAddPage', allPageCount, total, skipped);
+
+        resolve();
+      })
+      .catch(e => {
+        logger.error('Error wile iterating cursor.eachAsync()', e);
+        reject(e);
       });
-    });
   });
 };
 
@@ -268,46 +342,60 @@ SearchClient.prototype.addAllPages = function() {
  *   data: [ pages ...],
  * }
  */
-SearchClient.prototype.search = function(query) {
-  var self = this;
+SearchClient.prototype.search = async function(query) {
+  let self = this;
+
+  // for debug
+  if (process.env.NODE_ENV === 'development') {
+    const result = await this.client.indices.validateQuery({
+      explain: true,
+      body: {
+        query: query.body.query
+      },
+    });
+    logger.info('ES returns explanations: ', result.explanations);
+  }
 
   return new Promise(function(resolve, reject) {
-    self.client.search(query)
-    .then(function(data) {
-      var result = {
-        meta: {
-          took: data.took,
-          total: data.hits.total,
-          results: data.hits.hits.length,
-        },
-        data: data.hits.hits.map(function(elm) {
-          return {_id: elm._id, _score: elm._score};
-        })
-      };
-
-      resolve(result);
-    }).catch(function(err) {
-      reject(err);
-    });
+    self.client
+      .search(query)
+      .then(function(data) {
+        let result = {
+          meta: {
+            took: data.took,
+            total: data.hits.total,
+            results: data.hits.hits.length,
+          },
+          data: data.hits.hits.map(function(elm) {
+            return { _id: elm._id, _score: elm._score, _source: elm._source };
+          }),
+        };
+
+        resolve(result);
+      })
+      .catch(function(err) {
+        logger.error('Search error', err);
+        reject(err);
+      });
   });
 };
 
 SearchClient.prototype.createSearchQuerySortedByUpdatedAt = function(option) {
   // getting path by default is almost for debug
-  var fields = ['path'];
+  let fields = ['path', 'bookmark_count'];
   if (option) {
     fields = option.fields || fields;
   }
 
   // default is only id field, sorted by updated_at
-  var query = {
-    index: this.index_name,
+  let query = {
+    index: this.indexName,
     type: 'pages',
     body: {
-      sort: [{ updated_at: { order: 'desc'}}],
+      sort: [{ updated_at: { order: 'desc' } }],
       query: {}, // query
       _source: fields,
-    }
+    },
   };
   this.appendResultSize(query);
 
@@ -315,20 +403,20 @@ SearchClient.prototype.createSearchQuerySortedByUpdatedAt = function(option) {
 };
 
 SearchClient.prototype.createSearchQuerySortedByScore = function(option) {
-  var fields = ['path'];
+  let fields = ['path', 'bookmark_count'];
   if (option) {
     fields = option.fields || fields;
   }
 
   // sort by score
-  var query = {
-    index: this.index_name,
+  let query = {
+    index: this.indexName,
     type: 'pages',
     body: {
-      sort: [ {_score: { order: 'desc'} }],
+      sort: [{ _score: { order: 'desc' } }],
       query: {}, // query
       _source: fields,
-    }
+    },
   };
   this.appendResultSize(query);
 
@@ -340,21 +428,32 @@ SearchClient.prototype.appendResultSize = function(query, from, size) {
   query.size = size || this.DEFAULT_LIMIT;
 };
 
-SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keyword) {
+SearchClient.prototype.initializeBoolQuery = function(query) {
   // query is created by createSearchQuerySortedByScore() or createSearchQuerySortedByUpdatedAt()
   if (!query.body.query.bool) {
     query.body.query.bool = {};
   }
-  if (!query.body.query.bool.must || !Array.isArray(query.body.query.must)) {
+
+  const isInitialized = query => !!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 (!query.body.query.bool.must_not || !Array.isArray(query.body.query.must_not)) {
+  if (!isInitialized(query.body.query.bool.must_not)) {
     query.body.query.bool.must_not = [];
   }
+  return query;
+};
 
-  var appendMultiMatchQuery = function(query, type, keywords) {
-    var target;
-    var operator = 'and';
+SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keyword) {
+  query = this.initializeBoolQuery(query);
+
+  const appendMultiMatchQuery = function(query, type, keywords) {
+    let target;
+    let operator = 'and';
     switch (type) {
       case 'not_match':
         target = query.body.query.bool.must_not;
@@ -369,21 +468,15 @@ SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keywor
       multi_match: {
         query: keywords.join(' '),
         // TODO: By user's i18n setting, change boost or search target fields
-        fields: [
-          'path_ja^2',
-          'path_en^2',
-          'body_ja',
-          // "path_en",
-          // "body_en",
-        ],
+        fields: ['path.ja^2', 'path.en^2', 'body.ja', 'body.en'],
         operator: operator,
-      }
+      },
     });
 
     return query;
   };
 
-  var parsedKeywords = this.getParsedKeywords(keyword);
+  let parsedKeywords = this.getParsedKeywords(keyword);
 
   if (parsedKeywords.match.length > 0) {
     query = appendMultiMatchQuery(query, 'match', parsedKeywords.match);
@@ -394,17 +487,18 @@ SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keywor
   }
 
   if (parsedKeywords.phrase.length > 0) {
-    var phraseQueries = [];
+    let phraseQueries = [];
     parsedKeywords.phrase.forEach(function(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_raw',
+          fields: [
+            // Not use "*.ja" fields here, because we want to analyze (parse) search words
+            'path.raw^2',
+            'body',
           ],
-        }
+        },
       });
     });
 
@@ -412,17 +506,18 @@ SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keywor
   }
 
   if (parsedKeywords.not_phrase.length > 0) {
-    var notPhraseQueries = [];
+    let notPhraseQueries = [];
     parsedKeywords.not_phrase.forEach(function(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_raw',
+          fields: [
+            // Not use "*.ja" fields here, because we want to analyze (parse) search words
+            'path.raw^2',
+            'body',
           ],
-        }
+        },
       });
     });
 
@@ -431,32 +526,140 @@ SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keywor
 };
 
 SearchClient.prototype.appendCriteriaForPathFilter = function(query, path) {
-  // query is created by createSearchQuerySortedByScore() or createSearchQuerySortedByUpdatedAt()
-  if (!query.body.query.bool) {
-    query.body.query.bool = {};
-  }
-
-  if (!query.body.query.bool.filter || !Array.isArray(query.body.query.bool.filter)) {
-    query.body.query.bool.filter = [];
-  }
+  query = this.initializeBoolQuery(query);
 
   if (path.match(/\/$/)) {
     path = path.substr(0, path.length - 1);
   }
   query.body.query.bool.filter.push({
     wildcard: {
-      'path': path + '/*'
-    }
+      'path.raw': path + '/*',
+    },
   });
 };
 
-SearchClient.prototype.searchKeyword = function(keyword, option) {
-  /* eslint-disable no-unused-vars */
-  var from = option.offset || null;
-  /* eslint-enable */
-  var query = this.createSearchQuerySortedByScore();
+SearchClient.prototype.filterPagesByViewer = function(query, user, userGroups) {
+  query = this.initializeBoolQuery(query);
+
+  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 } },
+  ];
+
+  if (user == null) {
+    grantConditions.push(
+      { term: { grant: GRANT_RESTRICTED } },
+      { term: { grant: GRANT_SPECIFIED } },
+      { term: { grant: GRANT_OWNER } },
+    );
+  }
+  else {
+    grantConditions.push(
+      { bool: {
+        must: [
+          { term: { grant: GRANT_RESTRICTED } },
+          { term: { granted_users: user._id.toString() } }
+        ]
+      } },
+      { 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 (userGroups != null && userGroups.length > 0) {
+    const userGroupIds = userGroups.map(group => 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);
+
+  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);
+
+  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);
+
+  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) {
+  const User = this.crowi.model('User');
+  const count = User.count({}) || 1;
+  // newScore = oldScore + log(1 + factor * 'bookmark_count')
+  query.body.query = {
+    function_score: {
+      query: { ...query.body.query },
+      field_value_factor: {
+        field: 'bookmark_count',
+        modifier: 'log1p',
+        factor: 10000 / count,
+        missing: 0,
+      },
+      boost_mode: 'sum',
+    },
+  };
+};
+
+SearchClient.prototype.searchKeyword = function(keyword, user, userGroups, option) {
+  const from = option.offset || null;
+  const size = option.limit || null;
+  const type = option.type || null;
+  const query = this.createSearchQuerySortedByScore();
   this.appendCriteriaForKeywordContains(query, keyword);
 
+  this.filterPagesByType(query, type);
+  this.filterPagesByViewer(query, user, userGroups);
+
+  this.appendResultSize(query, from, size);
+
+  this.appendFunctionScore(query);
+
   return this.search(query);
 };
 
@@ -464,31 +667,36 @@ SearchClient.prototype.searchByPath = function(keyword, prefix) {
   // TODO path 名だけから検索
 };
 
-SearchClient.prototype.searchKeywordUnderPath = function(keyword, path, option) {
-  var from = option.offset || null;
-  var query = this.createSearchQuerySortedByScore();
+SearchClient.prototype.searchKeywordUnderPath = function(keyword, path, user, userGroups, option) {
+  const from = option.offset || null;
+  const size = option.limit || null;
+  const type = option.type || null;
+  const query = this.createSearchQuerySortedByScore();
   this.appendCriteriaForKeywordContains(query, keyword);
   this.appendCriteriaForPathFilter(query, path);
 
-  if (from) {
-    this.appendResultSize(query, from);
-  }
+  this.filterPagesByType(query, type);
+  this.filterPagesByViewer(query, user, userGroups);
+
+  this.appendResultSize(query, from, size);
+
+  this.appendFunctionScore(query);
 
   return this.search(query);
 };
 
 SearchClient.prototype.getParsedKeywords = function(keyword) {
-  var matchWords = [];
-  var notMatchWords = [];
-  var phraseWords = [];
-  var notPhraseWords = [];
+  let matchWords = [];
+  let notMatchWords = [];
+  let phraseWords = [];
+  let notPhraseWords = [];
 
   keyword.trim();
   keyword = keyword.replace(/\s+/g, ' ');
 
   // First: Parse phrase keywords
-  var phraseRegExp = new RegExp(/(-?"[^"]+")/g);
-  var phrases = keyword.match(phraseRegExp);
+  let phraseRegExp = new RegExp(/(-?"[^"]+")/g);
+  let phrases = keyword.match(phraseRegExp);
 
   if (phrases !== null) {
     keyword = keyword.replace(phraseRegExp, '');
@@ -511,7 +719,7 @@ SearchClient.prototype.getParsedKeywords = function(keyword) {
     }
 
     if (word.match(/^-(.+)$/)) {
-      notMatchWords.push((RegExp.$1));
+      notMatchWords.push(RegExp.$1);
     }
     else {
       matchWords.push(word);
@@ -526,58 +734,70 @@ SearchClient.prototype.getParsedKeywords = function(keyword) {
   };
 };
 
-SearchClient.prototype.syncPageCreated = function(page, user) {
+SearchClient.prototype.syncPageCreated = function(page, user, bookmarkCount = 0) {
   debug('SearchClient.syncPageCreated', page.path);
 
   if (!this.shouldIndexed(page)) {
-    return ;
+    return;
   }
 
+  page.bookmarkCount = bookmarkCount;
   this.addPages([page])
-  .then(function(res) {
-    debug('ES Response', res);
-  })
-  .catch(function(err) {
-    debug('ES Error', err);
-  });
+    .then(function(res) {
+      debug('ES Response', res);
+    })
+    .catch(function(err) {
+      logger.error('ES Error', err);
+    });
 };
 
-SearchClient.prototype.syncPageUpdated = function(page, user) {
+SearchClient.prototype.syncPageUpdated = function(page, user, bookmarkCount = 0) {
   debug('SearchClient.syncPageUpdated', page.path);
   // TODO delete
   if (!this.shouldIndexed(page)) {
     this.deletePages([page])
-    .then(function(res) {
-      debug('deletePages: ES Response', res);
-    })
-    .catch(function(err) {
-      debug('deletePages:ES Error', err);
-    });
+      .then(function(res) {
+        debug('deletePages: ES Response', res);
+      })
+      .catch(function(err) {
+        logger.error('deletePages:ES Error', err);
+      });
 
-    return ;
+    return;
   }
 
+  page.bookmarkCount = bookmarkCount;
   this.updatePages([page])
-  .then(function(res) {
-    debug('ES Response', res);
-  })
-  .catch(function(err) {
-    debug('ES Error', err);
-  });
+    .then(function(res) {
+      debug('ES Response', res);
+    })
+    .catch(function(err) {
+      logger.error('ES Error', err);
+    });
 };
 
 SearchClient.prototype.syncPageDeleted = function(page, user) {
   debug('SearchClient.syncPageDeleted', page.path);
 
   this.deletePages([page])
-  .then(function(res) {
-    debug('deletePages: ES Response', res);
-  })
-  .catch(function(err) {
-    debug('deletePages:ES Error', err);
-  });
+    .then(function(res) {
+      debug('deletePages: ES Response', res);
+    })
+    .catch(function(err) {
+      logger.error('deletePages:ES Error', err);
+    });
+};
 
-  return ;
+SearchClient.prototype.syncBookmarkChanged = async function(pageId) {
+  const Page = this.crowi.model('Page');
+  const Bookmark = this.crowi.model('Bookmark');
+  const page = await Page.findPageById(pageId);
+  const bookmarkCount = await Bookmark.countByPageId(pageId);
+
+  page.bookmarkCount = bookmarkCount;
+  this.updatePages([page])
+    .then(res => debug('ES Response', res))
+    .catch(err => logger.error('ES Error', err));
 };
 
 module.exports = SearchClient;

+ 2 - 2
src/server/views/_form.html

@@ -17,8 +17,8 @@
 
   <div id="save-page-controls"
     data-grant="{{ page.grant }}"
-    data-grant-group="{{ pageRelatedGroup._id.toString() }}"
-    data-grant-group-name="{{ pageRelatedGroup.name }}">
+    data-grant-group="{{ page.grantedGroup._id.toString() }}"
+    data-grant-group-name="{{ page.grantedGroup.name }}">
   </div>
 
 </div>

+ 71 - 1
src/server/views/admin/search.html

@@ -45,12 +45,16 @@
         </div>
         {% endif %}
 
-        <form action="/admin/search/build" method="post" class="form-horizontal" id="appSettingForm" role="form">
+        <form action="/_api/admin/search/build" method="post" class="form-horizontal" id="buildIndexForm" role="form">
           <fieldset>
             <legend>Index Build</legend>
             <div class="form-group">
               <label for="" class="col-xs-3 control-label">Index Build</label>
               <div class="col-xs-6">
+
+                <div id="admin-rebuild-search">
+                </div>
+
                 <button type="submit" class="btn btn-inverse">Build Now</button>
                 <p class="help-block">
                   Force rebuild index.<br>
@@ -67,6 +71,72 @@
   </div>
 
 </div>
+
+<script>
+  /**
+   * show flash message
+   */
+  function showMessage(formId, msg, status) {
+    $('#' + formId + ' .alert').remove();
+
+    if (!status) {
+      status = 'success';
+    }
+    var $message = $('<p class="alert"></p>');
+    $message.addClass('alert-' + status);
+    $message.html(msg.replace('\n', '<br>'));
+    $message.insertAfter('#' + formId + ' legend');
+
+    if (status == 'success') {
+      setTimeout(function()
+      {
+        $message.fadeOut({
+          complete: function() {
+            $message.remove();
+          }
+        });
+      }, 5000);
+    }
+  }
+
+  /**
+   * Post form data and process UI
+   */
+  function postData(form, button, action) {
+    var id = form.attr('id');
+    button.attr('disabled', 'disabled');
+    var jqxhr = $.post(action, form.serialize(), function(res)
+      {
+        if (!res.ok) {
+          showMessage(id, `Error: ${res.message}`, 'danger');
+        }
+        else {
+          showMessage(id, 'Building request is successfully posted.');
+        }
+      })
+      .fail(function() {
+        showMessage(id, "エラーが発生しました", 'danger');
+      })
+      .always(function() {
+        button.prop('disabled', false);
+      });
+    return false;
+  }
+
+  /**
+   * Handle submit button esa
+   */
+  $('#buildIndexForm').each(function() {
+    var $form = $(this);
+    var $button = $("#buildIndexForm" + $(this).attr('name') + " button[type='submit']");
+    var $action = $form.attr('action');
+    var $success_msg = $button.attr('data-success-message');
+    var $error_msg = $button.attr('data-error-message');
+    $form.submit(function() { return postData($form, $button, $action, $success_msg, $error_msg) });
+  });
+
+</script>
+
 {% endblock content_main %}
 
 {% block content_footer %}

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

@@ -7,7 +7,7 @@
       {% elseif page.grant == 4 %}
         <i class="icon-fw icon-lock"></i><strong>{{ consts.pageGrants[page.grant] }}</strong> ({{ t('Browsing of this page is restricted') }})
       {% elseif page.grant == 5 %}
-        <i class="icon-fw icon-organization"></i><strong>'{{ pageRelatedGroup.name | preventXss }}' only</strong> ({{ t('Browsing of this page is restricted') }})
+        <i class="icon-fw icon-organization"></i><strong>'{{ page.grantedGroup.name | preventXss }}' only</strong> ({{ t('Browsing of this page is restricted') }})
       {% endif %}
       </p>
     {% endif %}