Browse Source

Merge commit '3b45960e31722cdce375618f10b8e62fa02b52a4' into feat/limit-amount-of-gridfs-use

yusueketk 7 years ago
parent
commit
f0f7e12213
71 changed files with 2710 additions and 2354 deletions
  1. 13 1
      CHANGES.md
  2. 1 1
      bin/wercker/trigger-growi-docker.sh
  3. 2 0
      config/webpack.common.js
  4. 5 4
      package.json
  5. 6 1
      resource/locales/en-US/translation.json
  6. 5 0
      resource/locales/ja/translation.json
  7. 34 44
      resource/search/mappings.json
  8. 9 6
      src/client/js/app.js
  9. 66 0
      src/client/js/components/Admin/AdminRebuildSearch.jsx
  10. 87 37
      src/client/js/components/InstallerForm.js
  11. 64 0
      src/client/js/components/Page.jsx
  12. 114 0
      src/client/js/components/Page/RevisionLoader.jsx
  13. 10 37
      src/client/js/components/Page/RevisionRenderer.jsx
  14. 2 4
      src/client/js/components/PageComment/Comment.js
  15. 3 4
      src/client/js/components/PageComment/CommentForm.jsx
  16. 1 2
      src/client/js/components/PageEditor.js
  17. 1 1
      src/client/js/components/PageEditor/MarkdownTableDataImportForm.jsx
  18. 17 12
      src/client/js/components/PageList/PageListMeta.js
  19. 17 6
      src/client/js/components/PageList/PagePath.js
  20. 2 2
      src/client/js/components/RecentCreated/RecentCreated.jsx
  21. 4 4
      src/client/js/components/SearchPage/SearchResultList.js
  22. 2 0
      src/client/js/hackmd-agent.js
  23. 17 11
      src/client/js/legacy/crowi.js
  24. 10 2
      src/client/js/util/Crowi.js
  25. 5 5
      src/client/js/util/GrowiRenderer.js
  26. 7 2
      src/client/js/util/markdown-it/task-lists.js
  27. 1 4
      src/client/styles/agile-admin/inverse/widgets.scss
  28. 23 15
      src/client/styles/scss/_wiki.scss
  29. 112 0
      src/migrations/20181019114028-abolish-page-group-relation.js
  30. 2 0
      src/server/crowi/index.js
  31. 15 0
      src/server/events/bookmark.js
  32. 11 0
      src/server/events/search.js
  33. 19 17
      src/server/events/user.js
  34. 7 6
      src/server/form/admin/securityGeneral.js
  35. 2 1
      src/server/form/register.js
  36. 3 10
      src/server/models/GlobalNotificationSetting/index.js
  37. 28 37
      src/server/models/bookmark.js
  38. 13 0
      src/server/models/config.js
  39. 486 629
      src/server/models/page.js
  40. 0 16
      src/server/models/revision.js
  41. 19 9
      src/server/models/user-group-relation.js
  42. 75 66
      src/server/routes/admin.js
  43. 1 1
      src/server/routes/attachment.js
  44. 53 47
      src/server/routes/bookmark.js
  45. 50 30
      src/server/routes/comment.js
  46. 6 6
      src/server/routes/index.js
  47. 9 1
      src/server/routes/installer.js
  48. 301 579
      src/server/routes/page.js
  49. 31 22
      src/server/routes/revision.js
  50. 52 36
      src/server/routes/search.js
  51. 44 0
      src/server/util/apiPaginate.js
  52. 456 228
      src/server/util/search.js
  53. 2 2
      src/server/views/_form.html
  54. 71 1
      src/server/views/admin/search.html
  55. 45 3
      src/server/views/admin/security.html
  56. 5 33
      src/server/views/installer.html
  57. 1 1
      src/server/views/layout-crowi/page_list.html
  58. 1 1
      src/server/views/layout-growi/page_list.html
  59. 1 1
      src/server/views/layout-kibela/page_list.html
  60. 0 1
      src/server/views/widget/forbidden_content.html
  61. 0 1
      src/server/views/widget/not_found_content.html
  62. 8 13
      src/server/views/widget/page_alerts.html
  63. 12 2
      src/server/views/widget/page_content.html
  64. 1 2
      src/server/views/widget/page_list.html
  65. 1 1
      src/server/views/widget/page_list_and_timeline.html
  66. 1 1
      src/server/views/widget/page_list_and_timeline_kibela.html
  67. 5 5
      src/server/views/widget/page_tabs.html
  68. 2 2
      src/server/views/widget/page_tabs_kibela.html
  69. 182 285
      src/test/models/page.test.js
  70. 4 2
      wercker.yml
  71. 45 51
      yarn.lock

+ 13 - 1
CHANGES.md

@@ -1,10 +1,22 @@
 CHANGES
 ========
 
-## 3.2.10-RC
+## 3.3.0-RC
+
+* Feature: Add option to show/hide restricted pages in list
+* Improvement: Refactor Access Control
+* Improvement: Checkbox behavior of task list
+* Fix: Hide restricted pages contents in timeline
+* Support: Upgrade libs
+    * googleapis
+    * passport-saml
+
+## 3.2.10
 
 * Fix: Pages in trash are available to create
 * Fix: Couldn't create portal page under Crowi Classic Behavior
+* Fix: Table tag in Timeline/SearchResult missed border and BS3 styles
+* I18n: Installer
 
 
 ## 3.2.9

+ 1 - 1
bin/wercker/trigger-growi-docker.sh

