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

Merge pull request #672 from weseek/imprv/refactor-acl

Imprv/refactor acl
Yuki Takei 7 лет назад
Родитель
Сommit
320d331e6f
44 измененных файлов с 2255 добавлено и 2093 удалено
  1. 2 0
      config/webpack.common.js
  2. 6 1
      resource/locales/en-US/translation.json
  3. 5 0
      resource/locales/ja/translation.json
  4. 34 44
      resource/search/mappings.json
  5. 9 6
      src/client/js/app.js
  6. 66 0
      src/client/js/components/Admin/AdminRebuildSearch.jsx
  7. 17 12
      src/client/js/components/PageList/PageListMeta.js
  8. 17 6
      src/client/js/components/PageList/PagePath.js
  9. 2 2
      src/client/js/components/RecentCreated/RecentCreated.jsx
  10. 3 2
      src/client/js/legacy/crowi.js
  11. 7 0
      src/client/js/util/Crowi.js
  12. 1 4
      src/client/styles/agile-admin/inverse/widgets.scss
  13. 112 0
      src/migrations/20181019114028-abolish-page-group-relation.js
  14. 2 0
      src/server/crowi/index.js
  15. 15 0
      src/server/events/bookmark.js
  16. 11 0
      src/server/events/search.js
  17. 19 17
      src/server/events/user.js
  18. 7 6
      src/server/form/admin/securityGeneral.js
  19. 3 10
      src/server/models/GlobalNotificationSetting/index.js
  20. 28 37
      src/server/models/bookmark.js
  21. 13 0
      src/server/models/config.js
  22. 488 629
      src/server/models/page.js
  23. 19 9
      src/server/models/user-group-relation.js
  24. 75 66
      src/server/routes/admin.js
  25. 1 1
      src/server/routes/attachment.js
  26. 53 47
      src/server/routes/bookmark.js
  27. 50 30
      src/server/routes/comment.js
  28. 6 6
      src/server/routes/index.js
  29. 310 580
      src/server/routes/page.js
  30. 2 2
      src/server/routes/revision.js
  31. 52 36
      src/server/routes/search.js
  32. 44 0
      src/server/util/apiPaginate.js
  33. 445 225
      src/server/util/search.js
  34. 2 2
      src/server/views/_form.html
  35. 71 1
      src/server/views/admin/search.html
  36. 45 3
      src/server/views/admin/security.html
  37. 1 1
      src/server/views/layout-crowi/page_list.html
  38. 1 1
      src/server/views/layout-growi/page_list.html
  39. 1 1
      src/server/views/layout-kibela/page_list.html
  40. 8 13
      src/server/views/widget/page_alerts.html
  41. 13 1
      src/server/views/widget/page_content.html
  42. 5 5
      src/server/views/widget/page_tabs.html
  43. 2 2
      src/server/views/widget/page_tabs_kibela.html
  44. 182 285
      src/test/models/page.test.js

+ 2 - 0
config/webpack.common.js