@@ -24,7 +24,7 @@ RESPONSE=`curl -X POST \
       }, \
       { \
         "key": "GROWI_REPOS_GIT_COMMIT", \
-        "value": "'$WERCKER_GIT_COMMIT'" \
+        "value": "'$RELEASE_GIT_COMMIT'" \
       } \
     ] \
   }' \

+ 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

+ 5 - 4
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.2.10-RC",
+  "version": "3.3.0-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -85,7 +85,7 @@
     "express-sanitizer": "^1.0.4",
     "express-session": "~1.15.0",
     "express-webpack-assets": "^0.1.0",
-    "googleapis": "^35.0.0",
+    "googleapis": "^36.0.0",
     "graceful-fs": "^4.1.11",
     "growi-pluginkit": "^1.1.0",
     "helmet": "^3.13.0",
@@ -112,7 +112,7 @@
     "passport-google-auth": "^1.0.2",
     "passport-ldapauth": "^2.0.0",
     "passport-local": "^1.0.0",
-    "passport-saml": "^0.35.0",
+    "passport-saml": "^1.0.0",
     "passport-twitter": "^1.0.4",
     "rimraf": "^2.6.1",
     "slack-node": "^0.1.8",
@@ -168,7 +168,7 @@
     "markdown-it-mathjax": "^2.0.0",
     "markdown-it-named-headers": "^0.0.4",
     "markdown-it-plantuml": "^1.0.0",
-    "markdown-it-task-lists": "^2.1.0",
+    "markdown-it-task-checkbox": "^1.0.6",
     "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
     "markdown-table": "^1.1.1",
     "metismenu": "^3.0.3",
@@ -193,6 +193,7 @@
     "react-dropzone": "^7.0.1",
     "react-frame-component": "^4.0.0",
     "react-i18next": "=7.13.0",
+    "react-waypoint": "^8.1.0",
     "reveal.js": "^3.5.0",
     "sass-loader": "^7.1.0",
     "simple-load-script": "^1.0.2",

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

+ 87 - 37
src/client/js/components/InstallerForm.js

@@ -1,49 +1,99 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import i18next from 'i18next';
 import { translate } from 'react-i18next';
 
 class InstallerForm extends React.Component {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isValidUserName: true,
+    };
+    this.checkUserName = this.checkUserName.bind(this);
+  }
+
+  checkUserName(event) {
+    const axios = require('axios').create({
+      headers: {
+        'Content-Type': 'application/json',
+        'X-Requested-With': 'XMLHttpRequest'
+      },
+      responseType: 'json'
+    });
+    axios.get('/_api/check_username', {params: {username: event.target.value}})
+      .then((res) => this.setState({ isValidUserName: res.data.valid }));
+  }
+
+  changeLanguage(locale) {
+    i18next.changeLanguage(locale);
+  }
+
   render() {
+    const hasErrorClass = this.state.isValidUserName ? '' : ' has-error';
+    const unavailableUserId = this.state.isValidUserName ? '' : <span><i className="icon-fw icon-ban" />{ this.props.t('installer.unavaliable_user_id') }</span>;
     return (
-      <form role="form" action="/installer/createAdmin" method="post" id="register-form">
-        <div className="input-group" id="input-group-username">
-          <span className="input-group-addon"><i className="icon-user"></i></span>
-          <input type="text" className="form-control" placeholder={ this.props.t('User ID') }
-            name="registerForm[username]" defaultValue={this.props.userName} required />
-        </div>
-        <p className="help-block">
-          <span id="help-block-username"></span>
+      <div className={'login-dialog p-t-10 p-b-10 col-sm-offset-4 col-sm-4' + hasErrorClass}>
+        <p className="alert alert-success">
+          <strong>{ this.props.t('installer.create_initial_account') }</strong><br />
+          <small>{ this.props.t('installer.initial_account_will_be_administrator_automatically') }</small>
         </p>
 
-        <div className="input-group">
-          <span className="input-group-addon"><i className="icon-tag"></i></span>
-          <input type="text" className="form-control" placeholder={ this.props.t('Name') } name="registerForm[name]" defaultValue={ this.props.name } required />
-        </div>
-
-        <div className="input-group">
-          <span className="input-group-addon"><i className="icon-envelope"></i></span>
-          <input type="email" className="form-control" placeholder={ this.props.t('Email') } name="registerForm[email]" defaultValue={ this.props.email } required />
-        </div>
-
-        <div className="input-group">
-          <span className="input-group-addon"><i className="icon-lock"></i></span>
-          <input type="password" className="form-control" placeholder={ this.props.t('Password') } name="registerForm[password]" required />
-        </div>
-
-        <input type="hidden" name="_csrf" value={ this.props.csrf } />
-        <div className="input-group m-t-30 m-b-20 d-flex justify-content-center">
-          <button type="submit" className="fcbtn btn btn-success btn-1b btn-register">
-            <span className="btn-label"><i className="icon-user-follow"></i></span>
-            { this.props.t('Create') }
-          </button>
-        </div>
-
-        <div className="input-group m-t-30 d-flex justify-content-center">
-          <a href="https://growi.org" className="link-growi-org">
-            <span className="growi">GROWI</span>.<span className="org">ORG</span>
-          </a>
-        </div>
-      </form>
+        <form role="form" action="/installer/createAdmin" method="post" id="register-form">
+          <div className={'input-group' + hasErrorClass}>
+            <span className="input-group-addon"><i className="icon-user" /></span>
+            <input type="text" className="form-control" placeholder={ this.props.t('User ID') }
+              name="registerForm[username]" defaultValue={this.props.userName} onBlur={this.checkUserName} required />
+          </div>
+          <p className="help-block">{ unavailableUserId }</p>
+
+          <div className="input-group">
+            <span className="input-group-addon"><i className="icon-tag" /></span>
+            <input type="text" className="form-control" placeholder={ this.props.t('Name') }
+                   name="registerForm[name]" defaultValue={ this.props.name } required />
+          </div>
+
+          <div className="input-group">
+            <span className="input-group-addon"><i className="icon-envelope" /></span>
+            <input type="email" className="form-control" placeholder={ this.props.t('Email') }
+                   name="registerForm[email]" defaultValue={ this.props.email } required />
+          </div>
+
+          <div className="input-group">
+            <span className="input-group-addon"><i className="icon-lock" /></span>
+            <input type="password" className="form-control" placeholder={ this.props.t('Password') }
+                   name="registerForm[password]" required />
+          </div>
+
+          <input type="hidden" name="_csrf" value={ this.props.csrf } />
+
+          <div className="input-group m-t-20 m-b-20 mx-auto">
+            <div className="radio radio-primary radio-inline">
+              <input type="radio" id="radioLangEn" name="registerForm[app:globalLang]" value="en-US"
+                     defaultChecked={ true } onClick={() => this.changeLanguage('en-US')} />
+              <label htmlFor="radioLangEn">{ this.props.t('English') }</label>
+            </div>
+            <div className="radio radio-primary radio-inline">
+              <input type="radio" id="radioLangJa" name="registerForm[app:globalLang]" value="ja"
+                     defaultChecked={ false } onClick={() => this.changeLanguage('ja')} />
+              <label htmlFor="radioLangJa">{ this.props.t('Japanese') }</label>
+            </div>
+          </div>
+
+          <div className="input-group m-t-30 m-b-20 d-flex justify-content-center">
+            <button type="submit" className="fcbtn btn btn-success btn-1b btn-register">
+              <span className="btn-label"><i className="icon-user-follow" /></span>
+              { this.props.t('Create') }
+            </button>
+          </div>
+
+          <div className="input-group m-t-30 d-flex justify-content-center">
+            <a href="https://growi.org" className="link-growi-org">
+              <span className="growi">GROWI</span>.<span className="org">ORG</span>
+            </a>
+          </div>
+        </form>
+      </div>
     );
   }
 }

+ 64 - 0
src/client/js/components/Page.jsx

@@ -0,0 +1,64 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import RevisionRenderer from './Page/RevisionRenderer';
+import HandsontableModal from './PageEditor/HandsontableModal';
+import MarkdownTable from '../models/MarkdownTable';
+import mtu from './PageEditor/MarkdownTableUtil';
+
+export default class Page extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      markdown: this.props.markdown,
+      currentTargetTableArea: null
+    };
+
+    this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
+  }
+
+  setMarkdown(markdown) {
+    this.setState({ markdown });
+  }
+
+  /**
+   * launch HandsontableModal with data specified by arguments
+   * @param beginLineNumber
+   * @param endLineNumber
+   */
+  launchHandsontableModal(beginLineNumber, endLineNumber) {
+    const tableLines = this.state.markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber).join('\n');
+    this.setState({currentTargetTableArea: {beginLineNumber, endLineNumber}});
+    this.refs.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
+  }
+
+  saveHandlerForHandsontableModal(markdownTable) {
+    const newMarkdown = mtu.replaceMarkdownTableInMarkdown(markdownTable, this.state.markdown, this.state.currentTargetTableArea.beginLineNumber, this.state.currentTargetTableArea.endLineNumber);
+    this.props.onSaveWithShortcut(newMarkdown);
+    this.setState({currentTargetTableArea: null});
+  }
+
+  render() {
+    const isMobile = this.props.crowi.isMobile;
+
+    return <div className={isMobile ? 'page-mobile' : ''}>
+      <RevisionRenderer
+          crowi={this.props.crowi} crowiRenderer={this.props.crowiRenderer}
+          markdown={this.state.markdown}
+          pagePath={this.props.pagePath}
+      />
+      <HandsontableModal ref='handsontableModal' onSave={this.saveHandlerForHandsontableModal} />
+    </div>;
+  }
+}
+
+Page.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  crowiRenderer: PropTypes.object.isRequired,
+  onSaveWithShortcut: PropTypes.func.isRequired,
+  markdown: PropTypes.string.isRequired,
+  pagePath: PropTypes.string.isRequired,
+  showHeadEditButton: PropTypes.bool,
+};

+ 114 - 0
src/client/js/components/Page/RevisionLoader.jsx

@@ -0,0 +1,114 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Waypoint  from 'react-waypoint';
+
+import RevisionRenderer from './RevisionRenderer';
+
+/**
+ * Load data from server and render RevisionBody component
+ */
+export default class RevisionLoader extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.logger = require('@alias/logger')('growi:Page:RevisionLoader');
+
+    this.state = {
+      markdown: '',
+      isLoading: false,
+      isLoaded: false,
+      error: null,
+    };
+
+    this.loadData = this.loadData.bind(this);
+    this.onWaypointChange = this.onWaypointChange.bind(this);
+  }
+
+  componentWillMount() {
+    if (!this.props.lazy) {
+      this.loadData();
+    }
+  }
+
+  loadData() {
+    if (!this.state.isLoaded && !this.state.isLoading) {
+      this.setState({ isLoading: true });
+    }
+
+    const requestData = {
+      page_id: this.props.pageId,
+      revision_id: this.props.revisionId,
+    };
+
+    // load data with REST API
+    this.props.crowi.apiGet('/revisions.get', requestData)
+      .then(res => {
+        if (!res.ok) {
+          throw new Error(res.error);
+        }
+
+        this.setState({
+          markdown: res.revision.body,
+          error: null,
+        });
+      })
+      .catch(err => {
+        this.setState({ error: err });
+      })
+      .finally(() => {
+        this.setState({ isLoaded: true, isLoading: false });
+      });
+  }
+
+  onWaypointChange(event) {
+    if (event.currentPosition === Waypoint.above || event.currentPosition === Waypoint.inside) {
+      this.loadData();
+    }
+  }
+
+  render() {
+    // ----- before load -----
+    if (this.props.lazy && !this.state.isLoaded) {
+      return <Waypoint onPositionChange={this.onWaypointChange} bottomOffset='-100px'>
+        <div className="wiki"></div>
+      </Waypoint>;
+    }
+
+    // ----- loading -----
+    if (this.state.isLoading) {
+      return (
+        <div className="wiki">
+          <div className="text-muted text-center">
+            <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+          </div>
+        </div>
+      );
+    }
+
+    // ----- after load -----
+    let markdown = this.state.markdown;
+    if (this.state.error != null) {
+      markdown = `<span class="text-muted"><em>${this.state.error}</em></span>`;
+    }
+
+    return (
+      <RevisionRenderer
+          crowi={this.props.crowi} crowiRenderer={this.props.crowiRenderer}
+          pagePath={this.props.pagePath}
+          markdown={markdown}
+          highlightKeywords={this.props.highlightKeywords}
+      />
+    );
+  }
+}
+
+RevisionLoader.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  crowiRenderer: PropTypes.object.isRequired,
+  pageId: PropTypes.string.isRequired,
+  pagePath: PropTypes.string.isRequired,
+  revisionId: PropTypes.string.isRequired,
+  lazy: PropTypes.bool,
+  highlightKeywords: PropTypes.string,
+};

+ 10 - 37
src/client/js/components/Page.js → src/client/js/components/Page/RevisionRenderer.jsx

@@ -1,29 +1,25 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import RevisionBody from './Page/RevisionBody';
-import HandsontableModal from './PageEditor/HandsontableModal';
-import MarkdownTable from '../models/MarkdownTable';
-import mtu from './PageEditor/MarkdownTableUtil';
+import RevisionBody from './RevisionBody';
 
-export default class Page extends React.Component {
+export default class RevisionRenderer extends React.Component {
 
   constructor(props) {
     super(props);
 
     this.state = {
       html: '',
-      markdown: '',
-      currentTargetTableArea: null
     };
 
     this.renderHtml = this.renderHtml.bind(this);
     this.getHighlightedBody = this.getHighlightedBody.bind(this);
-    this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
+
+    this.setMarkdown(this.props.markdown);
   }
 
-  componentWillMount() {
-    this.renderHtml(this.props.markdown, this.props.highlightKeywords);
+  componentWillReceiveProps(nextProps) {
+    this.renderHtml(nextProps.markdown, this.props.highlightKeywords);
   }
 
   setMarkdown(markdown) {
@@ -52,27 +48,9 @@ export default class Page extends React.Component {
     return returnBody;
   }
 
-  /**
-   * launch HandsontableModal with data specified by arguments
-   * @param beginLineNumber
-   * @param endLineNumber
-   */
-  launchHandsontableModal(beginLineNumber, endLineNumber) {
-    const tableLines = this.state.markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber).join('\n');
-    this.setState({currentTargetTableArea: {beginLineNumber, endLineNumber}});
-    this.refs.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
-  }
-
-  saveHandlerForHandsontableModal(markdownTable) {
-    const newMarkdown = mtu.replaceMarkdownTableInMarkdown(markdownTable, this.state.markdown, this.state.currentTargetTableArea.beginLineNumber, this.state.currentTargetTableArea.endLineNumber);
-    this.props.onSaveWithShortcut(newMarkdown);
-    this.setState({currentTargetTableArea: null});
-  }
-
   renderHtml(markdown, highlightKeywords) {
     let context = {
       markdown,
-      dom: this.revisionBodyElement,
       currentPagePath: this.props.pagePath,
     };
 
@@ -89,7 +67,7 @@ export default class Page extends React.Component {
       })
       .then(() => interceptorManager.process('prePostProcess', context))
       .then(() => {
-        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML, context.dom);
+        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML);
 
         // highlight
         if (highlightKeywords != null) {
@@ -108,27 +86,22 @@ export default class Page extends React.Component {
 
   render() {
     const config = this.props.crowi.getConfig();
-    const isMobile = this.props.crowi.isMobile;
     const isMathJaxEnabled = !!config.env.MATHJAX;
 
-    return <div className={isMobile ? 'page-mobile' : ''}>
+    return (
       <RevisionBody
           html={this.state.html}
-          inputRef={el => this.revisionBodyElement = el}
           isMathJaxEnabled={isMathJaxEnabled}
           renderMathJaxOnInit={true}
       />
-      <HandsontableModal ref='handsontableModal' onSave={this.saveHandlerForHandsontableModal} />
-    </div>;
+    );
   }
 }
 
-Page.propTypes = {
+RevisionRenderer.propTypes = {
   crowi: PropTypes.object.isRequired,
   crowiRenderer: PropTypes.object.isRequired,
-  onSaveWithShortcut: PropTypes.func.isRequired,
   markdown: PropTypes.string.isRequired,
   pagePath: PropTypes.string.isRequired,
-  showHeadEditButton: PropTypes.bool,
   highlightKeywords: PropTypes.string,
 };

+ 2 - 4
src/client/js/components/PageComment/Comment.js

@@ -74,7 +74,6 @@ export default class Comment extends React.Component {
     const isMathJaxEnabled = !!config.env.MATHJAX;
     return (
       <RevisionBody html={this.state.html}
-          inputRef={el => this.revisionBodyElement = el}
           isMathJaxEnabled={isMathJaxEnabled}
           renderMathJaxOnInit={true}
           additionalClassName="comment" />
@@ -82,9 +81,8 @@ export default class Comment extends React.Component {
   }
 
   renderHtml(markdown) {
-    var context = {
+    const context = {
       markdown,
-      dom: this.revisionBodyElement,
     };
 
     const crowiRenderer = this.props.crowiRenderer;
@@ -101,7 +99,7 @@ export default class Comment extends React.Component {
       })
       .then(() => interceptorManager.process('prePostProcess', context))
       .then(() => {
-        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML, context.dom);
+        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML);
       })
       .then(() => interceptorManager.process('postPostProcess', context))
       .then(() => interceptorManager.process('preRenderCommentHtml', context))

+ 3 - 4
src/client/js/components/PageComment/CommentForm.jsx

@@ -129,15 +129,14 @@ export default class CommentForm extends React.Component {
   getCommentHtml() {
     return (
       <CommentPreview
-        html={this.state.html}
-        inputRef={el => this.previewElement = el}/>
+        inputRef={el => this.previewElement = el}
+        html={this.state.html} />
     );
   }
 
   renderHtml(markdown) {
     const context = {
       markdown,
-      dom: this.previewElement,
     };
 
     const growiRenderer = this.growiRenderer;
@@ -154,7 +153,7 @@ export default class CommentForm extends React.Component {
       })
       .then(() => interceptorManager.process('prePostProcess', context))
       .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context.dom);
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
       })
       .then(() => interceptorManager.process('postPostProcess', context))
       .then(() => interceptorManager.process('preRenderCommentPreviewHtml', context))

+ 1 - 2
src/client/js/components/PageEditor.js

@@ -278,7 +278,6 @@ export default class PageEditor extends React.Component {
     // render html
     const context = {
       markdown: this.state.markdown,
-      dom: this.previewElement,
       currentPagePath: decodeURIComponent(location.pathname)
     };
 
@@ -296,7 +295,7 @@ export default class PageEditor extends React.Component {
       })
       .then(() => interceptorManager.process('prePostProcess', context))
       .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context.dom);
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
       })
       .then(() => interceptorManager.process('postPostProcess', context))
       .then(() => interceptorManager.process('preRenderPreviewHtml', context))

+ 1 - 1
src/client/js/components/PageEditor/MarkdownTableDataImportForm.jsx

@@ -68,7 +68,7 @@ export default class MarkdownTableDataImportForm extends React.Component {
         <Collapse in={this.state.parserErrorMessage != null}>
           <FormGroup>
             <ControlLabel>Parse Error</ControlLabel>
-            <FormControl componentClass="textarea" style={{ height: 100 }}  value={this.state.parserErrorMessage} readOnly/>
+            <FormControl componentClass="textarea" style={{ height: 100 }} value={this.state.parserErrorMessage || ''} readOnly/>
           </FormGroup>
         </Collapse>
         <div className="d-flex justify-content-end">

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

+ 4 - 4
src/client/js/components/SearchPage/SearchResultList.js

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 
 import GrowiRenderer from '../../util/GrowiRenderer';
 
-import Page from '../Page.js';
+import RevisionLoader from '../Page/RevisionLoader';
 
 export default class SearchResultList extends React.Component {
 
@@ -15,15 +15,15 @@ export default class SearchResultList extends React.Component {
 
   render() {
     const resultList = this.props.pages.map((page) => {
-      const pageBody = page.revision.body;
       return (
         <div id={page._id} key={page._id} className="search-result-page">
           <h2><a href={page.path}>{page.path}</a></h2>
-          <Page
+          <RevisionLoader
             crowi={this.props.crowi}
             crowiRenderer={this.growiRenderer}
-            markdown={pageBody}
+            pageId={page._id}
             pagePath={page.path}
+            revisionId={page.revision}
             highlightKeywords={this.props.searchingKeyword}
           />
         </div>

+ 2 - 0
src/client/js/hackmd-agent.js

@@ -122,6 +122,8 @@ function connectToParentWithPenpal() {
   });
   connection.promise.then(parent => {
     window.growi = parent;
+  }).catch(err => {
+    console.log(err);
   });
 }
 

+ 17 - 11
src/client/js/legacy/crowi.js

@@ -17,7 +17,7 @@ require('jquery.cookie');
 require('bootstrap-select');
 
 import GrowiRenderer from '../util/GrowiRenderer';
-import Page from '../components/Page';
+import RevisionLoader from '../components/Page/RevisionLoader';
 
 require('./thirdparty-js/agile-admin');
 
@@ -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');
 
@@ -524,7 +525,7 @@ $(function() {
 
   // for list page
   let growiRendererForTimeline = null;
-  $('a[data-toggle="tab"][href="#view-timeline"]').on('show.bs.tab', function() {
+  $('a[data-toggle="tab"][href="#view-timeline"]').on('shown.bs.tab', function() {
     const isShown = $('#view-timeline').data('shown');
 
     if (growiRendererForTimeline == null) {
@@ -534,16 +535,21 @@ $(function() {
     if (isShown == 0) {
       $('#view-timeline .timeline-body').each(function() {
         const id = $(this).attr('id');
-        const contentId = '#' + id + ' > script';
         const revisionBody = '#' + id + ' .revision-body';
         const revisionBodyElem = document.querySelector(revisionBody);
         /* eslint-disable no-unused-vars */
         const revisionPath = '#' + id + ' .revision-path';
         /* eslint-enable */
-        const pagePath = document.getElementById(id).getAttribute('data-page-path');
-        const markdown = entities.decodeHTML($(contentId).html());
-
-        ReactDOM.render(<Page crowi={crowi} crowiRenderer={growiRendererForTimeline} markdown={markdown} pagePath={pagePath} />, revisionBodyElem);
+        const timelineElm = document.getElementById(id);
+        const pageId = timelineElm.getAttribute('data-page-id');
+        const pagePath = timelineElm.getAttribute('data-page-path');
+        const revisionId = timelineElm.getAttribute('data-revision');
+
+        ReactDOM.render(
+          <RevisionLoader lazy={true}
+            crowi={crowi} crowiRenderer={growiRendererForTimeline}
+            pageId={pageId} pagePath={pagePath} revisionId={revisionId} />,
+          revisionBodyElem);
       });
 
       $('#view-timeline').data('shown', 1);
@@ -839,6 +845,6 @@ window.addEventListener('keydown', (event) => {
 });
 
 // adjust min-height of page for print temporarily
-window.onbeforeprint = function () {
-  $("#page-wrapper").css("min-height", "0px");
+window.onbeforeprint = function() {
+  $('#page-wrapper').css('min-height', '0px');
 };

+ 10 - 2
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;
   }
@@ -94,9 +101,10 @@ export default class Crowi {
     ];
 
     keys.forEach(key => {
-      if (this.localStorage[key]) {
+      const keyContent = this.localStorage[key];
+      if (keyContent) {
         try {
-          this[key] = JSON.parse(this.localStorage[key]);
+          this[key] = JSON.parse(keyContent);
         }
         catch (e) {
           this.localStorage.removeItem(key);

+ 5 - 5
src/client/js/util/GrowiRenderer.js

@@ -99,13 +99,13 @@ export default class GrowiRenderer {
           new TableConfigurer(crowi)
         ]);
         break;
-      case 'comment':
+      // case 'comment':
+      //   break;
+      default:
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
           new TableConfigurer(crowi)
         ]);
         break;
-      default:
-        break;
     }
   }
 
@@ -151,12 +151,12 @@ export default class GrowiRenderer {
     return this.md.render(markdown);
   }
 
-  postProcess(html, dom) {
+  postProcess(html) {
     for (let i = 0; i < this.postProcessors.length; i++) {
       if (!this.postProcessors[i].process) {
         continue;
       }
-      html = this.postProcessors[i].process(html, dom);
+      html = this.postProcessors[i].process(html);
     }
 
     return html;

+ 7 - 2
src/client/js/util/markdown-it/task-lists.js

@@ -5,8 +5,13 @@ export default class TaskListsConfigurer {
   }
 
   configure(md) {
-    md.use(require('markdown-it-task-lists'), {
-      enabled: true,
+    md.use(require('markdown-it-task-checkbox'), {
+      disabled: true,
+      divWrap: true,
+      divClass: 'checkbox checkbox-primary',
+      idPrefix: 'cbx_',
+      ulClass: 'task-list',
+      liClass: 'task-list-item',
     });
   }
 

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

+ 23 - 15
src/client/styles/scss/_wiki.scss

@@ -79,17 +79,32 @@ div.body {
     }
   }
 
-  // borrowed from https://www.npmjs.com/package/github-markdown-css
-  .contains-task-list {
+  .task-list {
     .task-list-item {
       list-style-type: none;
-    }
-    .task-list-item+.task-list-item {
-      margin-top: 3px;
-    }
-    .task-list-item input {
       margin: 0 0.2em 0.25em -1.6em;
-      vertical-align: middle;
+    }
+    .task-list-item > .task-list {
+      margin-left: 30px;
+    }
+    // use awesome-bootstrap-checkbox
+    .task-list-item .checkbox input[type="checkbox"] {
+      // layout
+      +label {
+        padding-left: 0.3em;
+        &:before {
+          margin-top: 0.4em;
+        }
+      }
+      // styles
+      cursor: default;
+      +label {
+        cursor: default;
+        opacity: 1;
+        &:before, &:after {
+          cursor: default;
+        }
+      }
     }
   }
 
@@ -190,13 +205,6 @@ div.body {
       }
     }
 
-    // borrowed from https://www.npmjs.com/package/github-markdown-css
-    .contains-task-list {
-      .task-list-item input {
-        margin: 0 0.2em * $ratio 0.25em * $ratio -1.6em * $ratio;
-      }
-    }
-
     .revision-head {
       .revision-head-link,
       .revision-head-edit-button {

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

+ 2 - 1
src/server/form/register.js

@@ -9,5 +9,6 @@ module.exports = form(
   field('registerForm.email').required(),
   field('registerForm.password').required().is(/^[\x20-\x7F]{6,}$/),
   field('registerForm.googleId'),
-  field('registerForm.googleImage')
+  field('registerForm.googleImage'),
+  field('registerForm[app:globalLang]')
 );

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

File diff suppressed because it is too large
+ 486 - 629
src/server/models/page.js


+ 0 - 16
src/server/models/revision.js

@@ -40,22 +40,6 @@ module.exports = function(crowi) {
       });
   };
 
-  revisionSchema.statics.findRevision = function(id) {
-    const Revision = this;
-
-    return new Promise(function(resolve, reject) {
-      Revision.findById(id)
-        .populate('author')
-        .exec(function(err, data) {
-          if (err) {
-            return reject(err);
-          }
-
-          return resolve(data);
-        });
-    });
-  };
-
   revisionSchema.statics.findRevisions = function(ids) {
     const Revision = this,
       User = crowi.model('User');

+ 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

@@ -155,7 +155,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);
 
@@ -230,6 +230,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);
 };

+ 9 - 1
src/server/routes/installer.js

@@ -34,13 +34,16 @@ module.exports = function(crowi, app) {
 
   actions.createAdmin = function(req, res) {
     var registerForm = req.body.registerForm || {};
-    var language = req.language || 'en-US';
 
     if (req.form.isValid) {
       var name = registerForm.name;
       var username = registerForm.username;
       var email = registerForm.email;
       var password = registerForm.password;
+      var language = registerForm['app:globalLang'] || (req.language || 'en-US');
+      // for config.globalLang setting.
+      var langForm = {};
+      langForm['app:globalLang'] = language;
 
       User.createUserByEmailAndPassword(name, username, email, password, language, function(err, userData) {
         if (err) {
@@ -69,6 +72,11 @@ module.exports = function(crowi, app) {
           // create initial pages
           createInitialPages(userData, language);
         });
+
+        // save config settings, and update config cache
+        Config.updateNamespaceByArray('crowi', langForm, function(err, config) {
+          Config.updateConfigCache('crowi', config);
+        });
       });
     }
     else {

File diff suppressed because it is too large
+ 301 - 579
src/server/routes/page.js


+ 31 - 22
src/server/routes/revision.js

@@ -1,12 +1,13 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  var debug = require('debug')('growi:routes:revision')
-    , Page = crowi.model('Page')
-    , Revision = crowi.model('Revision')
-    , ApiResponse = require('../util/apiResponse')
-    , actions = {}
-  ;
+  const debug = require('debug')('growi:routes:revision');
+  const logger = require('@alias/logger')('growi:routes:revision');
+  const Page = crowi.model('Page');
+  const Revision = crowi.model('Revision');
+  const ApiResponse = require('../util/apiResponse');
+
+  const actions = {};
   actions.api = {};
 
   /**
@@ -14,23 +15,31 @@ module.exports = function(crowi, app) {
    * @apiName GetRevision
    * @apiGroup Revision
    *
+   * @apiParam {String} page_id Page Id.
    * @apiParam {String} revision_id Revision Id.
    */
-  actions.api.get = function(req, res) {
-    var revisionId = req.query.revision_id;
+  actions.api.get = async function(req, res) {
+    const pageId = req.query.page_id;
+    const revisionId = req.query.revision_id;
 
-    Revision
-      .findRevision(revisionId)
-      .then(function(revisionData) {
-        var result = {
-          revision: revisionData,
-        };
-        return res.json(ApiResponse.success(result));
-      })
-      .catch(function(err) {
-        debug('Error revisios.get', err);
-        return res.json(ApiResponse.error(err));
-      });
+    if (!pageId || !revisionId) {
+      return res.json(ApiResponse.error('Parameter page_id and revision_id are required.'));
+    }
+
+    // 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.'));
+    }
+
+    try {
+      const revision = await Revision.findById(revisionId);
+      return res.json(ApiResponse.success({ revision }));
+    }
+    catch (err) {
+      logger.error('Error revisios.get', err);
+      return res.json(ApiResponse.error(err));
+    }
   };
 
   /**
@@ -44,7 +53,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 +81,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;

+ 456 - 228
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);
+
+        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);
 
-      // 最後にすべてを送信
-      self.client.bulk({
-        body: body,
-        requestTimeout: Infinity,
+          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,49 @@ SearchClient.prototype.addAllPages = function() {
  *   data: [ pages ...],
  * }
  */
-SearchClient.prototype.search = function(query) {
-  var self = this;
-
-  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);
+SearchClient.prototype.search = async function(query) {
+  // for debug
+  if (process.env.NODE_ENV === 'development') {
+    const result = await this.client.indices.validateQuery({
+      explain: true,
+      body: {
+        query: query.body.query
+      },
     });
-  });
+    logger.info('ES returns explanations: ', result.explanations);
+  }
+
+  const result = await this.client.search(query);
+
+  return {
+    meta: {
+      took: result.took,
+      total: result.hits.total,
+      results: result.hits.hits.length,
+    },
+    data: result.hits.hits.map(function(elm) {
+      return { _id: elm._id, _score: elm._score, _source: elm._source };
+    }),
+  };
+
 };
 
 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 +392,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 +417,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 +457,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 +476,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 +495,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,64 +515,196 @@ 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 = async function(query, user, userGroups) {
+  const Config = this.crowi.model('Config');
+  const config = this.crowi.getConfig();
+
+  // determine User condition
+  const hidePagesRestrictedByOwner = Config.hidePagesRestrictedByOwnerInList(config);
+  user = hidePagesRestrictedByOwner ? user : null;
+
+  // determine UserGroup condition
+  const hidePagesRestrictedByGroup = Config.hidePagesRestrictedByGroupInList(config);
+  if (hidePagesRestrictedByGroup && user != null) {
+    const UserGroupRelation = this.crowi.model('UserGroupRelation');
+    userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+  }
+
+  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) {
+    grantConditions.push(
+      { term: { grant: GRANT_USER_GROUP } },
+    );
+  }
+  else if (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 = async 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);
+  await this.filterPagesByViewer(query, user, userGroups);
+
+  this.appendResultSize(query, from, size);
+
+  this.appendFunctionScore(query);
+
   return this.search(query);
 };
 
-SearchClient.prototype.searchByPath = function(keyword, prefix) {
+SearchClient.prototype.searchByPath = async function(keyword, prefix) {
   // TODO path 名だけから検索
 };
 
-SearchClient.prototype.searchKeywordUnderPath = function(keyword, path, option) {
-  var from = option.offset || null;
-  var query = this.createSearchQuerySortedByScore();
+SearchClient.prototype.searchKeywordUnderPath = async 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);
+  await 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 +727,7 @@ SearchClient.prototype.getParsedKeywords = function(keyword) {
     }
 
     if (word.match(/^-(.+)$/)) {
-      notMatchWords.push((RegExp.$1));
+      notMatchWords.push(RegExp.$1);
     }
     else {
       matchWords.push(word);
@@ -526,58 +742,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() }}">

+ 5 - 33
src/server/views/installer.html

@@ -44,44 +44,16 @@
       </div>
     </div>
 
-    <div class="login-dialog p-t-10 p-b-10 col-sm-offset-4 col-sm-4" id="login-dialog">
-      <p class="alert alert-success">
-        <strong>{{ t('installer.create_initial_account') }}</strong><br>
-        <small>{{ t('installer.initial_account_will_be_administrator_automatically') }}</small>
-      </p>
-
-      <div id='installer-form'
-        data-user-name="{{ req.body.registerForm.username }}"
-        data-name="{{ googleName|default(req.body.registerForm.name) }}"
-        data-email="{{ googleEmail|default(req.body.registerForm.email) }}"
-        data-csrf="{{ csrf() }}">
-      </div>
+    <div id='installer-form'
+      data-user-name="{{ req.body.registerForm.username }}"
+      data-name="{{ googleName|default(req.body.registerForm.name) }}"
+      data-email="{{ googleEmail|default(req.body.registerForm.email) }}"
+      data-csrf="{{ csrf() }}">
     </div>
 
   </div>{# /.row #}
 
 </div>{# /.main #}
 
-<script>
-$(function() {
-  $('#register-form input[name="registerForm[username]"]').change(function(e) {
-    var username = $(this).val();
-    $('#login-dialog').removeClass('has-error');
-    $('#input-group-username').removeClass('has-error');
-    $('#help-block-username').html("");
-
-    $.getJSON('/_api/check_username', {username: username}, function(json) {
-      if (!json.valid) {
-        $('#help-block-username').html(
-          '<i class="icon-fw icon-ban"></i>{{ t("installer.unavaliable_user_id") }}'
-        );
-        $('#login-dialog').addClass('has-error');
-        $('#input-group-username').addClass('has-error');
-      }
-    });
-  });
-});
-</script>
-
 {% endblock %}
 

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

+ 0 - 1
src/server/views/widget/forbidden_content.html

@@ -9,7 +9,6 @@
 
 <div id="content-main" class="content-main content-main-not-found page-list"
   data-path="{{ path | preventXss }}"
-  data-path-shortname="{{ path|path2name | preventXss }}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
   >
 

+ 0 - 1
src/server/views/widget/not_found_content.html

@@ -9,7 +9,6 @@
 
 <div id="content-main" class="content-main content-main-not-found page-list"
   data-path="{{ path | preventXss }}"
-  data-path-shortname="{{ path|path2name | preventXss }}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
   >
 

+ 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

+ 12 - 2
src/server/views/widget/page_content.html

@@ -1,8 +1,8 @@
+{% 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 +11,13 @@
   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-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
+  data-slack-channels="{{ slack|default('') }}"
+  >
+{% endif %}
 
   {% include 'page_alerts.html' %}
 
@@ -33,6 +40,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() %}

+ 1 - 2
src/server/views/widget/page_list.html

@@ -11,8 +11,7 @@
   <img src="{{ page.lastUpdateUser|picture }}" class="picture img-circle">
   <a href="{{ page.path }}"
     class="page-list-link"
-    data-path="{{ page.path }}"
-    data-short-path="{{ page.path|path2name }}">{{ decodeURIComponent(page.path) }}
+    data-path="{{ page.path }}">{{ decodeURIComponent(page.path) }}
   </a>
   <span class="page-list-meta">
     {% if page.isPortal() %}

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

@@ -26,7 +26,7 @@
     {% if isEnabledTimeline() %}
     <div class="tab-pane m-t-30" id="view-timeline" data-shown=0>
       {% for page in pages %}
-      <div class="timeline-body" id="id-{{ page.id }}" data-page-path="{{ page.path }}">
+      <div class="timeline-body" id="id-{{ page.id }}" data-page-id="{{ page.id }}" data-page-path="{{ page.path }}" data-revision="{{ page.revision.toString() }}">
         <div class="panel panel-timeline">
           <div class="panel-heading"><a href="{{ page.path }}">{{ decodeURIComponent(page.path) }}</a></div>
           <div class="panel-body">

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

@@ -26,7 +26,7 @@
     {% if isEnabledTimeline() %}
     <div class="tab-pane m-t-30" id="view-timeline" data-shown=0>
       {% for page in pages %}
-      <div class="timeline-body" id="id-{{ page.id }}" data-page-path="{{ page.path }}">
+      <div class="timeline-body" id="id-{{ page.id }}" data-page-id="{{ page.id }}" data-page-path="{{ page.path }}" data-revision="{{ page.revision._id }}">
         <div class="panel panel-timeline">
           <div class="panel-heading"><a href="{{ page.path }}">{{ decodeURIComponent(page.path) }}</a></div>
           <div class="panel-body">

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

+ 4 - 2
wercker.yml

@@ -147,12 +147,12 @@ release: # would be run on release branch
         TMP_RELEASE_BRANCH=tmp/release-$RELEASE_VERSION
         git checkout -B $TMP_RELEASE_BRANCH
         git push -u origin HEAD:$TMP_RELEASE_BRANCH
-        TARGET_COMMITISH=`git rev-parse HEAD`
+        export RELEASE_GIT_COMMIT=`git rev-parse HEAD`
 
     - github-create-release:
       token: $GITHUB_TOKEN
       tag: v$RELEASE_VERSION
-      target-commitish: $TARGET_COMMITISH
+      target-commitish: $RELEASE_GIT_COMMIT
 
     - script:
       name: remove temporary release branch
@@ -180,7 +180,9 @@ release-rc: # would be run on rc/* branches
       name: get RELEASE_VERSION
       code: |
         export RELEASE_VERSION=`npm run version --silent`
+        export RELEASE_GIT_COMMIT=$WERCKER_GIT_COMMIT
         echo "export RELEASE_VERSION=$RELEASE_VERSION"
+        echo "export RELEASE_GIT_COMMIT=$RELEASE_GIT_COMMIT"
 
     - script:
       name: trigger growi-docker release-rc pipeline

+ 45 - 51
yarn.lock

@@ -2189,6 +2189,10 @@ console-control-strings@^1.0.0, console-control-strings@~1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
 
+"consolidated-events@^1.1.0 || ^2.0.0":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/consolidated-events/-/consolidated-events-2.0.2.tgz#da8d8f8c2b232831413d9e190dc11669c79f4a91"
+
 constants-browserify@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75"
@@ -2394,14 +2398,14 @@ css-declaration-sorter@^3.0.0:
     timsort "^0.3.0"
 
 css-loader@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-1.0.0.tgz#9f46aaa5ca41dbe31860e3b62b8e23c42916bf56"
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-1.0.1.tgz#6885bb5233b35ec47b006057da01cc640b6b79fe"
   dependencies:
     babel-code-frame "^6.26.0"
     css-selector-tokenizer "^0.7.0"
     icss-utils "^2.1.0"
     loader-utils "^1.0.2"
-    lodash.camelcase "^4.3.0"
+    lodash "^4.17.11"
     postcss "^6.0.23"
     postcss-modules-extract-imports "^1.2.0"
     postcss-modules-local-by-default "^1.2.0"
@@ -4010,9 +4014,9 @@ googleapis@^16.0.0:
     google-auth-library "~0.10.0"
     string-template "~1.0.0"
 
-googleapis@^35.0.0:
-  version "35.0.0"
-  resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-35.0.0.tgz#958503baa2d32b2702aed7308f8b6abd15a9b5c1"
+googleapis@^36.0.0:
+  version "36.0.0"
+  resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-36.0.0.tgz#9ba4c8b9a5f1b606f6271fabe65edde7d85b65d1"
   dependencies:
     google-auth-library "^2.0.0"
     googleapis-common "^0.4.0"
@@ -5286,10 +5290,6 @@ lodash.assign@^4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7"
 
-lodash.camelcase@^4.3.0:
-  version "4.3.0"
-  resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
-
 lodash.clonedeep@^3.0.0:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-3.0.2.tgz#a0a1e40d82a5ea89ff5b147b8444ed63d92827db"
@@ -5369,7 +5369,7 @@ lodash.uniq@^4.5.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
 
-lodash@4.17.11:
+lodash@4.17.11, lodash@^4.17.11:
   version "4.17.11"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
 
@@ -5500,9 +5500,9 @@ markdown-it-plantuml@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/markdown-it-plantuml/-/markdown-it-plantuml-1.0.0.tgz#7b6a351a1d9275705c09626b02d873301e5899c2"
 
-markdown-it-task-lists@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/markdown-it-task-lists/-/markdown-it-task-lists-2.1.0.tgz#4594f750f70df053d1dad68024388007c1d20783"
+markdown-it-task-checkbox@^1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/markdown-it-task-checkbox/-/markdown-it-task-checkbox-1.0.6.tgz#9ebd7b6382e99162264605bc580f2ac118be4242"
 
 markdown-it-toc-and-anchor-with-slugid@^1.1.4:
   version "1.1.4"
@@ -6691,14 +6691,14 @@ passport-oauth2@1.x.x:
     uid2 "0.0.x"
     utils-merge "1.x.x"
 
-passport-saml@^0.35.0:
-  version "0.35.0"
-  resolved "https://registry.yarnpkg.com/passport-saml/-/passport-saml-0.35.0.tgz#06a4952bde9e003923e80efa5c6faffcf7d4f7e0"
+passport-saml@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/passport-saml/-/passport-saml-1.0.0.tgz#3931bfb7046e85840e3b04691c619411082bf2f5"
   dependencies:
     debug "^3.1.0"
     passport-strategy "*"
     q "^1.5.0"
-    xml-crypto "^0.10.1"
+    xml-crypto "^1.0.2"
     xml-encryption "^0.11.0"
     xml2js "0.4.x"
     xmlbuilder "^9.0.4"
@@ -7011,8 +7011,8 @@ postcss-minify-selectors@^4.0.0:
     postcss-selector-parser "^3.0.0"
 
 postcss-modules-extract-imports@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.2.0.tgz#66140ecece38ef06bf0d3e355d69bf59d141ea85"
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.2.1.tgz#dc87e34148ec7eab5f791f7cd5849833375b741a"
   dependencies:
     postcss "^6.0.1"
 
@@ -7179,15 +7179,7 @@ postcss@^6.0.0:
     source-map "^0.6.1"
     supports-color "^5.3.0"
 
-postcss@^6.0.1:
-  version "6.0.16"
-  resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.16.tgz#112e2fe2a6d2109be0957687243170ea5589e146"
-  dependencies:
-    chalk "^2.3.0"
-    source-map "^0.6.1"
-    supports-color "^5.1.0"
-
-postcss@^6.0.23:
+postcss@^6.0.1, postcss@^6.0.23:
   version "6.0.23"
   resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324"
   dependencies:
@@ -7251,6 +7243,13 @@ prop-types-extra@^1.0.1:
   dependencies:
     warning "^3.0.0"
 
+prop-types@^15.0.0, prop-types@^15.6.2:
+  version "15.6.2"
+  resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102"
+  dependencies:
+    loose-envify "^1.3.1"
+    object-assign "^4.1.1"
+
 prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.0:
   version "15.6.0"
   resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856"
@@ -7267,13 +7266,6 @@ prop-types@^15.6.1:
     loose-envify "^1.3.1"
     object-assign "^4.1.1"
 
-prop-types@^15.6.2:
-  version "15.6.2"
-  resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102"
-  dependencies:
-    loose-envify "^1.3.1"
-    object-assign "^4.1.1"
-
 proxy-addr@~2.0.2:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.2.tgz#6571504f47bb988ec8180253f85dd7e14952bdec"
@@ -7499,6 +7491,10 @@ react-i18next@=7.13.0:
     html-parse-stringify2 "2.0.1"
     prop-types "^15.6.0"
 
+react-is@^16.6.3:
+  version "16.6.3"
+  resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.6.3.tgz#d2d7462fcfcbe6ec0da56ad69047e47e56e7eac0"
+
 react-onclickoutside@^6.1.1:
   version "6.7.0"
   resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.7.0.tgz#997a4d533114c9a0a104913638aa26afc084f75c"
@@ -7538,6 +7534,14 @@ react-transition-group@^2.0.0, react-transition-group@^2.2.0:
     prop-types "^15.5.8"
     warning "^3.0.0"
 
+react-waypoint@^8.1.0:
+  version "8.1.0"
+  resolved "https://registry.yarnpkg.com/react-waypoint/-/react-waypoint-8.1.0.tgz#91d926a2fd1be4cbd0351cb8c3d494fac0ef1699"
+  dependencies:
+    consolidated-events "^1.1.0 || ^2.0.0"
+    prop-types "^15.0.0"
+    react-is "^16.6.3"
+
 react@^16.4.1:
   version "16.4.1"
   resolved "https://registry.yarnpkg.com/react/-/react-16.4.1.tgz#de51ba5764b5dbcd1f9079037b862bd26b82fe32"
@@ -8806,12 +8810,6 @@ supports-color@^4.0.0:
   dependencies:
     has-flag "^2.0.0"
 
-supports-color@^5.1.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.1.0.tgz#058a021d1b619f7ddf3980d712ea3590ce7de3d5"
-  dependencies:
-    has-flag "^2.0.0"
-
 supports-color@^5.3.0:
   version "5.3.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.3.0.tgz#5b24ac15db80fa927cf5227a4a33fd3c4c7676c0"
@@ -9580,11 +9578,11 @@ x-xss-protection@1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/x-xss-protection/-/x-xss-protection-1.1.0.tgz#4f1898c332deb1e7f2be1280efb3e2c53d69c1a7"
 
-xml-crypto@^0.10.1:
-  version "0.10.1"
-  resolved "https://registry.yarnpkg.com/xml-crypto/-/xml-crypto-0.10.1.tgz#f832f74ccf56f24afcae1163a1fcab44d96774a8"
+xml-crypto@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/xml-crypto/-/xml-crypto-1.0.2.tgz#248df860b1e3f7326e61bcbd00c234886b0d6e3b"
   dependencies:
-    xmldom "=0.1.19"
+    xmldom "0.1.27"
     xpath.js ">=0.0.3"
 
 xml-encryption@^0.11.0:
@@ -9621,14 +9619,10 @@ xmlbuilder@^9.0.4, xmlbuilder@~9.0.1:
   version "9.0.7"
   resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d"
 
-xmldom@0.1.x, xmldom@~0.1.15:
+xmldom@0.1.27, xmldom@0.1.x, xmldom@~0.1.15:
   version "0.1.27"
   resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.27.tgz#d501f97b3bdb403af8ef9ecc20573187aadac0e9"
 
-xmldom@=0.1.19:
-  version "0.1.19"
-  resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.19.tgz#631fc07776efd84118bf25171b37ed4d075a0abc"
-
 xmlhttprequest-ssl@~1.5.4:
   version "1.5.4"
   resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.4.tgz#04f560915724b389088715cc0ed7813e9677bf57"

Some files were not shown because too many files changed in this diff