@@ -63,6 +63,7 @@ module.exports = (options) => {
       alias: {
         '@root': helpers.root('/'),
         '@commons': helpers.root('src/lib'),
+        '@client': helpers.root('src/client'),
         '@tmp': helpers.root('tmp'),
         '@alias/logger': helpers.root('src/lib/service/logger'),
         '@alias/locales': helpers.root('resource/locales'),
@@ -77,6 +78,7 @@ module.exports = (options) => {
           exclude: {
             test:    helpers.root('node_modules'),
             exclude: [  // include as a result
+              { test: helpers.root('node_modules', 'growi-plugin-') },
               helpers.root('node_modules/codemirror/src'),
               helpers.root('node_modules/string-width'),
               helpers.root('node_modules/is-fullwidth-code-point'), // depends from string-width

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

@@ -314,7 +314,12 @@
     "restrict_emails": "You can restrict registerable e-mail address.",
 		"for_instance": " For instance, if you use growi within a company, you can write ",
 		"only_those": " Only those whose e-mail address including the company address can register.",
-		"insert_single": "Please insert single e-mail address per line.",
+    "insert_single": "Please insert single e-mail address per line.",
+    "page_listing_1": "Page listing<br>restricted by 'Just Me'",
+    "page_listing_1_desc": "Show pages that are restricted by 'Just Me' option when listing",
+    "page_listing_2": "Page listing<br>restricted by User Group",
+    "page_listing_2_desc": "Show pages that are restricted by User Group when listing",
+
 		"Authentication mechanism settings": "Authentication mechanism settings",
     "note": "Note",
     "require_server_restart_change_auth": "Restarting the server is required if you switch the auth mechanism.",

+ 5 - 0
resource/locales/ja/translation.json

@@ -332,6 +332,11 @@
     "for_instance":"例えば、",
     "only_those":"と記載することで、そのドメインのメールアドレスを持っている人のみ登録可能になります。",
     "insert_single":"1行に1メールアドレス入力してください。",
+    "page_listing_1": "ページのリスト表示<br>'自分のみ'に閲覧制限しているページ",
+    "page_listing_1_desc": "ページのリスト表示時、'自分のみ'に閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
+    "page_listing_2": "ページのリスト表示<br>特定グループに閲覧制限しているページ",
+    "page_listing_2_desc": "ページのリスト表示時、特定グループにのみ閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
+
     "Authentication mechanism settings":"認証機構設定",
     "note": "メモ",
     "require_server_restart_change_auth": "認証機構の変更後はサーバーを再起動してください。",

+ 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 - 6
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, {
@@ -331,7 +329,6 @@ if (savePageControlsElem) {
   componentInstances.savePageControls = savePageControls;
 }
 
-// RecentCreated dev GC-939 start
 const recentCreatedControlsElem = document.getElementById('user-created-list');
 if (recentCreatedControlsElem) {
   let limit = crowi.getConfig().recentCreatedLimit;
@@ -344,7 +341,6 @@ if (recentCreatedControlsElem) {
     </RecentCreated>, document.getElementById('user-created-list')
   );
 }
-// RecentCreated dev GC-939 end
 
 /*
  * HackMD Editor
@@ -477,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,
+};

+ 17 - 12
src/client/js/components/PageList/PageListMeta.js

@@ -17,34 +17,39 @@ export default class PageListMeta extends React.Component {
     const page = this.props.page;
 
     // portal check
-    let PortalLabel;
+    let portalLabel;
     if (this.isPortalPath(page.path)) {
-      PortalLabel = <span className="label label-info">PORTAL</span>;
+      portalLabel = <span className="label label-info">PORTAL</span>;
     }
 
     // template check
-    let TemplateLabel;
+    let templateLabel;
     if (templateChecker(page.path)) {
-      TemplateLabel = <span className="label label-info">TMPL</span>;
+      templateLabel = <span className="label label-info">TMPL</span>;
     }
 
-    let CommentCount;
+    let commentCount;
     if (page.commentCount > 0) {
-      CommentCount = <span><i className="icon-bubble" />{page.commentCount}</span>;
+      commentCount = <span><i className="icon-bubble" />{page.commentCount}</span>;
     }
 
-    let LikerCount;
+    let likerCount;
     if (page.liker.length > 0) {
-      LikerCount = <span><i className="icon-like" />{page.liker.length}</span>;
+      likerCount = <span><i className="icon-like" />{page.liker.length}</span>;
     }
 
+    let locked;
+    if (page.grant != 1) {
+      locked = <span><i className="icon-lock" /></span>;
+    }
 
     return (
       <span className="page-list-meta">
-        {PortalLabel}
-        {TemplateLabel}
-        {CommentCount}
-        {LikerCount}
+        {portalLabel}
+        {templateLabel}
+        {commentCount}
+        {likerCount}
+        {locked}
       </span>
     );
   }

+ 17 - 6
src/client/js/components/PageList/PagePath.js

@@ -29,24 +29,35 @@ export default class PagePath extends React.Component {
 
   render() {
     const page = this.props.page;
-    const pagePath = page.path.replace(this.props.excludePathString.replace(/^\//, ''), '');
+    const isShortPathOnly = this.props.isShortPathOnly;
+    const pagePath = decodeURIComponent(page.path.replace(this.props.excludePathString.replace(/^\//, ''), ''));
     const shortPath = this.getShortPath(pagePath);
+
     const shortPathEscaped = escapeStringRegexp(shortPath);
     const pathPrefix = pagePath.replace(new RegExp(shortPathEscaped + '(/)?$'), '');
 
-    return (
-      <span className="page-path">
-        {pathPrefix}<strong>{shortPath}</strong>
-      </span>
-    );
+    let classNames = ['page-path'];
+    classNames = classNames.concat(this.props.additionalClassNames);
+
+    if (isShortPathOnly) {
+      return <span className={classNames.join(' ')}>{shortPath}</span>;
+    }
+    else {
+      return <span className={classNames.join(' ')}>{pathPrefix}<strong>{shortPath}</strong></span>;
+    }
+
   }
 }
 
 PagePath.propTypes = {
   page: PropTypes.object.isRequired,
+  isShortPathOnly: PropTypes.bool,
+  excludePathString: PropTypes.string,
+  additionalClassNames: PropTypes.array,
 };
 
 PagePath.defaultProps = {
   page: {},
+  additionalClassNames: [],
   excludePathString: '',
 };

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

@@ -30,9 +30,9 @@ export default class RecentCreated extends React.Component {
     // pagesList get and pagination calculate
     this.props.crowi.apiGet('/pages.recentCreated', { page_id: pageId, user: userId, limit, offset })
       .then(res => {
-        const totalCount = res.pages[0].totalCount;
+        const totalCount = res.totalCount;
+        const pages = res.pages;
         const activePage = selectPageNumber;
-        const pages = res.pages[1];
         // pagiNation calculate function call
         const paginationNumbers = this.calculatePagination(limit, totalCount, activePage);
         this.setState({

+ 3 - 2
src/client/js/legacy/crowi.js

@@ -471,8 +471,7 @@ $(function() {
         $('#delete-errors').addClass('alert-danger');
       }
       else {
-        const page = res.page;
-        top.location.href = page.path + '?unlinked=true';
+        top.location.href = res.path + '?unlinked=true';
       }
     });
 
@@ -480,6 +479,8 @@ $(function() {
   });
 
   $('#create-portal-button').on('click', function(e) {
+    $('a[data-toggle="tab"][href="#edit"]').tab('show');
+
     $('body').addClass('on-edit');
     $('body').addClass('builtin-editor');
 

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

+ 112 - 0
src/migrations/20181019114028-abolish-page-group-relation.js

@@ -0,0 +1,112 @@
+'use strict';
+
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:abolish-page-group-relation');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+
+async function isCollectionExists(db, collectionName) {
+  const collections = await db.listCollections({ name: collectionName }).toArray();
+  return collections.length > 0;
+}
+
+/**
+ * BEFORE
+ *   - 'pagegrouprelations' collection exists (related to models/page-group-relation.js)
+ *     - schema:
+ *       {
+ *         "_id" : ObjectId("5bc9de4d745e137e0424ed89"),
+ *         "targetPage" : ObjectId("5b028f13c1f7ba2e58d2fd21"),
+ *         "relatedGroup" : ObjectId("5b07e6e6929bad5d3cce9995"),
+ *         "__v" : 0
+ *       }
+ * AFTER
+ *   - 'pagegrouprelations' collection is dropped and models/page-group-relation.js is removed
+ *   - Page model has 'grantedGroup' field newly
+ */
+module.exports = {
+
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const isPagegrouprelationsExists = await isCollectionExists(db, 'pagegrouprelations');
+    if (!isPagegrouprelationsExists) {
+      logger.info("'pagegrouprelations' collection doesn't exist");   // eslint-disable-line
+      logger.info('Migration has successfully applied');
+      return;
+    }
+
+    const Page = require('@server/models/page')();
+    const UserGroup = require('@server/models/user-group')();
+
+    // retrieve all documents from 'pagegrouprelations'
+    const relations = await db.collection('pagegrouprelations').find().toArray();
+
+    for (let relation of relations) {
+      const page = await Page.findOne({ _id: relation.targetPage });
+
+      // skip if grant mismatch
+      if (page.grant !== Page.GRANT_USER_GROUP) {
+        continue;
+      }
+
+      const userGroup = await UserGroup.findOne({ _id: relation.relatedGroup });
+
+      // skip if userGroup does not exist
+      if (userGroup == null) {
+        continue;
+      }
+
+      page.grantedGroup = userGroup;
+      await page.save();
+    }
+
+    // drop collection
+    await db.collection('pagegrouprelations').drop();
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db) {
+    logger.info('Undo migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Page = require('@server/models/page')();
+    const UserGroup = require('@server/models/user-group')();
+
+    // retrieve all Page documents which granted by UserGroup
+    const relatedPages = await Page.find({ grant: Page.GRANT_USER_GROUP });
+    const insertDocs = [];
+    for (let page of relatedPages) {
+      if (page.grantedGroup == null) {
+        continue;
+      }
+
+      const userGroup = await UserGroup.findOne({ _id: page.grantedGroup });
+
+      // skip if userGroup does not exist
+      if (userGroup == null) {
+        continue;
+      }
+
+      // create a new document for 'pagegrouprelations' collection that is managed by mongoose
+      insertDocs.push({
+        targetPage: page._id,
+        relatedGroup: userGroup._id,
+        __v: 0,
+      });
+
+      // clear 'grantedGroup' field
+      page.grantedGroup = undefined;
+      await page.save();
+    }
+
+    await db.collection('pagegrouprelations').insertMany(insertDocs);
+
+    logger.info('Migration has successfully undoed');
+  }
+
+};

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

+ 19 - 17
src/server/events/user.js

@@ -1,6 +1,6 @@
-var debug = require('debug')('growi:events:user');
-var util = require('util');
-var events = require('events');
+const debug = require('debug')('growi:events:user');
+const util = require('util');
+const events = require('events');
 
 function UserEvent(crowi) {
   this.crowi = crowi;
@@ -9,25 +9,27 @@ function UserEvent(crowi) {
 }
 util.inherits(UserEvent, events.EventEmitter);
 
-UserEvent.prototype.onActivated = function(user) {
-  var User = this.crowi.model('User');
-  var Page = this.crowi.model('Page');
+UserEvent.prototype.onActivated = async function(user) {
+  const Page = this.crowi.model('Page');
+
+  const userPagePath = Page.getUserPagePath(user);
+
+  const page = await Page.findByPathAndViewer(userPagePath, user);
+
+  if (page == null) {
+    const body = `# ${user.username}\nThis is ${user.username}'s page`;
 
-  var userPagePath = Page.getUserPagePath(user);
-  Page.findPage(userPagePath, user, {}, false)
-  .then(function(page) {
-    // do nothing because user page is already exists.
-  }).catch(function(err) {
-    var body = `# ${user.username}\nThis is ${user.username}\'s page`;
     // create user page
-    Page.create(userPagePath, body, user, {})
-    .then(function(page) {
+    try {
+      await Page.create(userPagePath, body, user, {});
+
       // page created
       debug('User page created', page);
-    }).catch(function(err) {
+    }
+    catch (err) {
       debug('Failed to create user page', err);
-    });
-  });
+    }
+  }
 };
 
 module.exports = UserEvent;

+ 7 - 6
src/server/form/admin/securityGeneral.js

@@ -1,15 +1,16 @@
 'use strict';
 
-var form = require('express-form')
-  , field = form.field
-  , stringToArray = require('../../util/formUtil').stringToArrayFilter
-  , normalizeCRLF = require('../../util/formUtil').normalizeCRLFFilter
-  ;
+const form = require('express-form')
+const field = form.field;
+const stringToArray = require('../../util/formUtil').stringToArrayFilter;
+const normalizeCRLF = require('../../util/formUtil').normalizeCRLFFilter;
 
 module.exports = form(
   field('settingForm[security:basicName]'),
   field('settingForm[security:basicSecret]'),
   field('settingForm[security:restrictGuestMode]').required(),
   field('settingForm[security:registrationMode]').required(),
-  field('settingForm[security:registrationWhiteList]').custom(normalizeCRLF).custom(stringToArray)
+  field('settingForm[security:registrationWhiteList]').custom(normalizeCRLF).custom(stringToArray),
+  field('settingForm[security:list-policy:hideRestrictedByOwner]').trim().toBooleanStrict(),
+  field('settingForm[security:list-policy:hideRestrictedByGroup]').trim().toBooleanStrict(),
 );

+ 3 - 10
src/server/models/GlobalNotificationSetting/index.js

@@ -1,4 +1,5 @@
 const mongoose = require('mongoose');
+const nodePath = require('path');
 
 /**
  * parent schema for GlobalNotificationSetting model
@@ -74,22 +75,14 @@ class GlobalNotificationSetting {
   }
 }
 
-
-// move this to util
-// remove this from models/page
-const cutOffLastSlash = path => {
-  const lastSlash = path.lastIndexOf('/');
-  return path.substr(0, lastSlash);
-};
-
 const generatePathsOnTree = (path, pathList) => {
   pathList.push(path);
 
-  if (path === '') {
+  if (path === '/') {
     return pathList;
   }
 
-  const newPath = cutOffLastSlash(path);
+  const newPath = nodePath.posix.dirname(path);
 
   return generatePathsOnTree(newPath, pathList);
 };

+ 28 - 37
src/server/models/bookmark.js

@@ -1,8 +1,9 @@
 module.exports = function(crowi) {
-  var debug = require('debug')('growi:models:bookmark')
-    , mongoose = require('mongoose')
-    , ObjectId = mongoose.Schema.Types.ObjectId
-    , bookmarkSchema;
+  const debug = require('debug')('growi:models:bookmark');
+  const mongoose = require('mongoose');
+  const ObjectId = mongoose.Schema.Types.ObjectId;
+
+  let bookmarkSchema = null;
 
 
   bookmarkSchema = new mongoose.Schema({
@@ -12,32 +13,18 @@ module.exports = function(crowi) {
   });
   bookmarkSchema.index({page: 1, user: 1}, {unique: true});
 
-  bookmarkSchema.statics.populatePage = function(bookmarks, requestUser) {
+  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');
-    const Page = crowi.model('Page');
-
-    requestUser = requestUser || null;
-
-    // mongoose promise に置き換えてみたものの、こいつは not native promise but original promise だったので
-    // これ以上は置き換えないことにする ...
-    // @see http://eddywashere.com/blog/switching-out-callbacks-with-promises-in-mongoose/
-    return Bookmark.populate(bookmarks, {path: 'page'})
-      .then(function(bookmarks) {
-        return Bookmark.populate(bookmarks, {path: 'page.revision', model: 'Revision'});
-      }).then(function(bookmarks) {
-        // hmm...
-        bookmarks = bookmarks.filter(function(bookmark) {
-          // requestUser を指定しない場合 public のみを返す
-          if (requestUser === null) {
-            return bookmark.page.isPublic();
-          }
 
-          return bookmark.page.isGrantedFor(requestUser);
-        });
-
-        return Bookmark.populate(bookmarks, {path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS});
-      });
+    return Bookmark.populate(bookmarks, [
+      {path: 'page'},
+      {path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS},
+    ]);
   };
 
   // bookmark チェック用
@@ -63,15 +50,14 @@ module.exports = function(crowi) {
    * }
    */
   bookmarkSchema.statics.findByUser = function(user, option) {
-    var User = crowi.model('User');
-    var Bookmark = this;
-    var requestUser = option.requestUser || null;
+    const Bookmark = this;
+    const requestUser = option.requestUser || null;
 
     debug('Finding bookmark with requesting user:', requestUser);
 
-    var limit = option.limit || 50;
-    var offset = option.offset || 0;
-    var populatePage = option.populatePage || false;
+    const limit = option.limit || 50;
+    const offset = option.offset || 0;
+    const populatePage = option.populatePage || false;
 
     return new Promise(function(resolve, reject) {
       Bookmark
@@ -94,10 +80,10 @@ module.exports = function(crowi) {
   };
 
   bookmarkSchema.statics.add = function(page, user) {
-    var Bookmark = this;
+    const Bookmark = this;
 
     return new Promise(function(resolve, reject) {
-      var newBookmark = new Bookmark;
+      const newBookmark = new Bookmark;
 
       newBookmark.page = page;
       newBookmark.user = user;
@@ -116,8 +102,13 @@ module.exports = function(crowi) {
     });
   };
 
+  /**
+   * Remove bookmark
+   * used only when removing the page
+   * @param {string} pageId
+   */
   bookmarkSchema.statics.removeBookmarksByPageId = function(pageId) {
-    var Bookmark = this;
+    const Bookmark = this;
 
     return new Promise(function(resolve, reject) {
       Bookmark.remove({page: pageId}, function(err, data) {
@@ -132,7 +123,7 @@ module.exports = function(crowi) {
   };
 
   bookmarkSchema.statics.removeBookmark = function(page, user) {
-    var Bookmark = this;
+    const Bookmark = this;
 
     return new Promise(function(resolve, reject) {
       Bookmark.findOneAndRemove({page: page, user: user}, function(err, data) {

+ 13 - 0
src/server/models/config.js

@@ -60,6 +60,9 @@ module.exports = function(crowi) {
       'security:registrationMode'      : 'Open',
       'security:registrationWhiteList' : [],
 
+      'security:list-policy:hideRestrictedByOwner' : false,
+      'security:list-policy:hideRestrictedByGroup' : false,
+
       'security:isEnabledPassport' : false,
       'security:passport-ldap:isEnabled' : false,
       'security:passport-ldap:serverUrl' : undefined,
@@ -377,6 +380,16 @@ module.exports = function(crowi) {
     return SECURITY_RESTRICT_GUEST_MODE_READONLY === config.crowi['security:restrictGuestMode'];
   };
 
+  configSchema.statics.hidePagesRestrictedByOwnerInList = function(config) {
+    const key = 'security:list-policy:hideRestrictedByOwner';
+    return getValueForCrowiNS(config, key);
+  };
+
+  configSchema.statics.hidePagesRestrictedByGroupInList = function(config) {
+    const key = 'security:list-policy:hideRestrictedByGroup';
+    return getValueForCrowiNS(config, key);
+  };
+
   configSchema.statics.isEnabledPlugins = function(config) {
     const key = 'plugin:isEnabledPlugins';
     return getValueForCrowiNS(config, key);

Разница между файлами не показана из-за своего большого размера
+ 488 - 629
src/server/models/page.js


+ 19 - 9
src/server/models/user-group-relation.js

@@ -128,6 +128,21 @@ class UserGroupRelation {
       });
   }
 
+  /**
+   * find all UserGroup IDs that related to specified User
+   *
+   * @static
+   * @param {User} user
+   * @returns {Promise<ObjectId[]>}
+   */
+  static async findAllUserGroupIdsRelatedToUser(user) {
+    const relations = await this.find({ relatedUser: user.id })
+      .select('relatedGroup')
+      .exec();
+
+    return relations.map(relation => relation.relatedGroup);
+  }
+
   /**
    * find all entities with pagination
    *
@@ -156,25 +171,20 @@ class UserGroupRelation {
   }
 
   /**
-   * find one result by related group id and related user
+   * count by related group id and related user
    *
    * @static
    * @param {string} userGroupId find query param for relatedGroup
    * @param {User} userData find query param for relatedUser
-   * @returns {Promise<UserGroupRelation>}
-   * @memberof UserGroupRelation
+   * @returns {Promise<number>}
    */
-  static findByGroupIdAndUser(userGroupId, userData) {
+  static async countByGroupIdAndUser(userGroupId, userData) {
     const query = {
       relatedGroup: userGroupId,
       relatedUser: userData.id
     };
 
-    return this
-      .findOne(query)
-      .populate('relatedUser')
-      .populate('relatedGroup')
-      .exec();
+    return this.count(query);
   }
 
   /**

+ 75 - 66
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 = {};
@@ -606,7 +572,7 @@ module.exports = function(crowi, app) {
       return ExternalAccount.remove({user: userData}).then(() => userData);
     })
     .then((userData) => {
-      return Page.removePageByPath(`/user/${username}`).then(() => userData);
+      return Page.removeByPath(`/user/${username}`).then(() => userData);
     })
     .then((userData) => {
       req.flash('successMessage', `${username} さんのアカウントを削除しました`);
@@ -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/attachment.js

@@ -142,7 +142,7 @@ module.exports = function(crowi, app) {
           .catch(reject);
       }
       else {
-        Page.findPageById(id).then(resolve).catch(reject);
+        Page.findById(id).then(resolve).catch(reject);
       }
     }).then(function(pageData) {
       page = pageData;

+ 53 - 47
src/server/routes/bookmark.js

@@ -1,15 +1,12 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  var debug = require('debug')('growi:routes:bookmark')
-    , Bookmark = crowi.model('Bookmark')
-    , Page = crowi.model('Page')
-    , User = crowi.model('User')
-    , Revision = crowi.model('Revision')
-    , Bookmark = crowi.model('Bookmark')
-    , ApiResponse = require('../util/apiResponse')
-    , actions = {}
-  ;
+  const debug = require('debug')('growi:routes:bookmark');
+  const Bookmark = crowi.model('Bookmark');
+  const Page = crowi.model('Page');
+  const ApiResponse = require('../util/apiResponse');
+  const ApiPaginate = require('../util/apiPaginate');
+  let actions = {};
   actions.api = {};
 
   /**
@@ -20,20 +17,35 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id Page Id.
    */
   actions.api.get = function(req, res) {
-    var pageId = req.query.page_id;
+    let pageId = req.query.page_id;
 
     Bookmark.findByPageIdAndUserId(pageId, req.user)
-    .then(function(data) {
-      debug('bookmark found', pageId, data);
-      var result = {};
-      if (data) {
-      }
+      .then(function(data) {
+        debug('bookmark found', pageId, data);
+        let result = {};
 
-      result.bookmark = data;
-      return res.json(ApiResponse.success(result));
-    }).catch(function(err) {
-      return res.json(ApiResponse.error(err));
-    });
+        result.bookmark = data;
+        return res.json(ApiResponse.success(result));
+      })
+      .catch(function(err) {
+        return res.json(ApiResponse.error(err));
+      });
+  };
+
+  /**
+   *
+   */
+  actions.api.list = function(req, res) {
+    let paginateOptions = ApiPaginate.parseOptions(req.query);
+
+    let options = Object.assign(paginateOptions, { populatePage: true });
+    Bookmark.findByUserId(req.user._id, options)
+      .then(function(result) {
+        return res.json(ApiResponse.success(result));
+      })
+      .catch(function(err) {
+        return res.json(ApiResponse.error(err));
+      });
   };
 
   /**
@@ -43,27 +55,21 @@ module.exports = function(crowi, app) {
    *
    * @apiParam {String} page_id Page Id.
    */
-  actions.api.add = function(req, res) {
-    var pageId = req.body.page_id;
+  actions.api.add = async function(req, res) {
+    const pageId = req.body.page_id;
+
+    const page = await Page.findByIdAndViewer(pageId, req.user);
+    if (page == null) {
+      return res.json(ApiResponse.success({ bookmark: null }));
+    }
 
-    Page.findPageByIdAndGrantedUser(pageId, req.user)
-    .then(function(pageData) {
-      if (pageData) {
-        return Bookmark.add(pageData, req.user);
-      }
-      else {
-        return res.json(ApiResponse.success({bookmark: null}));
-      }
-    }).then(function(data) {
-      var result = {};
-      data.depopulate('page');
-      data.depopulate('user');
+    const bookmark = await Bookmark.add(page, req.user);
 
-      result.bookmark = data;
-      return res.json(ApiResponse.success(result));
-    }).catch(function(err) {
-      return res.json(ApiResponse.error(err));
-    });
+    bookmark.depopulate('page');
+    bookmark.depopulate('user');
+    const result = { bookmark };
+
+    return res.json(ApiResponse.success(result));
   };
 
   /**
@@ -74,17 +80,17 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id Page Id.
    */
   actions.api.remove = function(req, res) {
-    var pageId = req.body.page_id;
+    let pageId = req.body.page_id;
 
     Bookmark.removeBookmark(pageId, req.user)
-    .then(function(data) {
-      debug('Bookmark removed.', data); // if the bookmark is not exists, this 'data' is null
-      return res.json(ApiResponse.success());
-    }).catch(function(err) {
-      return res.json(ApiResponse.error(err));
-    });
+      .then(function(data) {
+        debug('Bookmark removed.', data); // if the bookmark is not exists, this 'data' is null
+        return res.json(ApiResponse.success());
+      })
+      .catch(function(err) {
+        return res.json(ApiResponse.error(err));
+      });
   };
 
-
   return actions;
 };

+ 50 - 30
src/server/routes/comment.js

@@ -1,8 +1,7 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  const debug = require('debug')('growi:routs:comment')
-    , logger = require('@alias/logger')('growi:routes:comment')
+  const logger = require('@alias/logger')('growi:routes:comment')
     , Comment = crowi.model('Comment')
     , User = crowi.model('User')
     , Page = crowi.model('Page')
@@ -13,6 +12,7 @@ module.exports = function(crowi, app) {
 
   actions.api = api;
 
+
   /**
    * @api {get} /comments.get Get comments of the page of the revision
    * @apiName GetComments
@@ -21,25 +21,31 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id Page Id.
    * @apiParam {String} revision_id Revision Id.
    */
-  api.get = function(req, res) {
+  api.get = async function(req, res) {
     const pageId = req.query.page_id;
     const revisionId = req.query.revision_id;
 
-    if (revisionId) {
-      return Comment.getCommentsByRevisionId(revisionId)
-        .then(function(comments) {
-          res.json(ApiResponse.success({comments}));
-        }).catch(function(err) {
-          res.json(ApiResponse.error(err));
-        });
+    // check whether accessible
+    const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
+    if (!isAccessible) {
+      return res.json(ApiResponse.error('Current user is not accessible to this page.'));
     }
 
-    return Comment.getCommentsByPageId(pageId)
-      .then(function(comments) {
-        res.json(ApiResponse.success({comments}));
-      }).catch(function(err) {
-        res.json(ApiResponse.error(err));
-      });
+    let comments = null;
+
+    try {
+      if (revisionId) {
+        comments = await Comment.getCommentsByRevisionId(revisionId);
+      }
+      else {
+        comments = await Comment.getCommentsByPageId(pageId);
+      }
+    }
+    catch (err) {
+      return res.json(ApiResponse.error(err));
+    }
+
+    res.json(ApiResponse.success({comments}));
   };
 
   /**
@@ -67,6 +73,12 @@ module.exports = function(crowi, app) {
     const position = commentForm.comment_position || -1;
     const isMarkdown = commentForm.is_markdown;
 
+    // check whether accessible
+    const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
+    if (!isAccessible) {
+      return res.json(ApiResponse.error('Current user is not accessible to this page.'));
+    }
+
     const createdComment = await Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown)
       .catch(function(err) {
         return res.json(ApiResponse.error(err));
@@ -114,26 +126,34 @@ module.exports = function(crowi, app) {
    *
    * @apiParam {String} comment_id Comment Id.
    */
-  api.remove = function(req, res) {
+  api.remove = async function(req, res) {
     const commentId = req.body.comment_id;
     if (!commentId) {
       return Promise.resolve(res.json(ApiResponse.error('\'comment_id\' is undefined')));
     }
 
-    return Comment.findById(commentId).exec()
-      .then(function(comment) {
-        return comment.remove()
-        .then(function() {
-          return Page.updateCommentCount(comment.page);
-        })
-        .then(function() {
-          return res.json(ApiResponse.success({}));
-        });
-      })
-      .catch(function(err) {
-        return res.json(ApiResponse.error(err));
-      });
+    try {
+      const comment = await Comment.findById(commentId).exec();
+
+      if (comment == null) {
+        throw new Error('This comment does not exist.');
+      }
+
+      // check whether accessible
+      const pageId = comment.page;
+      const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
+      if (!isAccessible) {
+        throw new Error('Current user is not accessible to this page.');
+      }
+
+      await comment.remove();
+      await Page.updateCommentCount(comment.page);
+    }
+    catch (err) {
+      return res.json(ApiResponse.error(err));
+    }
 
+    return res.json(ApiResponse.success({}));
   };
 
   return actions;

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

@@ -27,7 +27,7 @@ module.exports = function(crowi, app) {
 
   /* eslint-disable comma-spacing */
 
-  app.get('/'                        , middleware.applicationInstalled(), loginRequired(crowi, app, false) , page.pageListShow);
+  app.get('/'                        , middleware.applicationInstalled(), loginRequired(crowi, app, false) , page.showTopPage);
 
   app.get('/installer'               , middleware.applicationNotInstalled() , middleware.checkSearchIndicesGenerated(crowi, app) , installer.index);
   app.post('/installer/createAdmin'  , middleware.applicationNotInstalled() , form.register , csrf, installer.createAdmin);
@@ -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);
@@ -173,8 +173,8 @@ module.exports = function(crowi, app) {
   app.post('/me/auth/google'          , loginRequired(crowi, app) , me.authGoogle);
   app.get( '/me/auth/google/callback' , loginRequired(crowi, app) , me.authGoogleCallback);
 
-  app.get( '/:id([0-9a-z]{24})'       , loginRequired(crowi, app, false) , page.api.redirector);
-  app.get( '/_r/:id([0-9a-z]{24})'    , loginRequired(crowi, app, false) , page.api.redirector); // alias
+  app.get( '/:id([0-9a-z]{24})'       , loginRequired(crowi, app, false) , page.redirector);
+  app.get( '/_r/:id([0-9a-z]{24})'    , loginRequired(crowi, app, false) , page.redirector); // alias
   app.get( '/download/:id([0-9a-z]{24})' , loginRequired(crowi, app, false) , attachment.api.download);
   app.get( '/attachment/:pageId/:fileName'  , loginRequired(crowi, app, false), attachment.api.get);
 
@@ -229,6 +229,6 @@ module.exports = function(crowi, app) {
   // API v3
   app.use('/_api/v3', require('./apiv3')(crowi));
 
-  app.get('/*/$'                   , loginRequired(crowi, app, false) , page.pageListShowWrapper);
-  app.get('/*'                     , loginRequired(crowi, app, false) , page.pageShowWrapper);
+  app.get('/*/$'                   , loginRequired(crowi, app, false) , page.showPageWithEndOfSlash, page.notFound);
+  app.get('/*'                     , loginRequired(crowi, app, false) , page.showPage, page.notFound);
 };

Разница между файлами не показана из-за своего большого размера
+ 310 - 580
src/server/routes/page.js


+ 2 - 2
src/server/routes/revision.js

@@ -44,7 +44,7 @@ module.exports = function(crowi, app) {
     const pageId = req.query.page_id || null;
 
     if (pageId && crowi.isPageId(pageId)) {
-      Page.findPageByIdAndGrantedUser(pageId, req.user)
+      Page.findByIdAndViewer(pageId, req.user)
       .then(function(pageData) {
         debug('Page found', pageData._id, pageData.path);
         return Revision.findRevisionIdList(pageData.path);
@@ -72,7 +72,7 @@ module.exports = function(crowi, app) {
     const pageId = req.query.page_id || null;
 
     if (pageId) {
-      Page.findPageByIdAndGrantedUser(pageId, req.user)
+      Page.findByIdAndViewer(pageId, req.user)
       .then(function(pageData) {
         debug('Page found', pageData._id, pageData.path);
         return Revision.findRevisionList(pageData.path, {});

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

+ 44 - 0
src/server/util/apiPaginate.js

@@ -0,0 +1,44 @@
+'use strict';
+
+const LIMIT_DEFAULT = 50;
+const LIMIT_MAX = 1000;
+
+const OFFSET_DEFAULT = 0;
+
+const DEFAULT_MAX_RESULT_WINDOW = 10000;
+
+const parseIntValue = function(value, defaultValue, maxLimit) {
+  if (!value) {
+    return defaultValue;
+  }
+
+  let v = parseInt(value);
+  if (!maxLimit) {
+    return v;
+  }
+
+  return v <= maxLimit ? v : maxLimit;
+};
+
+function ApiPaginate() {}
+
+ApiPaginate.parseOptionsForElasticSearch = function(params) {
+  let limit = parseIntValue(params.limit, LIMIT_DEFAULT, LIMIT_MAX);
+  let offset = parseIntValue(params.offset, OFFSET_DEFAULT);
+
+  // See https://github.com/crowi/crowi/pull/293
+  if (limit + offset > DEFAULT_MAX_RESULT_WINDOW) {
+    throw new Error(`(limit + offset) must be less than or equal to ${DEFAULT_MAX_RESULT_WINDOW}`);
+  }
+
+  return { limit: limit, offset: offset };
+};
+
+ApiPaginate.parseOptions = function(params) {
+  let limit = parseIntValue(params.limit, LIMIT_DEFAULT, LIMIT_MAX);
+  let offset = parseIntValue(params.offset, OFFSET_DEFAULT);
+
+  return { limit: limit, offset: offset };
+};
+
+module.exports = ApiPaginate;

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

+ 45 - 3
src/server/views/admin/security.html

@@ -51,7 +51,7 @@
               <input class="form-control" type="text" name="settingForm[security:basicSecret]" value="{{ settingForm['security:basicSecret']|default('') }}" {% if not isAclEnabled  %}readonly{% endif%}>
             </div>
             <div class="col-xs-offset-3 col-xs-9">
-              <p class="help-block">
+              <p class="help-block small">
                 {% if not isAclEnabled %}
                   {{ t("security_setting.basic_acl_disable") }}<br>
                 {% else %}
@@ -81,7 +81,7 @@
                 <option value="{{ t(modeValue) }}" {% if modeValue == settingForm['security:registrationMode'] %}selected{% endif %} >{{ t(modeLabel) }}</option>
                 {% endfor %}
               </select>
-              <p class="help-block">{{ t('The contents entered here will be shown in the header etc') }}</p>
+              <p class="help-block small">{{ t('The contents entered here will be shown in the header etc') }}</p>
             </div>
           </div>
 
@@ -89,11 +89,53 @@
             <label for="settingForm[security:registrationWhiteList]" class="col-xs-3 control-label">{{ t('The whitelist of registration permission E-mail address') }}</label>
             <div class="col-xs-8">
               <textarea class="form-control" type="textarea" name="settingForm[security:registrationWhiteList]" placeholder="{{ t('security_setting.example') }}: @growi.org">{{ settingForm['security:registrationWhiteList']|join('&#13')|raw }}</textarea>
-              <p class="help-block">{{ t("security_setting.restrict_emails") }}{{ t("security_setting.for_instance") }}<code>@growi.org</code>{{ t("security_setting.only_those") }}<br>
+              <p class="help-block small">{{ t("security_setting.restrict_emails") }}{{ t("security_setting.for_instance") }}<code>@growi.org</code>{{ t("security_setting.only_those") }}<br>
               {{ t("security_setting.insert_single") }}</p>
             </div>
           </div>
 
+          <div class="form-group">
+            {% set configName = 'settingForm[security:list-policy:hideRestrictedByOwner]' %}
+            {% set configValue = settingForm['security:list-policy:hideRestrictedByOwner'] %}
+            {% set isEnabled = !configValue %}
+            <label for="{{configName}}" class="col-xs-3 control-label">{{ t("security_setting.page_listing_1") }}</label>
+            <div class="col-xs-9">
+              <div class="btn-group btn-toggle" data-toggle="buttons">
+                <label class="btn btn-default btn-rounded btn-outline {% if isEnabled %}active{% endif %}" data-active-class="primary">
+                  <input name="{{configName}}" value="false" type="radio" {% if isEnabled %}checked{% endif %}> ON
+                </label>
+                <label class="btn btn-default btn-rounded btn-outline {% if !isEnabled %}active{% endif %}" data-active-class="default">
+                  <input name="{{configName}}" value="true" type="radio" {% if !isEnabled %}checked{% endif %}> OFF
+                </label>
+              </div>
+
+              <p class="help-block small">
+                {{ t("security_setting.page_listing_1_desc") }}
+              </p>
+            </div>
+          </div>
+
+          <div class="form-group">
+            {% set configName = 'settingForm[security:list-policy:hideRestrictedByGroup]' %}
+            {% set configValue = settingForm['security:list-policy:hideRestrictedByGroup'] %}
+            {% set isEnabled = !configValue %}
+            <label for="{{configName}}" class="col-xs-3 control-label">{{ t("security_setting.page_listing_2") }}</label>
+            <div class="col-xs-9">
+              <div class="btn-group btn-toggle" data-toggle="buttons">
+                <label class="btn btn-default btn-rounded btn-outline {% if isEnabled %}active{% endif %}" data-active-class="primary">
+                  <input name="{{configName}}" value="false" type="radio" {% if isEnabled %}checked{% endif %}> ON
+                </label>
+                <label class="btn btn-default btn-rounded btn-outline {% if !isEnabled %}active{% endif %}" data-active-class="default">
+                  <input name="{{configName}}" value="true" type="radio" {% if !isEnabled %}checked{% endif %}> OFF
+                </label>
+              </div>
+
+              <p class="help-block small">
+                {{ t("security_setting.page_listing_2_desc") }}
+              </p>
+            </div>
+          </div>
+
           <div class="form-group">
             <div class="col-xs-offset-3 col-xs-6">
               <input type="hidden" name="_csrf" value="{{ csrf() }}">

+ 1 - 1
src/server/views/layout-crowi/page_list.html

@@ -63,7 +63,7 @@
     {% include '../widget/page_content.html' %}
   </div>
 
-  <div class="row page-list hidden-print {% if isPortal %}m-t-30{% endif %}">
+  <div class="row page-list hidden-print {% if page.isPortal() %}m-t-30{% endif %}">
     <div class="col-md-12">
       {% include '../widget/page_list_and_timeline.html' %}
     </div>

+ 1 - 1
src/server/views/layout-growi/page_list.html

@@ -33,7 +33,7 @@
 
   </div>
 
-  <div class="row page-list hidden-print {% if isPortal %}m-t-30{% endif %}">
+  <div class="row page-list hidden-print {% if page.isPortal() %}m-t-30{% endif %}">
     <div class="col-md-10">
       {% include '../widget/page_list_and_timeline.html' %}
     </div>

+ 1 - 1
src/server/views/layout-kibela/page_list.html

@@ -35,7 +35,7 @@
 
 
 
-  <div class="row page-list bg-white round-corner p-t-10 m-20 m-b-30 {% if isPortal %}m-t-30{% endif %}">
+  <div class="row page-list bg-white round-corner p-t-10 m-20 m-b-30 {% if page.isPortal() %}m-t-30{% endif %}">
     <div class="col-xs-12">
       {% include '../widget/page_list_and_timeline_kibela.html' %}
     </div>

+ 8 - 13
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 %}
@@ -34,24 +34,19 @@
     </div>
     {% endif %}
 
-    {% if req.query.renamed and not page.isDeleted() %}
-    <div class="alert alert-info alert-moved">
-      <span>
-        <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.sanitize(req.query.renamed)) }}
-      </span>
-    </div>
-    {% endif %}
-
-    {% if req.query.redirectFrom and not page.isDeleted() %}
+    {% if not page.isDeleted() and (req.query.renamed or req.query.redirectFrom) %}
     <div class="alert alert-info alert-moved d-flex align-items-center justify-content-between">
       <span>
-        <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.sanitize(req.query.redirectFrom)) }}
+        {% if req.query.renamed %}
+          <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.sanitize(req.query.renamed)) }}
+        {% else %}
+          <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.sanitize(req.query.redirectFrom)) }}
+        {% endif %}
       </span>
       {% if user %}
       <form role="form" id="unlink-page-form" onsubmit="return false;">
         <input type="hidden" name="_csrf" value="{{ csrf() }}">
-        <input type="hidden" name="path" value="{{ page.path }}">
-        <input type="hidden" name="page_id" value="{{ page._id.toString() }}">
+        <input type="hidden" name="path" value="{{ path }}">
         <button type="submit" class="btn btn-default btn-sm pull-right">
           <i class="ti-unlink" aria-hidden="true"></i>
           Unlink

+ 13 - 1
src/server/views/widget/page_content.html

@@ -1,8 +1,9 @@
+{% if page %}
 <div id="content-main" class="content-main"
   data-path="{{ path }}"
   data-path-shortname="{{ path|path2name }}"
-  data-page-id="{% if page %}{{ page._id.toString() }}{% endif %}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
+  data-page-id="{% if page %}{{ page._id.toString() }}{% endif %}"
   data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
   data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
   data-page-revision-id-hackmd-synced="{% if revisionHackmdSynced %}{{ revisionHackmdSynced.toString() }}{% endif %}"
@@ -11,6 +12,14 @@
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
   data-slack-channels="{{ slack|default('') }}"
   >
+{% else %}
+<div id="content-main" class="content-main"
+  data-path="{{ path }}"
+  data-path-shortname="{{ path|path2name }}"
+  data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
+  data-slack-channels="{{ slack|default('') }}"
+  >
+{% endif %}
 
   {% include 'page_alerts.html' %}
 
@@ -33,6 +42,9 @@
         </div>
         <div id="page" class="m-t-15"></div>
       </div>
+    {% elseif 'crowi' === behaviorType() %}
+      <div class="tab-pane active" id="cancel-creating-portal">
+      </div>
     {% endif %}
 
     {% if not page.isDeleted() %}

+ 5 - 5
src/server/views/widget/page_tabs.html

@@ -29,7 +29,7 @@
     Right Tabs
   #}
   {% if !isTrashPage() %}
-    {% if isPortal %}
+    {% if page.isPortal() %}
     <li class="nav-main-right-tab dropdown pull-right">
       <a class="dropdown-toggle {% if not user %}dropdown-disabled{% endif %}" {% if user %}data-toggle="dropdown" href="#"{% endif %}>
         <i class="icon-options-vertical"></i>
@@ -66,7 +66,7 @@
       <i class="icon-layers"></i><span class="hidden-xs"> {{ t('History') }}</span>
     </a>
   </li>
-  {% if not isPortal %}
+  {% if not page.isPortal() %}
     <li class="nav-main-right-tab pull-right">
       <a href="?presentation=1" class="toggle-presentation">
         <i class="icon-film"></i><span class="hidden-xs"> {{ t('Presentation Mode') }}</span>
@@ -81,13 +81,13 @@
 <ul class="nav nav-tabs nav-tabs-create-portal hidden-print">
 
   <li class="nav-main-left-tab">
-    <a id="portal-form-close" href="#" data-toggle="tab">
+    <a id="portal-form-close" data-toggle="tab" href="#cancel-creating-portal">
       <i class="icon-action-undo"></i> {{ t('Cancel') }}
     </a>
   </li>
 
-  <li class="nav-main-left-tab active">
-    <a>
+  <li class="nav-main-left-tab">
+    <a {% if user %}href="#edit" data-toggle="tab"{% endif %} class="edit-button {% if not user %}edit-button-disabled{% endif %}">
       <i class="icon-note"></i> {{ t('Create') }}
     </a>
   </li>

+ 2 - 2
src/server/views/widget/page_tabs_kibela.html

@@ -29,7 +29,7 @@
     Right Tabs
   #}
   {% if !isTrashPage() %}
-    {% if isPortal %}
+    {% if page.isPortal() %}
     <li class="nav-main-right-tab dropdown pull-right">
       <a class="dropdown-toggle {% if not user %}dropdown-disabled{% endif %}" {% if user %}data-toggle="dropdown" href="#"{% endif %}>
         <i class="icon-options-vertical"></i>
@@ -66,7 +66,7 @@
       <i class="icon-layers"></i><span class="hidden-xs"> {{ t('History') }}</span>
     </a>
   </li>
-  {% if not isPortal %}
+  {% if not page.isPortal() %}
     <li class="nav-main-right-tab pull-right">
       <a href="?presentation=1" class="toggle-presentation">
         <i class="icon-film"></i><span class="hidden-xs"> {{ t('Presentation Mode') }}</span>

+ 182 - 285
src/test/models/page.test.js

@@ -1,140 +1,120 @@
-var chai = require('chai')
+const chai = require('chai')
   , expect = chai.expect
-  , sinon = require('sinon')
   , sinonChai = require('sinon-chai')
   , utils = require('../utils.js')
   ;
 chai.use(sinonChai);
 
 describe('Page', () => {
-  var Page = utils.models.Page,
+  const Page = utils.models.Page,
     User   = utils.models.User,
-    UserGroup = utils.models.UserGroup,
-    UserGroupRelation = utils.models.UserGroupRelation,
-    PageGroupRelation = utils.models.PageGroupRelation,
-    conn   = utils.mongoose.connection,
-    createdPages,
+    conn   = utils.mongoose.connection;
+
+  let createdPages,
     createdUsers,
     createdUserGroups;
 
-  before(done => {
-    conn.collection('pages').remove().then(() => {
-      var userFixture = [
-        { name: 'Anon 0', username: 'anonymous0', email: 'anonymous0@example.com' },
-        { name: 'Anon 1', username: 'anonymous1', email: 'anonymous1@example.com' },
-        { name: 'Anon 2', username: 'anonymous2', email: 'anonymous2@example.com' },
-      ];
-
-      return testDBUtil.generateFixture(conn, 'User', userFixture);
-    }).then(testUsers => {
-      createdUsers = testUsers;
-      var testUser0 = testUsers[0];
-      var testUser1 = testUsers[1];
-
-      var fixture = [
-        {
-          path: '/user/anonymous0/memo',
-          grant: Page.GRANT_RESTRICTED,
-          grantedUsers: [testUser0],
-          creator: testUser0
-        },
-        {
-          path: '/grant/public',
-          grant: Page.GRANT_PUBLIC,
-          grantedUsers: [testUser0],
-          creator: testUser0
-        },
-        {
-          path: '/grant/restricted',
-          grant: Page.GRANT_RESTRICTED,
-          grantedUsers: [testUser0],
-          creator: testUser0
-        },
-        {
-          path: '/grant/specified',
-          grant: Page.GRANT_SPECIFIED,
-          grantedUsers: [testUser0],
-          creator: testUser0
-        },
-        {
-          path: '/grant/owner',
-          grant: Page.GRANT_OWNER,
-          grantedUsers: [testUser0],
-          creator: testUser0,
-        },
-        {
-          path: '/page/for/extended',
-          grant: Page.GRANT_PUBLIC,
-          creator: testUser0,
-          extended: {hoge: 1}
-        },
-        {
-          path: '/grant/groupacl',
-          grant: 5,
-          grantedUsers: [],
-          creator: testUser1,
-        },
-        {
-          path: '/page1',
-          grant: Page.GRANT_PUBLIC,
-          creator: testUser0,
-        },
-        {
-          path: '/page1/child1',
-          grant: Page.GRANT_PUBLIC,
-          creator: testUser0,
-        },
-        {
-          path: '/page2',
-          grant: Page.GRANT_PUBLIC,
-          creator: testUser0,
-        },
-      ];
-
-      return testDBUtil.generateFixture(conn, 'Page', fixture);
-    })
-    .then(pages => {
-      createdPages = pages;
-      groupFixture = [
-        {
-          image: '',
-          name: 'TestGroup0',
-        },
-        {
-          image: '',
-          name: 'TestGroup1',
-        },
-      ];
-
-      return testDBUtil.generateFixture(conn, 'UserGroup', groupFixture);
-    })
-    .then(userGroups => {
-      createdUserGroups = userGroups;
-      testGroup0 = createdUserGroups[0];
-      testUser0 = createdUsers[0];
-      userGroupRelationFixture = [
-        {
-          relatedGroup: testGroup0,
-          relatedUser: testUser0,
-        }
-      ];
-      return testDBUtil.generateFixture(conn, 'UserGroupRelation', userGroupRelationFixture);
-    })
-    .then(userGroupRelations => {
-      testGroup0 = createdUserGroups[0];
-      testPage = createdPages[6];
-      pageGroupRelationFixture = [
-        {
-          relatedGroup: testGroup0,
-          targetPage: testPage,
-        }
-      ];
-
-      return testDBUtil.generateFixture(conn, 'PageGroupRelation', pageGroupRelationFixture)
-      .then(pageGroupRelations => {
-        done();
-      });
-    });
+  before(async() => {
+    await conn.collection('pages').remove();
+
+    const userFixture = [
+      { name: 'Anon 0', username: 'anonymous0', email: 'anonymous0@example.com' },
+      { name: 'Anon 1', username: 'anonymous1', email: 'anonymous1@example.com' },
+      { name: 'Anon 2', username: 'anonymous2', email: 'anonymous2@example.com' },
+    ];
+
+    createdUsers = await testDBUtil.generateFixture(conn, 'User', userFixture);
+
+    const testUser0 = createdUsers[0];
+    const testUser1 = createdUsers[1];
+
+    const groupFixture = [
+      {
+        image: '',
+        name: 'TestGroup0',
+      },
+      {
+        image: '',
+        name: 'TestGroup1',
+      },
+    ];
+    createdUserGroups = await testDBUtil.generateFixture(conn, 'UserGroup', groupFixture);
+
+    const testGroup0 = createdUserGroups[0];
+    const userGroupRelationFixture = [
+      {
+        relatedGroup: testGroup0,
+        relatedUser: testUser0,
+      },
+      {
+        relatedGroup: testGroup0,
+        relatedUser: testUser1,
+      }
+    ];
+    await testDBUtil.generateFixture(conn, 'UserGroupRelation', userGroupRelationFixture);
+
+    const fixture = [
+      {
+        path: '/user/anonymous0/memo',
+        grant: Page.GRANT_RESTRICTED,
+        grantedUsers: [testUser0],
+        creator: testUser0
+      },
+      {
+        path: '/grant/public',
+        grant: Page.GRANT_PUBLIC,
+        grantedUsers: [testUser0],
+        creator: testUser0
+      },
+      {
+        path: '/grant/restricted',
+        grant: Page.GRANT_RESTRICTED,
+        grantedUsers: [testUser0],
+        creator: testUser0
+      },
+      {
+        path: '/grant/specified',
+        grant: Page.GRANT_SPECIFIED,
+        grantedUsers: [testUser0],
+        creator: testUser0
+      },
+      {
+        path: '/grant/owner',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [testUser0],
+        creator: testUser0,
+      },
+      {
+        path: '/page/for/extended',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser0,
+        extended: {hoge: 1}
+      },
+      {
+        path: '/grant/groupacl',
+        grant: 5,
+        grantedUsers: [],
+        grantedGroup: testGroup0,
+        creator: testUser1,
+      },
+      {
+        path: '/page1',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser0,
+      },
+      {
+        path: '/page1/child1',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser0,
+      },
+      {
+        path: '/page2',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser0,
+      },
+    ];
+    createdPages = await testDBUtil.generateFixture(conn, 'Page', fixture);
+
   });
 
   describe('.isPublic', () => {
@@ -229,77 +209,34 @@ describe('Page', () => {
     });
   });
 
-  describe('.isCreator', () => {
-    context('with creator', () => {
-      it('should return true', done => {
-        User.findOne({email: 'anonymous0@example.com'}, (err, user) => {
-          if (err) { done(err); }
-
-          Page.findOne({path: '/user/anonymous0/memo'}, (err, page) => {
-            expect(page.isCreator(user)).to.be.equal(true);
-            done();
-          })
-        });
-      });
-    });
-
-    context('with non-creator', () => {
-      it('should return false', done => {
-        User.findOne({email: 'anonymous1@example.com'}, (err, user) => {
-          if (err) { done(err); }
-
-          Page.findOne({path: '/user/anonymous0/memo'}, (err, page) => {
-            expect(page.isCreator(user)).to.be.equal(false);
-            done();
-          })
-        });
-      });
-    });
-  });
-
-  describe('.isGrantedFor', () => {
+  describe('.isAccessiblePageByViewer', () => {
     context('with a granted user', () => {
-      it('should return true', done => {
-        User.findOne({email: 'anonymous0@example.com'}, (err, user) => {
-          if (err) { done(err); }
-
-          Page.findOne({path: '/user/anonymous0/memo'}, (err, page) => {
-            if (err) { done(err); }
+      it('should return true', async() => {
+        const user = await User.findOne({email: 'anonymous0@example.com'});
+        const page = await Page.findOne({path: '/user/anonymous0/memo'});
 
-            expect(page.isGrantedFor(user)).to.be.equal(true);
-            done();
-          });
-        });
+        const bool = await Page.isAccessiblePageByViewer(page.id, user);
+        expect(bool).to.be.equal(true);
       });
     });
 
     context('with a public page', () => {
-      it('should return true', done => {
-        User.findOne({email: 'anonymous1@example.com'}, (err, user) => {
-          if (err) { done(err); }
+      it('should return true', async() => {
+        const user = await User.findOne({email: 'anonymous1@example.com'});
+        const page = await Page.findOne({path: '/grant/public'});
 
-          Page.findOne({path: '/grant/public'}, (err, page) => {
-            if (err) { done(err); }
-
-            expect(page.isGrantedFor(user)).to.be.equal(true);
-            done();
-          });
-        });
+        const bool = await Page.isAccessiblePageByViewer(page.id, user);
+        expect(bool).to.be.equal(true);
       });
     });
 
     context('with a restricted page and an user who has no grant', () => {
-      it('should return false', done => {
-        User.findOne({email: 'anonymous1@example.com'}, (err, user) => {
-          if (err) { done(err); }
-
-          Page.findOne({path: '/grant/restricted'}, (err, page) => {
-            if (err) { done(err); }
+      it('should return false', async() => {
+        const user = await User.findOne({email: 'anonymous1@example.com'});
+        const page = await Page.findOne({path: '/grant/restricted'});
 
-            expect(page.isGrantedFor(user)).to.be.equal(false);
-            done();
-          });
-        });
+        const bool = await Page.isAccessiblePageByViewer(page.id, user);
+        expect(bool).to.be.equal(false);
       });
     });
   });
@@ -345,132 +282,92 @@ describe('Page', () => {
   });
 
   describe('.findPage', () => {
-    context('findPageById', () => {
-      it('should find page', done => {
-        const pageToFind = createdPages[0];
-        Page.findPageById(pageToFind._id)
-        .then(pageData => {
-          expect(pageData.path).to.equal(pageToFind.path);
-          done();
-        });
-      });
-    });
-
-    context('findPageByIdAndGrantedUser', () => {
-      it('should find page', done => {
+    context('findByIdAndViewer', () => {
+      it('should find page', async() => {
         const pageToFind = createdPages[0];
         const grantedUser = createdUsers[0];
-        Page.findPageByIdAndGrantedUser(pageToFind._id, grantedUser)
-        .then((pageData) => {
-          expect(pageData.path).to.equal(pageToFind.path);
-          done();
-        })
-        .catch((err) => {
-          done(err);
-        });
+
+        const page = await Page.findByIdAndViewer(pageToFind._id, grantedUser);
+        expect(page).to.be.not.null;
+        expect(page.path).to.equal(pageToFind.path);
       });
 
-      it('should error by grant', done => {
+      it('should not be found by grant', async() => {
         const pageToFind = createdPages[0];
         const grantedUser = createdUsers[1];
-        Page.findPageByIdAndGrantedUser(pageToFind._id, grantedUser)
-        .then(pageData => {
-          done(new Error());
-        }).catch(err => {
-          expect(err).to.instanceof(Error);
-          done();
-        });
+
+        const page = await Page.findByIdAndViewer(pageToFind._id, grantedUser);
+        expect(page).to.be.null;
       });
     });
 
-    context('findPageByIdAndGrantedUser granted userGroup', () => {
-      it('should find page', done => {
+    context('findByIdAndViewer granted userGroup', () => {
+      it('should find page', async() => {
         const pageToFind = createdPages[6];
         const grantedUser = createdUsers[0];
-        Page.findPageByIdAndGrantedUser(pageToFind._id, grantedUser)
-        .then(pageData => {
-          expect(pageData.path).to.equal(pageToFind.path);
-          done();
-        })
-        .catch((err) => {
-          done(err);
-        });
+
+        const page = await Page.findByIdAndViewer(pageToFind._id, grantedUser);
+        expect(page).to.be.not.null;
+        expect(page.path).to.equal(pageToFind.path);
       });
 
-      it('should error by grant userGroup', done => {
+      it('should not be found by grant', async() => {
         const pageToFind = createdPages[6];
         const grantedUser = createdUsers[2];
-        Page.findPageByIdAndGrantedUser(pageToFind._id, grantedUser)
-          .then(pageData => {
-            done(new Error());
-          }).catch(err => {
-            expect(err).to.instanceof(Error);
-            done();
-          });
+
+        const page = await Page.findByIdAndViewer(pageToFind._id, grantedUser);
+        expect(page).to.be.null;
       });
     });
   });
 
-  context('generateQueryToListByStartWith', () => {
-    it('should return only /page/', done => {
+  context('findListWithDescendants', () => {
+    it('should return only /page/', async() => {
       const user = createdUsers[0];
-      Page.generateQueryToListByStartWith('/page/', user, { isRegExpEscapedFromPath: true })
-      .then(pages => {
-        // assert length
-        expect(pages.length).to.equal(1);
-        // assert paths
-        const pagePaths = pages.map(page => page.path);
-        expect(pagePaths).to.include.members(['/page/for/extended'])
-        done();
-      })
-      .catch((err) => {
-        done(err);
-      });
+
+      const result = await Page.findListWithDescendants('/page/', user, { isRegExpEscapedFromPath: true });
+
+      // assert totalCount
+      expect(result.totalCount).to.equal(1);
+      // assert paths
+      const pagePaths = result.pages.map(page => page.path);
+      expect(pagePaths).to.include.members(['/page/for/extended']);
     });
-    it('should return only /page1/', done => {
+    it('should return only /page1/', async() => {
       const user = createdUsers[0];
-      Page.generateQueryToListByStartWith('/page1/', user, { isRegExpEscapedFromPath: true })
-      .then(pages => {
-        // assert length
-        expect(pages.length).to.equal(2);
-        // assert paths
-        const pagePaths = pages.map(page => page.path);
-        expect(pagePaths).to.include.members(['/page1', '/page1/child1'])
-        done();
-      })
-      .catch((err) => {
-        done(err);
-      });
+
+      const result = await Page.findListWithDescendants('/page1/', user, { isRegExpEscapedFromPath: true });
+
+      // assert totalCount
+      expect(result.totalCount).to.equal(2);
+      // assert paths
+      const pagePaths = result.pages.map(page => page.path);
+      expect(pagePaths).to.include.members(['/page1', '/page1/child1']);
     });
-    it('should return pages which starts with /page', done => {
+  });
+
+  context('findListByStartWith', () => {
+    it('should return pages which starts with /page', async() => {
       const user = createdUsers[0];
-      Page.generateQueryToListByStartWith('/page', user, {})
-      .then(pages => {
-        // assert length
-        expect(pages.length).to.equal(4);
-        // assert paths
-        const pagePaths = pages.map(page => page.path);
-        expect(pagePaths).to.include.members(['/page/for/extended', '/page1', '/page1/child1', '/page2'])
-        done();
-      })
-      .catch((err) => {
-        done(err);
-      });
+
+      const result = await Page.findListByStartWith('/page', user, {});
+
+      // assert totalCount
+      expect(result.totalCount).to.equal(4);
+      // assert paths
+      const pagePaths = result.pages.map(page => page.path);
+      expect(pagePaths).to.include.members(['/page/for/extended', '/page1', '/page1/child1', '/page2']);
     });
-    it('should process with regexp', done => {
+    it('should process with regexp', async() => {
       const user = createdUsers[0];
-      Page.generateQueryToListByStartWith('/page\\d{1}/', user, {})
-      .then(pages => {
-        // assert length
-        expect(pages.length).to.equal(3);
-        // assert paths
-        const pagePaths = pages.map(page => page.path);
-        expect(pagePaths).to.include.members(['/page1', '/page1/child1', '/page2'])
-        done();
-      })
-      .catch((err) => {
-        done(err);
-      });
+
+      const result = await Page.findListByStartWith('/page\\d{1}/', user, {});
+
+      // assert totalCount
+      expect(result.totalCount).to.equal(3);
+      // assert paths
+      const pagePaths = result.pages.map(page => page.path);
+      expect(pagePaths).to.include.members(['/page1', '/page1/child1', '/page2']);
     });
   });
 

Некоторые файлы не были показаны из-за большого количества измененных файлов